enki b3204ea07a
Some checks are pending
CI Pipeline / Run Tests (push) Waiting to run
CI Pipeline / Lint Code (push) Waiting to run
CI Pipeline / Security Scan (push) Waiting to run
CI Pipeline / Build Docker Images (push) Blocked by required conditions
CI Pipeline / E2E Tests (push) Blocked by required conditions
first commit
2025-08-18 00:40:15 -07:00

370 lines
9.3 KiB
Go

package streaming
import (
"fmt"
"mime"
"path/filepath"
"strconv"
"strings"
)
const (
// HLS segment duration in seconds
DefaultSegmentDuration = 6.0
// Target segment size in bytes (approximately)
DefaultTargetSegmentSize = 2 * 1024 * 1024 // 2MB to match our chunk size
)
type HLSConfig struct {
SegmentDuration float64
TargetSegmentSize int64
PlaylistType string // VOD or LIVE
AllowCache bool
Version int
}
type MediaSegment struct {
Index int
Duration float64
Size int64
ChunkIndexes []int // Which chunks make up this segment
URI string
}
type HLSPlaylist struct {
Config HLSConfig
Segments []MediaSegment
TotalDuration float64
TargetDuration float64
MediaSequence int
EndList bool
}
type FileInfo struct {
Name string
Size int64
ChunkCount int
ChunkSize int
Duration float64 // For video files, estimated duration
IsVideo bool
MimeType string
}
// DefaultHLSConfig returns default HLS configuration
func DefaultHLSConfig() HLSConfig {
return HLSConfig{
SegmentDuration: DefaultSegmentDuration,
TargetSegmentSize: DefaultTargetSegmentSize,
PlaylistType: "VOD",
AllowCache: true,
Version: 3,
}
}
// DetectMediaType determines if a file is a video and its MIME type
func DetectMediaType(filename string) (bool, string) {
ext := strings.ToLower(filepath.Ext(filename))
mimeType := mime.TypeByExtension(ext)
videoExtensions := map[string]bool{
".mp4": true,
".mkv": true,
".avi": true,
".mov": true,
".wmv": true,
".flv": true,
".webm": true,
".m4v": true,
".3gp": true,
".ts": true,
}
isVideo := videoExtensions[ext]
if mimeType == "" {
if isVideo {
// Default MIME type for unknown video extensions
mimeType = "video/mp4"
} else {
mimeType = "application/octet-stream"
}
}
return isVideo, mimeType
}
// EstimateVideoDuration provides a rough estimation of video duration based on file size
// This is a simple heuristic - in production you'd use ffprobe or similar
func EstimateVideoDuration(fileSize int64, filename string) float64 {
// Very rough estimation: assume different bitrates based on file extension
ext := strings.ToLower(filepath.Ext(filename))
var estimatedBitrate int64 // bits per second
switch ext {
case ".mp4", ".m4v":
estimatedBitrate = 2000000 // 2 Mbps average
case ".mkv":
estimatedBitrate = 3000000 // 3 Mbps average
case ".avi":
estimatedBitrate = 1500000 // 1.5 Mbps average
case ".webm":
estimatedBitrate = 1000000 // 1 Mbps average
default:
estimatedBitrate = 2000000 // Default 2 Mbps
}
// Duration = (file size in bits) / bitrate
fileSizeInBits := fileSize * 8
duration := float64(fileSizeInBits) / float64(estimatedBitrate)
// Ensure minimum duration of 10 seconds for very small files
if duration < 10.0 {
duration = 10.0
}
return duration
}
// GenerateHLSSegments creates HLS segments from file chunks
func GenerateHLSSegments(fileInfo FileInfo, config HLSConfig) (*HLSPlaylist, error) {
if !fileInfo.IsVideo {
return nil, fmt.Errorf("file is not a video: %s", fileInfo.Name)
}
playlist := &HLSPlaylist{
Config: config,
Segments: make([]MediaSegment, 0),
MediaSequence: 0,
EndList: true, // VOD content
}
// Calculate number of segments based on duration and target segment duration
totalSegments := int(fileInfo.Duration/config.SegmentDuration) + 1
if totalSegments < 1 {
totalSegments = 1
}
segmentDuration := fileInfo.Duration / float64(totalSegments)
playlist.TargetDuration = segmentDuration
// Calculate chunks per segment
chunksPerSegment := fileInfo.ChunkCount / totalSegments
if chunksPerSegment < 1 {
chunksPerSegment = 1
}
// Generate segments
for i := 0; i < totalSegments; i++ {
startChunk := i * chunksPerSegment
endChunk := startChunk + chunksPerSegment
// Handle last segment
if i == totalSegments-1 {
endChunk = fileInfo.ChunkCount
}
// Ensure we don't exceed chunk count
if endChunk > fileInfo.ChunkCount {
endChunk = fileInfo.ChunkCount
}
chunkIndexes := make([]int, 0)
for j := startChunk; j < endChunk; j++ {
chunkIndexes = append(chunkIndexes, j)
}
segmentSize := int64(len(chunkIndexes)) * int64(fileInfo.ChunkSize)
segment := MediaSegment{
Index: i,
Duration: segmentDuration,
Size: segmentSize,
ChunkIndexes: chunkIndexes,
URI: fmt.Sprintf("segment_%d.ts", i),
}
playlist.Segments = append(playlist.Segments, segment)
}
playlist.TotalDuration = fileInfo.Duration
return playlist, nil
}
// GenerateM3U8Manifest creates the HLS playlist manifest
func (p *HLSPlaylist) GenerateM3U8Manifest(baseURL string) string {
var builder strings.Builder
// Header
builder.WriteString("#EXTM3U\n")
builder.WriteString(fmt.Sprintf("#EXT-X-VERSION:%d\n", p.Config.Version))
builder.WriteString(fmt.Sprintf("#EXT-X-TARGETDURATION:%d\n", int(p.TargetDuration)+1))
builder.WriteString(fmt.Sprintf("#EXT-X-MEDIA-SEQUENCE:%d\n", p.MediaSequence))
builder.WriteString(fmt.Sprintf("#EXT-X-PLAYLIST-TYPE:%s\n", p.Config.PlaylistType))
if !p.Config.AllowCache {
builder.WriteString("#EXT-X-ALLOW-CACHE:NO\n")
}
// Segments
for _, segment := range p.Segments {
builder.WriteString(fmt.Sprintf("#EXTINF:%.3f,\n", segment.Duration))
segmentURL := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), segment.URI)
builder.WriteString(segmentURL + "\n")
}
// End marker for VOD
if p.EndList {
builder.WriteString("#EXT-X-ENDLIST\n")
}
return builder.String()
}
// GetSegmentByIndex returns a segment by its index
func (p *HLSPlaylist) GetSegmentByIndex(index int) (*MediaSegment, error) {
if index < 0 || index >= len(p.Segments) {
return nil, fmt.Errorf("segment index %d out of range (0-%d)", index, len(p.Segments)-1)
}
return &p.Segments[index], nil
}
// GetSegmentByURI returns a segment by its URI
func (p *HLSPlaylist) GetSegmentByURI(uri string) (*MediaSegment, error) {
for _, segment := range p.Segments {
if segment.URI == uri {
return &segment, nil
}
}
return nil, fmt.Errorf("segment not found: %s", uri)
}
// ParseSegmentURI extracts segment index from URI like "segment_0.ts"
func ParseSegmentURI(uri string) (int, error) {
// Remove extension
name := strings.TrimSuffix(uri, filepath.Ext(uri))
// Extract number from "segment_N" format
parts := strings.Split(name, "_")
if len(parts) != 2 || parts[0] != "segment" {
return 0, fmt.Errorf("invalid segment URI format: %s", uri)
}
index, err := strconv.Atoi(parts[1])
if err != nil {
return 0, fmt.Errorf("invalid segment index in URI %s: %v", uri, err)
}
return index, nil
}
// RangeRequest represents an HTTP range request
type RangeRequest struct {
Start int64
End int64
Size int64
}
// ParseRangeHeader parses HTTP Range header like "bytes=0-1023"
func ParseRangeHeader(rangeHeader string, fileSize int64) (*RangeRequest, error) {
if rangeHeader == "" {
return nil, nil
}
// Remove "bytes=" prefix
if !strings.HasPrefix(rangeHeader, "bytes=") {
return nil, fmt.Errorf("invalid range header format: %s", rangeHeader)
}
rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=")
// Handle different range formats
if strings.Contains(rangeSpec, ",") {
// Multiple ranges not supported for simplicity
return nil, fmt.Errorf("multiple ranges not supported")
}
parts := strings.Split(rangeSpec, "-")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid range format: %s", rangeSpec)
}
var start, end int64
var err error
// Parse start
if parts[0] != "" {
start, err = strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid start range: %v", err)
}
}
// Parse end
if parts[1] != "" {
end, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid end range: %v", err)
}
} else {
// If no end specified, use file size - 1
end = fileSize - 1
}
// Handle suffix-byte-range-spec (e.g., "-500" means last 500 bytes)
if parts[0] == "" {
start = fileSize - end
end = fileSize - 1
}
// Validate range
if start < 0 {
start = 0
}
if end >= fileSize {
end = fileSize - 1
}
if start > end {
return nil, fmt.Errorf("invalid range: start %d > end %d", start, end)
}
return &RangeRequest{
Start: start,
End: end,
Size: end - start + 1,
}, nil
}
// FormatContentRange formats the Content-Range header
func (r *RangeRequest) FormatContentRange(fileSize int64) string {
return fmt.Sprintf("bytes %d-%d/%d", r.Start, r.End, fileSize)
}
// ChunkRange represents which chunks and byte offsets are needed for a range request
type ChunkRange struct {
StartChunk int
EndChunk int
StartOffset int64 // Byte offset within start chunk
EndOffset int64 // Byte offset within end chunk
TotalBytes int64
}
// CalculateChunkRange determines which chunks are needed for a byte range
func CalculateChunkRange(rangeReq *RangeRequest, chunkSize int) *ChunkRange {
startChunk := int(rangeReq.Start / int64(chunkSize))
endChunk := int(rangeReq.End / int64(chunkSize))
startOffset := rangeReq.Start % int64(chunkSize)
endOffset := rangeReq.End % int64(chunkSize)
return &ChunkRange{
StartChunk: startChunk,
EndChunk: endChunk,
StartOffset: startOffset,
EndOffset: endOffset,
TotalBytes: rangeReq.Size,
}
}