test(streaming): integration tests with real ffmpeg (skipped without it)
Three end-to-end checks that the transcoder actually produces playable output, not just plausible argv. Skip cleanly on hosts without ffmpeg on PATH so unit-test CI keeps working. - TestTranscoder_DirectPlayProducesH264 — synth h264+aac MP4 via `ffmpeg -f lavfi testsrc/sine`, run Analyze (expect direct play), Stream to disk, ffprobe the result, assert codecs are still h264+aac. - TestTranscoder_TranscodeHEVCToH264 — synth hevc+ac3 MKV, expect transcode decision, Stream to memory, ffprobe-verify the output is h264+aac. Skipped if libx265 isn't compiled in. - TestTranscoder_AnalyzeReportsRealMediaInfo — sanity check that Analyze returns a usable mediainfo (320x240, ~2s duration) the API handler can show to the player. Verified locally: PASS: TestTranscoder_DirectPlayProducesH264 (0.09s) PASS: TestTranscoder_TranscodeHEVCToH264 (0.22s) PASS: TestTranscoder_AnalyzeReportsRealMediaInfo (0.06s)
This commit is contained in:
parent
75dcc0f1cb
commit
c2e9925162
1 changed files with 204 additions and 0 deletions
204
internal/streaming/integration_test.go
Normal file
204
internal/streaming/integration_test.go
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
package streaming
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/torrentclaw/unarr/internal/library/mediainfo"
|
||||
)
|
||||
|
||||
// These tests need a real ffmpeg + ffprobe on PATH. They're skipped on
|
||||
// CI runners that lack them — the unit tests already pin the recipes
|
||||
// deterministically. Run locally when changing the transcoder pipeline.
|
||||
|
||||
func resolveBins(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
ffmpeg, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
t.Skip("ffmpeg not on PATH — skipping integration test")
|
||||
}
|
||||
ffprobe, err := exec.LookPath("ffprobe")
|
||||
if err != nil {
|
||||
t.Skip("ffprobe not on PATH — skipping integration test")
|
||||
}
|
||||
return ffmpeg, ffprobe
|
||||
}
|
||||
|
||||
// generateTestVideo synthesises a short MP4 for the transcoder to chew on.
|
||||
// vcodec/acodec let us exercise both direct-play and transcode branches.
|
||||
func generateTestVideo(t *testing.T, ffmpeg, dir, vcodec, acodec, container string) string {
|
||||
t.Helper()
|
||||
out := filepath.Join(dir, "sample."+container)
|
||||
args := []string{
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-f", "lavfi", "-i", "testsrc=duration=2:size=320x240:rate=15",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=2",
|
||||
"-c:v", vcodec,
|
||||
}
|
||||
// libx265 needs at least one keyframe; 2s @ 15fps is fine.
|
||||
if vcodec == "libx265" {
|
||||
args = append(args, "-x265-params", "log-level=error")
|
||||
}
|
||||
args = append(args, "-c:a", acodec, "-shortest", out)
|
||||
cmd := exec.Command(ffmpeg, args...)
|
||||
if buf, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Skipf("could not synthesise test video (%s/%s/%s): %v\n%s",
|
||||
vcodec, acodec, container, err, buf)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// probeOutput uses ffprobe to inspect the (synthesised) transcoder output
|
||||
// and returns video + audio codec names.
|
||||
func probeOutput(t *testing.T, ffprobe, path string) (string, string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(ffprobe,
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-print_format", "json", "-show_streams", path)
|
||||
buf, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("ffprobe %s: %v", path, err)
|
||||
}
|
||||
var data struct {
|
||||
Streams []struct {
|
||||
CodecType string `json:"codec_type"`
|
||||
CodecName string `json:"codec_name"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
if err := json.Unmarshal(buf, &data); err != nil {
|
||||
t.Fatalf("ffprobe parse: %v", err)
|
||||
}
|
||||
var v, a string
|
||||
for _, s := range data.Streams {
|
||||
switch s.CodecType {
|
||||
case "video":
|
||||
v = s.CodecName
|
||||
case "audio":
|
||||
a = s.CodecName
|
||||
}
|
||||
}
|
||||
return v, a
|
||||
}
|
||||
|
||||
// TestTranscoder_DirectPlayProducesH264 — H.264 + AAC source → direct play
|
||||
// → output keeps both codecs, just remuxed to fMP4.
|
||||
func TestTranscoder_DirectPlayProducesH264(t *testing.T) {
|
||||
ffmpeg, ffprobe := resolveBins(t)
|
||||
dir := t.TempDir()
|
||||
src := generateTestVideo(t, ffmpeg, dir, "libx264", "aac", "mp4")
|
||||
|
||||
tr, err := NewTranscoder(ffmpeg, ffprobe)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTranscoder: %v", err)
|
||||
}
|
||||
|
||||
report, _, err := tr.Analyze(context.Background(), src)
|
||||
if err != nil {
|
||||
t.Fatalf("Analyze: %v", err)
|
||||
}
|
||||
if !report.DirectPlay {
|
||||
t.Fatalf("h264+aac sample should be direct-playable, got %+v", report)
|
||||
}
|
||||
|
||||
out := filepath.Join(dir, "out.mp4")
|
||||
f, err := os.Create(out)
|
||||
if err != nil {
|
||||
t.Fatalf("create out: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := tr.Stream(ctx, src, f, StreamOptions{HW: HWAccelNone}); err != nil {
|
||||
f.Close()
|
||||
t.Fatalf("Stream: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
v, a := probeOutput(t, ffprobe, out)
|
||||
if v != "h264" {
|
||||
t.Fatalf("direct-play output video codec = %q want h264", v)
|
||||
}
|
||||
if a != "aac" {
|
||||
t.Fatalf("direct-play output audio codec = %q want aac", a)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscoder_TranscodeHEVCToH264 — HEVC source → transcode →
|
||||
// output is H.264 + AAC ready for the browser.
|
||||
func TestTranscoder_TranscodeHEVCToH264(t *testing.T) {
|
||||
ffmpeg, ffprobe := resolveBins(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
// Verify libx265 available; some Alpine builds disable it.
|
||||
if !encoderAvailable(context.Background(), ffmpeg, "libx265") {
|
||||
t.Skip("ffmpeg lacks libx265 — skipping HEVC transcode integration")
|
||||
}
|
||||
src := generateTestVideo(t, ffmpeg, dir, "libx265", "ac3", "mkv")
|
||||
|
||||
tr, err := NewTranscoder(ffmpeg, ffprobe)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTranscoder: %v", err)
|
||||
}
|
||||
report, _, err := tr.Analyze(context.Background(), src)
|
||||
if err != nil {
|
||||
t.Fatalf("Analyze: %v", err)
|
||||
}
|
||||
if report.DirectPlay {
|
||||
t.Fatalf("hevc+ac3 sample must NOT be direct-playable")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
if err := tr.Stream(ctx, src, &buf, StreamOptions{Quality: "480p", HW: HWAccelNone}); err != nil {
|
||||
t.Fatalf("Stream: %v", err)
|
||||
}
|
||||
|
||||
out := filepath.Join(dir, "transcoded.mp4")
|
||||
if err := os.WriteFile(out, buf.Bytes(), 0o644); err != nil {
|
||||
t.Fatalf("persist transcode: %v", err)
|
||||
}
|
||||
|
||||
v, a := probeOutput(t, ffprobe, out)
|
||||
if v != "h264" {
|
||||
t.Fatalf("transcoded video codec = %q want h264", v)
|
||||
}
|
||||
if a != "aac" {
|
||||
t.Fatalf("transcoded audio codec = %q want aac", a)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscoder_AnalyzeReportsRealMediaInfo validates that the Transcoder
|
||||
// returns a usable MediaInfo on top of the report — the API handler will
|
||||
// surface duration / resolution to the player UI.
|
||||
func TestTranscoder_AnalyzeReportsRealMediaInfo(t *testing.T) {
|
||||
ffmpeg, ffprobe := resolveBins(t)
|
||||
dir := t.TempDir()
|
||||
src := generateTestVideo(t, ffmpeg, dir, "libx264", "aac", "mp4")
|
||||
|
||||
tr, err := NewTranscoder(ffmpeg, ffprobe)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTranscoder: %v", err)
|
||||
}
|
||||
_, info, err := tr.Analyze(context.Background(), src)
|
||||
if err != nil {
|
||||
t.Fatalf("Analyze: %v", err)
|
||||
}
|
||||
if info == nil || info.Video == nil {
|
||||
t.Fatalf("missing parsed mediainfo: %+v", info)
|
||||
}
|
||||
if info.Video.Width != 320 || info.Video.Height != 240 {
|
||||
t.Errorf("dimensions = %dx%d want 320x240", info.Video.Width, info.Video.Height)
|
||||
}
|
||||
if info.Video.Duration < 1.5 || info.Video.Duration > 2.5 {
|
||||
t.Errorf("duration ~2s expected, got %v", info.Video.Duration)
|
||||
}
|
||||
// Ensure the package types line up with mediainfo's exported model.
|
||||
_ = mediainfo.MediaInfo{}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue