package transcoding import ( "context" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" ) // segmentSeconds is the target HLS segment duration. const segmentSeconds = 10 // HLSSession holds the completed segments for one (source, bitrate) // combination, cached on disk so repeated segment requests (which every // HLS client issues, one per segment) don't re-transcode. type HLSSession struct { Dir string SegmentCount int } // hlsCache maps a cache key (source path + options) to its completed // session, so concurrent/repeated requests for the same transcode reuse // one ffmpeg run instead of racing separate ones. type hlsCacheEntry struct { once sync.Once session *HLSSession err error } var ( hlsCacheMu sync.Mutex hlsCache = map[string]*hlsCacheEntry{} ) func hlsCacheKey(sourcePath string, opts Options) string { return fmt.Sprintf("%s|%d|%s", sourcePath, opts.MaxBitRateKbps, opts.Format) } // BuildHLS transcodes and segments sourcePath into cacheDir (once per // distinct source+options; concurrent callers for the same key share the // result), returning the session describing the produced .ts segments. func (m *Manager) BuildHLS(ctx context.Context, sourcePath, cacheDir string, opts Options) (*HLSSession, error) { key := hlsCacheKey(sourcePath, opts) hlsCacheMu.Lock() entry, ok := hlsCache[key] if !ok { entry = &hlsCacheEntry{} hlsCache[key] = entry } hlsCacheMu.Unlock() entry.once.Do(func() { entry.session, entry.err = m.buildHLSOnce(ctx, sourcePath, cacheDir, opts, key) }) return entry.session, entry.err } func (m *Manager) buildHLSOnce(ctx context.Context, sourcePath, cacheDir string, opts Options, key string) (*HLSSession, error) { select { case m.sem <- struct{}{}: case <-ctx.Done(): return nil, ctx.Err() } defer func() { <-m.sem }() dir := filepath.Join(cacheDir, "hls", sanitizeKey(key)) if err := os.MkdirAll(dir, 0o755); err != nil { return nil, err } playlistPath := filepath.Join(dir, "index.m3u8") segmentPattern := filepath.Join(dir, "seg%05d.ts") bitrate := opts.MaxBitRateKbps if bitrate <= 0 { bitrate = defaultBitRateKbps } args := []string{ "-i", sourcePath, "-c:v", "libx264", "-preset", "veryfast", "-c:a", "aac", "-b:a", fmt.Sprintf("%dk", bitrate), "-f", "hls", "-hls_time", strconv.Itoa(segmentSeconds), "-hls_list_size", "0", "-hls_segment_filename", segmentPattern, "--", playlistPath, } cmd := exec.CommandContext(ctx, m.FFmpegPath, args...) if out, err := cmd.CombinedOutput(); err != nil { return nil, fmt.Errorf("ffmpeg hls: %w: %s", err, out) } entries, err := os.ReadDir(dir) if err != nil { return nil, err } count := 0 for _, e := range entries { if strings.HasSuffix(e.Name(), ".ts") { count++ } } return &HLSSession{Dir: dir, SegmentCount: count}, nil } func sanitizeKey(key string) string { var b strings.Builder for _, r := range key { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': b.WriteRune(r) default: b.WriteByte('_') } } return b.String() } // Playlist returns the m3u8 media playlist text for a completed session, // with segment URIs built by segmentURL(index). func (s *HLSSession) Playlist(segmentURL func(index int) string) string { var b strings.Builder b.WriteString("#EXTM3U\n") b.WriteString("#EXT-X-VERSION:3\n") b.WriteString(fmt.Sprintf("#EXT-X-TARGETDURATION:%d\n", segmentSeconds)) b.WriteString("#EXT-X-PLAYLIST-TYPE:VOD\n") for i := 0; i < s.SegmentCount; i++ { b.WriteString(fmt.Sprintf("#EXTINF:%d.0,\n", segmentSeconds)) b.WriteString(segmentURL(i)) b.WriteString("\n") } b.WriteString("#EXT-X-ENDLIST\n") return b.String() } // SegmentPath returns the on-disk path for segment index, or "" if out // of range. func (s *HLSSession) SegmentPath(index int) string { if index < 0 || index >= s.SegmentCount { return "" } return filepath.Join(s.Dir, fmt.Sprintf("seg%05d.ts", index)) }