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
370 lines
9.3 KiB
Go
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,
|
|
}
|
|
} |