navidrome/tests/harness/harness.go
Deluan Quintão bb7d81a5ea
refactor(scanner): remove the unused legacy ffmpeg metadata extractor (#6231)
* refactor(scanner): remove the legacy ffmpeg metadata extractor

The ffmpeg extractor in scanner/metadata_old has not been wired into the scanner since the taglib-only rewrite, so it was only exercised by its own tests. Remove the package, the FFmpeg.Probe method and its ffmetadata command that only it used, and the startup fallback for Scanner.Extractor="ffmpeg". Configs that still set it keep working: unknown extractors already fall back to taglib with a warning.

* fix(conf): warn and fall back to taglib for an unknown Scanner.Extractor

Validate the option when loading the config, so invalid values such as the removed "ffmpeg" extractor are reported once at startup instead of only when a library storage is created.
2026-09-26 12:30:52 -04:00

195 lines
7.1 KiB
Go

// Package harness holds the pieces shared by the API e2e suites (server/subsonic/e2e and
// server/jellyfin/e2e): golden-database lifecycle, snapshot restore, fixture-FS registration,
// and service doubles. Like core/storage/storagetest, it must only be imported from test code.
package harness
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
"github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" //nolint:staticcheck
)
// DB is a golden e2e database: scanned once in BeforeSuite, restored per test via Restore.
type DB struct {
FilePath string
SnapshotPath string
Library model.Library
}
// CreateFS registers files under the "fake:" storage scheme the suites use as MusicFolder.
func CreateFS(files fstest.MapFS) storagetest.FakeFS {
fs := storagetest.FakeFS{}
fs.SetFiles(files)
storagetest.Register("fake", &fs)
return fs
}
// SetupDB boots the golden database: a temp SQLite file, the given users (password "password",
// all with access to the seeded "Music Library"), a full scan of the registered fake FS, and a
// snapshot for per-test restore. Callers must set conf.Server.MusicFolder and register the FS
// first; each user's Libraries field is populated in place.
func SetupDB(ctx context.Context, users ...*model.User) *DB {
tmpDir := ginkgo.GinkgoT().TempDir()
h := &DB{FilePath: filepath.Join(tmpDir, "test-e2e.db")}
h.SnapshotPath = h.FilePath + ".snapshot"
conf.Server.DbPath = h.FilePath + "?_journal_mode=WAL"
db.Db().SetMaxOpenConns(1)
db.Init(ctx)
ds := &tests.MockDataStore{RealDS: persistence.New(db.Db())}
auth.Init(ds)
h.Library = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"}
Expect(ds.Library().Put(ctx, &h.Library)).To(Succeed())
for _, u := range users {
seeded := *u
seeded.NewPassword = "password"
Expect(ds.User().Put(ctx, &seeded)).To(Succeed())
Expect(ds.User().SetUserLibraries(ctx, u.ID, []int{h.Library.ID})).To(Succeed())
loaded, err := ds.User().FindByUsername(ctx, u.UserName)
Expect(err).ToNot(HaveOccurred())
u.Libraries = loaded.Libraries
}
s := scanner.New(ctx, ds, events.NoopBroker(),
playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance())
_, err := s.ScanAll(ctx, true)
Expect(err).ToNot(HaveOccurred())
_, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)")
Expect(err).ToNot(HaveOccurred())
data, err := os.ReadFile(h.FilePath)
Expect(err).ToNot(HaveOccurred())
Expect(os.WriteFile(h.SnapshotPath, data, 0o600)).To(Succeed()) //nolint:gosec // path derives from TempDir
return h
}
// ResettableTables lists the tables a per-spec reset may write. FTS shadow tables are excluded:
// their content tables' triggers keep them in sync, and writing them directly corrupts the index.
func ResettableTables() []string {
rows, err := db.Db().Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'")
Expect(err).ToNot(HaveOccurred())
defer rows.Close()
var tables []string
for rows.Next() {
var name string
Expect(rows.Scan(&name)).To(Succeed())
tables = append(tables, name)
}
Expect(rows.Err()).ToNot(HaveOccurred())
return tables
}
// TruncateDB empties every resettable table, leaving the migrated schema in place — for suites
// whose specs each build their own library, so the schema is not re-migrated per spec.
func TruncateDB(tables []string) {
sqlDB := db.Db()
_, err := sqlDB.Exec("PRAGMA foreign_keys = OFF")
Expect(err).ToNot(HaveOccurred())
defer func() { _, _ = sqlDB.Exec("PRAGMA foreign_keys = ON") }()
for _, table := range tables {
// Table names come from sqlite_master, not user input.
_, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec
Expect(err).ToNot(HaveOccurred())
}
}
// Restore reloads every table from the golden snapshot via ATTACH DATABASE — much faster than a
// rescan.
func (h *DB) Restore() {
sqlDB := db.Db()
_, err := sqlDB.Exec("PRAGMA foreign_keys = OFF")
Expect(err).ToNot(HaveOccurred())
_, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", h.SnapshotPath)
Expect(err).ToNot(HaveOccurred())
for _, table := range ResettableTables() {
// Table names come from sqlite_master, not user input.
_, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec
Expect(err).ToNot(HaveOccurred())
_, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec
Expect(err).ToNot(HaveOccurred())
}
_, err = sqlDB.Exec("DETACH DATABASE snapshot")
Expect(err).ToNot(HaveOccurred())
_, err = sqlDB.Exec("PRAGMA foreign_keys = ON")
Expect(err).ToNot(HaveOccurred())
}
// SpyStreamer captures the Request passed to NewStream and returns a minimal fake stream.
type SpyStreamer struct {
LastRequest stream.Request
LastMediaFile *model.MediaFile
SimulateError error // when set, NewStream returns this error
SimulateEmptyStream bool // when true, returns a 0-byte stream (ffmpeg produced no output)
}
func (s *SpyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) {
s.LastRequest = req
s.LastMediaFile = mf
if s.SimulateError != nil {
return nil, s.SimulateError
}
format := req.Format
if format == "" || format == "raw" {
format = mf.Suffix
}
content := "fake audio data"
if s.SimulateEmptyStream {
content = ""
}
return stream.NewStream(mf, format, req.BitRate, io.NopCloser(strings.NewReader(content))), nil
}
// NoopFFmpeg implements ffmpeg.FFmpeg; transcoding never actually runs in e2e.
type NoopFFmpeg struct{}
func (NoopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) {
return nil, errors.New("noop ffmpeg: transcode not supported")
}
func (NoopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) {
return nil, errors.New("noop ffmpeg: extract image not supported")
}
func (NoopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) {
return nil, errors.New("noop ffmpeg: probe not supported")
}
func (NoopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) {
return nil, errors.New("noop ffmpeg: convert animated image not supported")
}
func (NoopFFmpeg) CmdPath() (string, error) { return "", nil }
func (NoopFFmpeg) IsAvailable() bool { return false }
func (NoopFFmpeg) IsProbeAvailable() bool { return true }
func (NoopFFmpeg) Version() string { return "noop" }
var (
_ stream.MediaStreamer = &SpyStreamer{}
_ ffmpeg.FFmpeg = NoopFFmpeg{}
)