149 lines
3.9 KiB
Go
149 lines
3.9 KiB
Go
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))
|
|
}
|