Files
truewhile 28485ed429 优化
2026-09-23 17:01:08 +08:00

309 lines
9.6 KiB
Go

package service
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// stubProber 是 mediaProber 的测试桩:可以拦住探测(gate)用来验证并发去重,
// 也可以直接返回预置结果或错误。
type stubProber struct {
gate chan struct{}
result *FullProbeResult
err error
mu sync.Mutex
calls int
last ProbeInput
}
func (s *stubProber) ProbeFull(_ context.Context, input ProbeInput) (*FullProbeResult, error) {
s.mu.Lock()
s.calls++
s.last = input
gate := s.gate
result := s.result
err := s.err
s.mu.Unlock()
if gate != nil {
<-gate
}
return result, err
}
func (s *stubProber) callCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.calls
}
func (s *stubProber) lastInput() ProbeInput {
s.mu.Lock()
defer s.mu.Unlock()
return s.last
}
func chapterProbeResult() *FullProbeResult {
return &FullProbeResult{
Container: "matroska,webm",
DurationSec: 1451,
BitRate: 8_000_000,
Streams: []ProbeStream{
{Index: 0, Type: "video", Codec: "hevc", Width: 3840, Height: 2160},
{Index: 1, Type: "audio", Codec: "eac3"},
{Index: 2, Type: "subtitle", Codec: "ass"},
},
Chapters: []ProbeChapter{
{Index: 0, StartMs: 0, EndMs: 95_000, Title: "Chapter 01"},
{Index: 1, StartMs: 228_664, EndMs: 246_143, Title: "Opening"},
{Index: 2, StartMs: 3_431_000, EndMs: 0, Title: "End Credits"},
},
}
}
// newProbeFixture 建一条本地媒体(真实落在临时目录里,因为 probeInput 会 stat
// 它)和一个可注入桩的 MediaProbeService。
func newProbeFixture(t *testing.T) (*MediaProbeService, *repository.Container, *model.Media) {
t.Helper()
repos := repository.New(newServiceTestDB(t))
dir := t.TempDir()
path := filepath.Join(dir, "S01E01.mkv")
if err := os.WriteFile(path, []byte("not-really-a-video"), 0o600); err != nil {
t.Fatal(err)
}
m := &model.Media{
Base: model.Base{ID: "ep-1"},
LibraryID: "lib-anime",
SeriesID: "s-1",
Title: "某剧",
Path: path,
SeasonNum: 1,
EpisodeNum: 1,
}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaProbeService(zap.NewNop(), repos, nil)
return svc, repos, m
}
func waitForCondition(t *testing.T, timeout time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("condition was not met before the deadline")
}
func TestMediaProbeRunPersistsProbeRow(t *testing.T) {
svc, repos, m := newProbeFixture(t)
prober := &stubProber{result: chapterProbeResult()}
svc.probe = prober
// 直接调 run(而不是 EnsureAsync)以获得确定性的断言:这里要验证的是落库,
// 不是调度。
svc.run(context.Background(), m)
if got := prober.lastInput().Source; got != m.Path {
t.Fatalf("probe source = %q, want the local path %q", got, m.Path)
}
probe, err := repos.MediaProbe.Get(t.Context(), m.ID)
if err != nil {
t.Fatal(err)
}
if probe == nil {
t.Fatal("probe row missing")
}
if probe.Container != "matroska,webm" || probe.DurationSec != 1451 || probe.ChapterCount != 3 {
t.Fatalf("probe summary = %#v", probe)
}
if probe.VideoStreams != 1 || probe.AudioStreams != 1 || probe.SubtitleStreams != 1 {
t.Fatalf("stream counts = %d/%d/%d", probe.VideoStreams, probe.AudioStreams, probe.SubtitleStreams)
}
if probe.Width != 3840 || probe.Height != 2160 || probe.VideoCodec != "hevc" || probe.AudioCodec != "eac3" {
t.Fatalf("probe primaries = %#v", probe)
}
if probe.Source != "local" || probe.Signature == "" {
t.Fatalf("probe source/signature = %q/%q", probe.Source, probe.Signature)
}
if probe.LastError != "" {
t.Fatalf("last error = %q, want empty", probe.LastError)
}
if !strings.Contains(probe.Payload, "hevc") {
t.Fatalf("payload should carry the parsed streams: %s", probe.Payload)
}
// 时长回填:STRM / 云盘媒体扫描时拿不到时长,末段区间要靠它换算结束时间。
var refreshed model.Media
if err := repos.DB.First(&refreshed, "id = ?", m.ID).Error; err != nil {
t.Fatal(err)
}
if refreshed.DurationSec != 1451 {
t.Fatalf("media duration = %d, want the probed 1451", refreshed.DurationSec)
}
}
func TestMediaProbeFailureKeepsPreviousSummary(t *testing.T) {
svc, repos, m := newProbeFixture(t)
prober := &stubProber{result: chapterProbeResult()}
svc.probe = prober
svc.run(context.Background(), m)
prober.err = errors.New("ffprobe full: exit status 1")
svc.run(context.Background(), m)
probe, err := repos.MediaProbe.Get(t.Context(), m.ID)
if err != nil {
t.Fatal(err)
}
if probe == nil || probe.LastError == "" {
t.Fatal("a failed re-probe must record the error")
}
// 关键:失败只更新时间与错误信息,上一次成功的媒体信息必须留着——
// 否则一次失败的重探会把已经拿到的时长(片尾区间换算要用)抹掉。
if probe.DurationSec != 1451 || probe.Container != "matroska,webm" || probe.Payload == "" {
t.Fatalf("a failed re-probe wiped the previous summary: %#v", probe)
}
}
// 探测有结论后就不再重探;失败要等冷却期过去才允许重试。
func TestMediaProbeSettledSkipsReprobeButStaleFailureRetries(t *testing.T) {
if !mediaProbeSettled(&model.MediaProbe{ProbedAt: time.Now()}) {
t.Fatal("a completed probe is settled")
}
if !mediaProbeSettled(&model.MediaProbe{ProbedAt: time.Now(), LastError: "boom"}) {
t.Fatal("a recent failure is settled: it must not be retried on every play")
}
if mediaProbeSettled(&model.MediaProbe{ProbedAt: time.Now().Add(-mediaProbeFailureRetry - time.Minute), LastError: "boom"}) {
t.Fatal("a failure past the cooldown should be retried")
}
if mediaProbeSettled(nil) {
t.Fatal("a missing probe row is not settled")
}
}
func TestMediaProbeEnsureAsyncDedupesInFlightProbes(t *testing.T) {
svc, _, m := newProbeFixture(t)
gate := make(chan struct{})
prober := &stubProber{result: chapterProbeResult(), gate: gate}
svc.probe = prober
if !svc.EnsureAsync(m) {
t.Fatal("first EnsureAsync should enqueue a probe")
}
// 同一条媒体在跑的时候不能重复排队:否则每次播放请求都会再起一次 3 秒探测。
if svc.EnsureAsync(m) {
t.Fatal("second EnsureAsync must report that a probe is already running")
}
close(gate)
waitForCondition(t, 5*time.Second, func() bool {
svc.mu.Lock()
defer svc.mu.Unlock()
return len(svc.inFlight) == 0
})
if got := prober.callCount(); got != 1 {
t.Fatalf("probe calls = %d, want 1", got)
}
// 跑完之后允许再次排队(例如失败冷却期到了之后的重试)。
if !svc.EnsureAsync(m) {
t.Fatal("EnsureAsync should enqueue again once the previous run finished")
}
waitForCondition(t, 5*time.Second, func() bool { return prober.callCount() == 2 })
}
func TestMediaProbeEnsureAsyncWithoutProberIsNotPending(t *testing.T) {
svc, _, m := newProbeFixture(t)
// probe 未注入(ffprobe 不可用时就是这样):不能谎报「提取中」,否则客户端
// 会白轮询一轮。
if svc.EnsureAsync(m) {
t.Fatal("EnsureAsync must return false without a prober")
}
}
func TestMediaProbeInputRejectsMissingLocalFile(t *testing.T) {
svc, _, _ := newProbeFixture(t)
_, err := svc.probeInput(t.Context(), &model.Media{
Base: model.Base{ID: "missing"}, Path: filepath.Join(t.TempDir(), "nope.mkv"),
})
if !errors.Is(err, ErrMediaNotFound) {
t.Fatalf("err = %v, want ErrMediaNotFound", err)
}
}
func TestMediaProbeInputResolvesStrmWithHeaders(t *testing.T) {
svc := &MediaProbeService{}
svc.SetPlayTargetResolver(func(_ context.Context, raw, userAgent string) (*StrmPlayResult, error) {
if raw != "https://pan.example.com/api/strm/play/115?v=1" {
t.Fatalf("resolver got raw = %q", raw)
}
if userAgent != "" {
t.Fatalf("userAgent = %q, want empty for a background probe", userAgent)
}
return &StrmPlayResult{RedirectURL: "https://cdn.example.com/a.mkv"}, nil
})
input, err := svc.probeInput(t.Context(), &model.Media{
Base: model.Base{ID: "strm-1"},
Path: "/media/a.mkv.strm",
STRMURL: "https://pan.example.com/api/strm/play/115?v=1",
})
if err != nil {
t.Fatalf("probeInput: %v", err)
}
if input.Source != "https://cdn.example.com/a.mkv" {
t.Fatalf("source = %q, want the resolved direct link", input.Source)
}
}
// 签名不能把播放目标(含 pickcode)再抄一份进数据库,所以整体做哈希。
func TestMediaProbeSignatureHidesPlayTargetAndTracksFileChanges(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("v1"), 0o600); err != nil {
t.Fatal(err)
}
local := &model.Media{Base: model.Base{ID: "l-1"}, Path: path}
first := mediaProbeSignature(local)
if len(first) != 32 {
t.Fatalf("signature = %q, want a 32-char hex digest", first)
}
if strings.Contains(first, "movie.mkv") {
t.Fatalf("signature leaked the path: %q", first)
}
if err := os.WriteFile(path, []byte("v2-changed-size"), 0o600); err != nil {
t.Fatal(err)
}
if mediaProbeSignature(local) == first {
t.Fatal("the signature must change when the file changes")
}
strm := &model.Media{
Base: model.Base{ID: "s-1"},
Path: "/media/movie.mkv.strm",
STRMURL: "https://pan.example.com/api/strm/play/115?pickcode=secret-pickcode",
}
strmSig := mediaProbeSignature(strm)
if strings.Contains(strmSig, "secret-pickcode") || strings.Contains(strmSig, "pan.example.com") {
t.Fatalf("strm signature leaked the play target: %q", strmSig)
}
if strmSig == "" {
t.Fatal("strm signature must not be empty")
}
}