Files
MeBox/internal/service/scanner_probe_backfill_test.go
T
2026-09-18 21:55:12 +08:00

168 lines
6.6 KiB
Go

package service
import (
"os"
"path/filepath"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// newProbeBackfillScanner builds a scanner over the given repository whose
// ffprobe "binary" is a temp file, so probe availability is deterministic
// regardless of what happens to be installed on the test machine.
func newProbeBackfillScanner(t *testing.T, repos *repository.Container, probeAvailable bool) *ScannerService {
t.Helper()
cfg := &config.Config{}
probePath := filepath.Join(t.TempDir(), "not-a-real-ffprobe")
if probeAvailable {
if err := os.WriteFile(probePath, []byte("stub"), 0o755); err != nil {
t.Fatalf("write stub probe: %v", err)
}
}
// Point the probe and its ffmpeg fallback at paths that only exist when this
// test wants them to, so Available() cannot pick up a system binary.
cfg.App.FFprobePath = probePath
cfg.App.FFmpegPath = probePath
return NewScannerService(cfg, zap.NewNop(), repos, NewHub(zap.NewNop()), NewFFprobeService(cfg, zap.NewNop()), nil)
}
func newProbeBackfillRepo(t *testing.T) *repository.Container {
t.Helper()
return repository.New(newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}))
}
func probeBackfillScanner(t *testing.T, probeAvailable bool) *ScannerService {
t.Helper()
return newProbeBackfillScanner(t, newProbeBackfillRepo(t), probeAvailable)
}
// localMediaProbeDataMissing decides which unchanged rows are worth re-probing.
func TestLocalMediaProbeDataMissing(t *testing.T) {
cases := []struct {
name string
existing existingLocalMedia
want bool
}{
{name: "never probed", existing: existingLocalMedia{}, want: true},
{name: "duration only", existing: existingLocalMedia{DurationSec: 148}, want: false},
{name: "resolution only", existing: existingLocalMedia{Width: 1920, Height: 1080}, want: false},
{name: "video codec only", existing: existingLocalMedia{VideoCodec: "h264"}, want: false},
{name: "audio codec only", existing: existingLocalMedia{AudioCodec: "aac"}, want: false},
{name: "fully probed", existing: existingLocalMedia{DurationSec: 148, Width: 1920, Height: 1080, VideoCodec: "h264", AudioCodec: "aac"}, want: false},
{name: "blank codecs are not data", existing: existingLocalMedia{VideoCodec: " ", AudioCodec: " "}, want: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := localMediaProbeDataMissing(tc.existing); got != tc.want {
t.Fatalf("localMediaProbeDataMissing(%+v) = %v, want %v", tc.existing, got, tc.want)
}
})
}
}
// A rescan must re-queue ffprobe for media that still has no technical metadata,
// otherwise a library scanned before ffmpeg was installed stays empty forever.
func TestRescanQueuesProbeBackfillForUnprobedMedia(t *testing.T) {
sc := probeBackfillScanner(t, true)
if !sc.queueProbeBackfillIfNeeded("/media/movie.mkv", ".mkv", existingLocalMedia{}) {
t.Fatal("unprobed media should get a backfill probe queued")
}
if sc.queueProbeBackfillIfNeeded("/media/movie.mkv", ".mkv", existingLocalMedia{DurationSec: 148, VideoCodec: "h264"}) {
t.Fatal("already probed media must not be re-queued")
}
}
// Backfill is pointless without a usable binary: every probe would fail, so the
// scanner must not flood the probe queue.
func TestRescanSkipsProbeBackfillWhenProbeUnavailable(t *testing.T) {
sc := probeBackfillScanner(t, false)
if sc.probe.Available() {
t.Skip("a system ffprobe/ffmpeg is resolvable in this environment; availability gating cannot be exercised")
}
if sc.queueProbeBackfillIfNeeded("/media/movie.mkv", ".mkv", existingLocalMedia{}) {
t.Fatal("no probe should be queued while ffprobe/ffmpeg is unavailable")
}
}
// Formats that mediaExtensionSupportsProbe excludes must never enter the backfill
// path, even when the row looks unprobed.
func TestRescanSkipsProbeBackfillForUnprobeableExtensions(t *testing.T) {
sc := probeBackfillScanner(t, true)
for _, ext := range []string{".strm", ".iso"} {
if sc.queueProbeBackfillIfNeeded("/media/item"+ext, ext, existingLocalMedia{}) {
t.Fatalf("ext %s must not be queued for a probe backfill", ext)
}
}
}
// End-to-end guard: after a scan leaves a row without technical metadata, the
// incremental-skip path for an unchanged file must still schedule a backfill
// probe rather than skipping the file forever.
func TestScanLibraryQueuesBackfillOnUnchangedRescan(t *testing.T) {
repos := newProbeBackfillRepo(t)
root := t.TempDir()
lib := model.Library{Name: "Movies", Path: root, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
file := filepath.Join(root, "Some Movie (2021).mkv")
if err := os.WriteFile(file, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
first := newProbeBackfillScanner(t, repos, true)
res, err := first.ScanLibrary(t.Context(), lib.ID)
if err != nil {
t.Fatalf("first scan: %v", err)
}
if res.Added != 1 {
t.Fatalf("first scan added=%d, want 1", res.Added)
}
var existing existingLocalMedia
if err := repos.DB.Model(&model.Media{}).
Select("duration_sec", "width", "height", "video_codec", "audio_codec", "size_bytes").
Where("path = ?", file).Scan(&existing).Error; err != nil {
t.Fatal(err)
}
if !localMediaProbeDataMissing(existing) {
t.Fatalf("fixture precondition failed, media already has probe data: %+v", existing)
}
// A fresh scanner instance has no probe in flight for this path, which is what
// a later rescan (e.g. after the operator installs ffmpeg) looks like.
second := newProbeBackfillScanner(t, repos, true)
_, skipUnchanged := second.localMediaScanState(localMediaScanStateInput{
ctx: t.Context(),
path: file,
cleanPath: filepath.Clean(file),
ext: ".mkv",
size: existing.SizeBytes,
existingMedia: map[string]existingLocalMedia{filepath.Clean(file): existing},
})
if !skipUnchanged {
t.Fatal("unchanged file should take the incremental-skip path")
}
// Control: a different unprobed path on the same instance still queues, so the
// instance is demonstrably able to schedule probes.
other := filepath.Join(root, "Another Movie (2022).mkv")
if !second.queueProbeBackfillIfNeeded(other, ".mkv", existing) {
t.Fatal("control path should queue a backfill probe")
}
// Therefore the incremental-skip path above must already have queued this path:
// queueing reserves the path, so a second attempt is refused. If the skip path
// had not queued it, this call would have succeeded.
if second.queueProbeBackfillIfNeeded(file, ".mkv", existing) {
t.Fatal("unchanged unprobed media was not queued by the incremental-skip path")
}
}