Cache keyed by sha256(absPath|quality|audioIdx)[:8] with .complete marker; LRU + size-budget eviction; per-key writer-lock; pinned during play; startup orphan reap; integrity verify on HIT; subtitle-completeness gate; hit/miss counters + daily log line. New [downloads.hls_cache] block in config.toml (enabled/size_gb/dir, default 5GB). Smoke test: 2nd play of same source+quality is 23-31× faster (HIT path skips ffmpeg entirely).
134 lines
3.7 KiB
Go
134 lines
3.7 KiB
Go
//go:build smoke
|
||
|
||
package engine
|
||
|
||
import (
|
||
"context"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// TestHLSCacheSmoke exercises the end-to-end cache flow against real ffmpeg:
|
||
// - First session encodes a 5s test pattern; expect MISS, ffmpeg runs,
|
||
// .complete written, MarkComplete logs.
|
||
// - Second session for identical (source, quality, audio); expect HIT,
|
||
// no ffmpeg, instant Start.
|
||
//
|
||
// Build tag `smoke` keeps it out of the default `go test ./...` run because
|
||
// it depends on a working ffmpeg/ffprobe and takes ~5–10 s.
|
||
//
|
||
// go test -tags=smoke -run TestHLSCacheSmoke -v ./internal/engine/
|
||
func TestHLSCacheSmoke(t *testing.T) {
|
||
ffmpeg, err := exec.LookPath("ffmpeg")
|
||
if err != nil {
|
||
t.Skipf("ffmpeg not on PATH: %v", err)
|
||
}
|
||
ffprobe, err := exec.LookPath("ffprobe")
|
||
if err != nil {
|
||
t.Skipf("ffprobe not on PATH: %v", err)
|
||
}
|
||
|
||
tmp := t.TempDir()
|
||
source := filepath.Join(tmp, "source.mp4")
|
||
t.Logf("generating 5 s test pattern → %s", source)
|
||
if out, err := exec.Command(ffmpeg,
|
||
"-y", "-loglevel", "error",
|
||
"-f", "lavfi", "-i", "testsrc=duration=5:size=640x480:rate=30",
|
||
"-f", "lavfi", "-i", "sine=frequency=1000:duration=5",
|
||
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p",
|
||
"-c:a", "aac",
|
||
source,
|
||
).CombinedOutput(); err != nil {
|
||
t.Fatalf("ffmpeg generate: %v\n%s", err, out)
|
||
}
|
||
|
||
cacheRoot := filepath.Join(tmp, "cache")
|
||
cache, err := NewHLSCache(cacheRoot, 1)
|
||
if err != nil {
|
||
t.Fatalf("NewHLSCache: %v", err)
|
||
}
|
||
|
||
cfg := HLSSessionConfig{
|
||
SessionID: "smoke1",
|
||
SourcePath: source,
|
||
FileName: "source.mp4",
|
||
Quality: "720p",
|
||
AudioIndex: 0,
|
||
Transcode: TranscodeRuntime{
|
||
FFmpegPath: ffmpeg,
|
||
FFprobePath: ffprobe,
|
||
Preset: "ultrafast",
|
||
},
|
||
Cache: cache,
|
||
}
|
||
|
||
// First run — expect MISS, ffmpeg runs.
|
||
t.Log("session 1: expect MISS")
|
||
t0 := time.Now()
|
||
s1, err := StartHLSSession(context.Background(), cfg)
|
||
if err != nil {
|
||
t.Fatalf("StartHLSSession #1: %v", err)
|
||
}
|
||
if s1.fromCache {
|
||
t.Fatal("session 1 reported cache HIT on a fresh cache")
|
||
}
|
||
|
||
// Wait for all segments to land. 5 s source @ 4 s segments → 2 segments.
|
||
deadline := time.Now().Add(60 * time.Second)
|
||
for {
|
||
s1.readyMu.Lock()
|
||
ready := s1.readyMax
|
||
exited := s1.exited
|
||
s1.readyMu.Unlock()
|
||
if ready >= s1.segmentCount-1 && exited {
|
||
break
|
||
}
|
||
if time.Now().After(deadline) {
|
||
_ = s1.Close()
|
||
t.Fatalf("session 1 didn't finish in 60 s (readyMax=%d/%d, exited=%v)",
|
||
ready, s1.segmentCount-1, exited)
|
||
}
|
||
time.Sleep(100 * time.Millisecond)
|
||
}
|
||
if err := s1.Close(); err != nil {
|
||
t.Fatalf("Close #1: %v", err)
|
||
}
|
||
encodeDur := time.Since(t0)
|
||
t.Logf("session 1: MISS completed in %s", encodeDur.Round(time.Millisecond))
|
||
|
||
key := cache.KeyFor(source, "720p", 0)
|
||
if !cache.HasComplete(key) {
|
||
t.Fatalf("cache.HasComplete(%s) is false after successful encode", key)
|
||
}
|
||
|
||
// Second run — expect HIT, no ffmpeg.
|
||
t.Log("session 2: expect HIT")
|
||
cfg.SessionID = "smoke2"
|
||
t1 := time.Now()
|
||
s2, err := StartHLSSession(context.Background(), cfg)
|
||
if err != nil {
|
||
t.Fatalf("StartHLSSession #2: %v", err)
|
||
}
|
||
if !s2.fromCache {
|
||
t.Fatal("session 2 should have reported cache HIT")
|
||
}
|
||
if s2.cmd != nil {
|
||
t.Fatal("session 2 should not have spawned ffmpeg (s.cmd != nil)")
|
||
}
|
||
hitDur := time.Since(t1)
|
||
t.Logf("session 2: HIT in %s (%.1f× faster than MISS)",
|
||
hitDur.Round(time.Millisecond), float64(encodeDur)/float64(hitDur))
|
||
if hitDur > 500*time.Millisecond {
|
||
t.Errorf("HIT path too slow: %s — expected <500 ms", hitDur)
|
||
}
|
||
if err := s2.Close(); err != nil {
|
||
t.Fatalf("Close #2: %v", err)
|
||
}
|
||
|
||
// After the HIT session closes, the cache dir + .complete must still exist.
|
||
if !cache.HasComplete(key) {
|
||
t.Fatal(".complete disappeared after HIT session closed")
|
||
}
|
||
}
|