mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
229 lines
7.3 KiB
Go
229 lines
7.3 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/truewhile/MeBox/internal/config"
|
|
"github.com/truewhile/MeBox/internal/model"
|
|
"github.com/truewhile/MeBox/internal/repository"
|
|
)
|
|
|
|
func TestHLSCompletedTranscodeStaysReadyAndIsReused(t *testing.T) {
|
|
bin, err := exec.LookPath("ffmpeg")
|
|
if err != nil {
|
|
t.Skip("ffmpeg is required for the real HLS lifecycle test")
|
|
}
|
|
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
|
defer cancel()
|
|
source := filepath.Join(t.TempDir(), "short.mp4")
|
|
cmd := exec.CommandContext(ctx, bin, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=64x64:rate=10", "-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-threads", "1", source)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("generate source: %v: %s", err, output)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.App.FFmpegPath = bin
|
|
cfg.Cache.CacheDir = t.TempDir()
|
|
cfg.Transcoder.Enabled = true
|
|
cfg.Transcoder.MaxConcurrent = 1
|
|
cfg.Transcoder.MaxHeight = 64
|
|
cfg.Transcoder.SegmentSeconds = 1
|
|
cfg.Transcoder.Threads = 1
|
|
db := newServiceTestDB(t)
|
|
media := model.Media{Path: source, DurationSec: 1}
|
|
if err := db.Create(&media).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
svc := NewTranscoderService(cfg, zap.NewNop(), repository.New(db), NewHub(zap.NewNop()))
|
|
t.Cleanup(svc.StopAll)
|
|
playlist, err := svc.EnsureJob(ctx, media.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
svc.mu.Lock()
|
|
job := svc.jobs[media.ID]
|
|
svc.mu.Unlock()
|
|
if job == nil {
|
|
t.Fatal("transcode lost its job before the playlist could be read")
|
|
}
|
|
select {
|
|
case <-job.done:
|
|
case <-ctx.Done():
|
|
t.Fatal("short transcode did not finish")
|
|
}
|
|
data, err := os.ReadFile(playlist)
|
|
if err != nil || !strings.Contains(string(data), "#EXT-X-ENDLIST") {
|
|
t.Fatalf("transcode did not produce a complete playlist: err=%v, data=%s", err, data)
|
|
}
|
|
if !svc.WaitReady(ctx, media.ID, 100*time.Millisecond) {
|
|
t.Fatal("completed HLS playlist is incorrectly reported as not ready")
|
|
}
|
|
if len(svc.Active()) != 0 {
|
|
t.Fatal("completed transcode still occupies an active job slot")
|
|
}
|
|
if _, err := svc.EnsureJob(ctx, media.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
svc.mu.Lock()
|
|
reused := svc.jobs[media.ID] == job
|
|
svc.mu.Unlock()
|
|
if !reused {
|
|
t.Fatal("refreshing a completed playlist started a second transcode")
|
|
}
|
|
other := model.Media{Path: source + ".second", DurationSec: 1}
|
|
if err := os.WriteFile(other.Path, mustReadTestFile(t, source), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&other).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := svc.EnsureJob(ctx, other.ID); err != nil {
|
|
t.Fatalf("completed cache must not consume MaxConcurrent: %v", err)
|
|
}
|
|
}
|
|
|
|
func mustReadTestFile(t *testing.T, path string) []byte {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func TestHLSWaitReadyReturnsImmediatelyForMissingJob(t *testing.T) {
|
|
svc := NewTranscoderService(&config.Config{}, zap.NewNop(), nil, NewHub(zap.NewNop()))
|
|
started := time.Now()
|
|
if svc.WaitReady(t.Context(), "failed", time.Second) {
|
|
t.Fatal("missing job must not be ready")
|
|
}
|
|
if elapsed := time.Since(started); elapsed >= 500*time.Millisecond {
|
|
t.Fatalf("missing/failed job waited for the readiness deadline: %v", elapsed)
|
|
}
|
|
}
|
|
|
|
func countingFFmpeg(t *testing.T) string {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("test ffmpeg uses a POSIX shell")
|
|
}
|
|
bin := filepath.Join(t.TempDir(), "ffmpeg")
|
|
script := `#!/bin/sh
|
|
printf '%s\n' "$*" >> "$0.calls"
|
|
case " $* " in
|
|
*" -encoders "*) printf ' V..... libx264\n V..... h264_nvenc\n' ;;
|
|
*" -muxers "*) printf ' E hls\n' ;;
|
|
esac
|
|
`
|
|
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return bin
|
|
}
|
|
|
|
func TestFFmpegValidationIsCachedAndInvalidatedByEncoder(t *testing.T) {
|
|
bin := countingFFmpeg(t)
|
|
cfg := &config.Config{}
|
|
cfg.App.FFmpegPath = bin
|
|
svc := NewTranscoderService(cfg, zap.NewNop(), nil, NewHub(zap.NewNop()))
|
|
for range 3 {
|
|
if _, err := svc.resolveFFmpegPath(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
calls := strings.Count(string(mustReadTestFile(t, bin+".calls")), "\n")
|
|
if calls != 2 {
|
|
t.Fatalf("repeated resolution spawned ffmpeg %d times, want one encoder/muxer probe pair", calls)
|
|
}
|
|
ApplyRuntimeSetting(cfg, "transcoder.hardware_accel", "true")
|
|
ApplyRuntimeSetting(cfg, "transcoder.encoder", "nvenc")
|
|
if _, err := svc.resolveFFmpegPath(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
calls = strings.Count(string(mustReadTestFile(t, bin+".calls")), "\n")
|
|
if calls != 4 {
|
|
t.Fatalf("encoder change must revalidate capabilities, calls=%d", calls)
|
|
}
|
|
changed := time.Now().Add(time.Second)
|
|
if err := os.Chtimes(bin, changed, changed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := svc.resolveFFmpegPath(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
calls = strings.Count(string(mustReadTestFile(t, bin+".calls")), "\n")
|
|
if calls != 6 {
|
|
t.Fatalf("binary replacement must invalidate validation, calls=%d", calls)
|
|
}
|
|
}
|
|
|
|
func TestHLSIdleCleanupPreservesReplacementAndRecentActivity(t *testing.T) {
|
|
svc := NewTranscoderService(&config.Config{}, zap.NewNop(), nil, NewHub(zap.NewNop()))
|
|
done := make(chan struct{})
|
|
close(done)
|
|
cancelled := false
|
|
old := &hlsJob{mediaID: "m", lastAccess: time.Now().Add(-time.Hour)}
|
|
current := &hlsJob{mediaID: "m", lastAccess: time.Now(), done: done, cancel: func() { cancelled = true }}
|
|
svc.jobs["m"] = current
|
|
if !svc.stopIdleJob(old, time.Minute) || cancelled || svc.jobs["m"] != current {
|
|
t.Fatal("an old watchdog must leave the replacement generation intact")
|
|
}
|
|
if svc.stopIdleJob(current, time.Minute) || cancelled {
|
|
t.Fatal("recent segment access must prevent idle cancellation")
|
|
}
|
|
current.lastAccess = time.Now().Add(-time.Hour)
|
|
if !svc.stopIdleJob(current, time.Minute) || !cancelled || svc.jobs["m"] != nil {
|
|
t.Fatal("an idle generation must release its resources")
|
|
}
|
|
}
|
|
|
|
func TestHLSOriginalQualityValidatesSoftwareEncoder(t *testing.T) {
|
|
bin := countingFFmpeg(t)
|
|
softwareOnly := strings.ReplaceAll(string(mustReadTestFile(t, bin)), ` V..... h264_nvenc\n`, "")
|
|
if err := os.WriteFile(bin, []byte(softwareOnly), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", t.TempDir())
|
|
t.Chdir(t.TempDir())
|
|
cfg := &config.Config{}
|
|
cfg.App.FFmpegPath = bin
|
|
cfg.Transcoder.Enabled = true
|
|
cfg.Transcoder.HardwareAccel = true
|
|
cfg.Transcoder.Encoder = "nvenc"
|
|
cfg.Cache.CacheDir = t.TempDir()
|
|
db := newServiceTestDB(t)
|
|
source := filepath.Join(t.TempDir(), "movie.mkv")
|
|
if err := os.WriteFile(source, []byte("source"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
media := model.Media{Path: source, DurationSec: 60}
|
|
if err := db.Create(&media).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
svc := NewTranscoderService(cfg, zap.NewNop(), repository.New(db), NewHub(zap.NewNop()))
|
|
t.Cleanup(svc.StopAll)
|
|
if _, err := svc.EnsureJobFromSubtitleQuality(t.Context(), media.ID, 0, 1, -1, "source"); err != nil {
|
|
t.Fatalf("source quality uses software and must not require NVENC: %v", err)
|
|
}
|
|
svc.mu.Lock()
|
|
job := svc.jobs[media.ID]
|
|
svc.mu.Unlock()
|
|
select {
|
|
case <-job.done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("fake ffmpeg did not finish")
|
|
}
|
|
calls := string(mustReadTestFile(t, bin+".calls"))
|
|
if strings.Count(calls, "\n") != 3 || !strings.Contains(calls, "-c:v libx264") {
|
|
t.Fatalf("startup must probe once and encode with libx264, calls=%s", calls)
|
|
}
|
|
}
|