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, } }