mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-10 03:17:27 +02:00
* 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.
195 lines
7.1 KiB
Go
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{}
|
|
)
|