Files
silo-server/internal/playback/copy_seek_anchor_test.go
845b96e703 fix(playback): preserve remux copy on seek (#422)
* fix(playback): preserve remux copy on seek

* fix(playback): harden remux replacement transactions

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-20 13:25:46 -04:00

175 lines
5.5 KiB
Go

package playback
import (
"context"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestResolveCopySeekAnchorUsesKeyPacketTimestamp(t *testing.T) {
dir := t.TempDir()
ffmpegPath := filepath.Join(dir, "ffmpeg")
ffprobePath := filepath.Join(dir, "ffprobe")
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write fake ffmpeg: %v", err)
}
probe := `#!/bin/sh
printf '%s' '{"packets":[{"pts_time":"N/A","dts_time":"14.500000","flags":"K__"}]}'
`
if err := os.WriteFile(ffprobePath, []byte(probe), 0o755); err != nil {
t.Fatalf("write fake ffprobe: %v", err)
}
anchor, segment, err := ResolveCopySeekAnchor(context.Background(), ffmpegPath, "/media/movie.mkv", 18.261, 2)
if err != nil {
t.Fatalf("ResolveCopySeekAnchor: %v", err)
}
if anchor != 14.5 || segment != 7 {
t.Fatalf("resolved anchor = %v, segment = %d; want 14.5, 7", anchor, segment)
}
}
func TestResolveCopySeekAnchorCoalescesMatchingConcurrentProbes(t *testing.T) {
dir := t.TempDir()
ffmpegPath := filepath.Join(dir, "ffmpeg")
ffprobePath := filepath.Join(dir, "ffprobe")
countPath := filepath.Join(dir, "probe-count")
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write fake ffmpeg: %v", err)
}
probe := "#!/bin/sh\n" +
"printf x >> \"" + countPath + "\"\n" +
"sleep 0.1\n" +
"printf '%s' '{\"packets\":[{\"pts_time\":\"16.000000\",\"flags\":\"K__\"}]}'\n"
if err := os.WriteFile(ffprobePath, []byte(probe), 0o755); err != nil {
t.Fatalf("write fake ffprobe: %v", err)
}
start := make(chan struct{})
errs := make(chan error, 2)
var ready sync.WaitGroup
ready.Add(2)
for range 2 {
go func() {
ready.Done()
<-start
anchor, segment, err := ResolveCopySeekAnchor(context.Background(), ffmpegPath, "/media/movie.mkv", 18, 2)
if err == nil && (anchor != 16 || segment != 8) {
err = fmt.Errorf("resolved anchor = %v, segment = %d", anchor, segment)
}
errs <- err
}()
}
ready.Wait()
close(start)
for range 2 {
if err := <-errs; err != nil {
t.Fatal(err)
}
}
count, err := os.ReadFile(countPath)
if err != nil {
t.Fatalf("read probe count: %v", err)
}
if string(count) != "x" {
t.Fatalf("ffprobe executions = %d, want 1", len(count))
}
}
func TestResolveCopySeekAnchorMatchesRealLongGOPHEVC(t *testing.T) {
if testing.Short() {
t.Skip("real FFmpeg integration test")
}
ffmpegPath, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("ffmpeg is not installed")
}
if _, err := exec.LookPath(ffprobePathFromFFmpeg(ffmpegPath)); err != nil {
t.Skip("ffprobe is not installed beside ffmpeg")
}
encoders, err := exec.Command(ffmpegPath, "-hide_banner", "-encoders").CombinedOutput()
if err != nil || !strings.Contains(string(encoders), "libx265") {
t.Skip("ffmpeg does not provide libx265")
}
sourcePath := filepath.Join(t.TempDir(), "long-gop-hevc.mkv")
encodeCtx, cancelEncode := context.WithTimeout(context.Background(), 20*time.Second)
defer cancelEncode()
encode := exec.CommandContext(encodeCtx, ffmpegPath,
"-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc2=size=320x180:rate=24",
"-t", "22",
"-c:v", "libx265", "-preset", "ultrafast",
"-x265-params", "keyint=240:min-keyint=240:scenecut=0:log-level=error:pools=1:frame-threads=1",
"-an", "-y", sourcePath,
)
if output, err := encode.CombinedOutput(); err != nil {
t.Fatalf("generate long-GOP HEVC fixture: %v\n%s", err, output)
}
probeCtx, cancelProbe := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelProbe()
anchor, segment, err := ResolveCopySeekAnchor(probeCtx, ffmpegPath, sourcePath, 18.261, 2)
if err != nil {
t.Fatalf("ResolveCopySeekAnchor: %v", err)
}
if math.Abs(anchor-10) > 0.001 || segment != 5 {
t.Fatalf("resolved anchor = %v, segment = %d; want 10, 5", anchor, segment)
}
outputDir := filepath.Join(t.TempDir(), "hls")
if err := os.MkdirAll(outputDir, 0o755); err != nil {
t.Fatalf("create HLS output: %v", err)
}
opts := TranscodeOpts{
InputPath: sourcePath,
OutputDir: outputDir,
SessionID: "copy-anchor-integration",
SourceVideoCodec: "hevc",
SeekSeconds: 18.261,
StreamOriginSeconds: anchor,
CopySeekAnchorResolved: true,
TargetCodecVideo: "copy",
TargetCodecAudio: "copy",
SegmentDuration: 2,
StartSegmentNumber: segment,
FFmpegPath: ffmpegPath,
}
packageCtx, cancelPackage := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelPackage()
packageHLS := exec.CommandContext(packageCtx, ffmpegPath, buildFFmpegArgs(opts)...)
if output, err := packageHLS.CombinedOutput(); err != nil {
t.Fatalf("package copy HLS: %v\n%s", err, output)
}
manifestPath := filepath.Join(outputDir, "stream.m3u8")
manifest, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("read copy HLS manifest: %v", err)
}
if !strings.Contains(string(manifest), "#EXT-X-MEDIA-SEQUENCE:5") {
t.Fatalf("copy HLS media sequence does not use resolved anchor:\n%s", manifest)
}
firstFrame, err := exec.Command(ffprobePathFromFFmpeg(ffmpegPath),
"-v", "error",
"-select_streams", "v:0",
"-read_intervals", "%+0.1",
"-show_entries", "frame=best_effort_timestamp_time,key_frame",
"-of", "csv=p=0",
manifestPath,
).CombinedOutput()
if err != nil {
t.Fatalf("probe copy HLS first frame: %v\n%s", err, firstFrame)
}
if !strings.Contains(string(firstFrame), "10.000000") {
t.Fatalf("copy HLS first frame = %q, want resolved 10-second keyframe", firstFrame)
}
}