mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Relocate the image-upload service from core to core/artwork as artwork.Uploader, co-locating it with the resolver/worker/serving that own the artwork state it invalidates. MaxImageUploadSize moves too — its only callers are the two image-upload handlers — which lets core/image_upload.go be deleted entirely. Extract the shared "clear resolved state + re-queue at Bump" invalidation into artwork.Refresh and fold nativeapi's refreshArtwork handler onto it, removing the duplicated DeleteForItem+Enqueue block that had drifted into three places. The wire provider moves from core's set to artwork's; the playlists.ImageUploadService binding moves to the top-level injector so core/artwork stays unaware of playlists. Behavior is unchanged.
351 lines
11 KiB
Go
351 lines
11 KiB
Go
package e2e
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/core/artwork"
|
|
"github.com/navidrome/navidrome/core/metrics"
|
|
"github.com/navidrome/navidrome/core/playlists"
|
|
"github.com/navidrome/navidrome/core/storage/storagetest"
|
|
"github.com/navidrome/navidrome/db"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/criteria"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"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"
|
|
)
|
|
|
|
func TestSmartPlaylistE2E(t *testing.T) {
|
|
tests.Init(t, false)
|
|
defer db.Close(t.Context())
|
|
log.SetLevel(log.LevelFatal)
|
|
RegisterFailHandler(Fail)
|
|
RunSpecs(t, "Smart Playlist E2E Suite")
|
|
}
|
|
|
|
type _t = map[string]any
|
|
|
|
var template = storagetest.Template
|
|
var track = storagetest.Track
|
|
|
|
var (
|
|
ctx context.Context
|
|
ds *tests.MockDataStore
|
|
lib model.Library
|
|
|
|
dbFilePath string
|
|
snapshotPath string
|
|
snapshotTables []string
|
|
|
|
adminUser = model.User{
|
|
ID: "sp-test-user-1",
|
|
UserName: "sptestuser",
|
|
Name: "SP Test User",
|
|
IsAdmin: true,
|
|
}
|
|
|
|
regularUser = model.User{
|
|
ID: "sp-test-user-2",
|
|
UserName: "spotheruser",
|
|
Name: "SP Other User",
|
|
IsAdmin: false,
|
|
}
|
|
)
|
|
|
|
func buildTestFS() {
|
|
abbeyRoad := template(_t{
|
|
"albumartist": "The Beatles",
|
|
"artist": "The Beatles",
|
|
"album": "Abbey Road",
|
|
"year": 1969,
|
|
"genre": "Rock;Blues",
|
|
"replaygain_album_gain": "-6.5 dB",
|
|
"replaygain_album_peak": "0.98",
|
|
})
|
|
ledZepIV := template(_t{
|
|
"albumartist": "Led Zeppelin",
|
|
"artist": "Led Zeppelin",
|
|
"album": "IV",
|
|
"year": 1971,
|
|
})
|
|
kindOfBlue := template(_t{
|
|
"albumartist": "Miles Davis",
|
|
"artist": "Miles Davis",
|
|
"album": "Kind of Blue",
|
|
"year": 1959,
|
|
"genre": "Jazz",
|
|
"composer": "Miles Davis",
|
|
})
|
|
nightAtOpera := template(_t{
|
|
"albumartist": "Queen",
|
|
"artist": "Queen",
|
|
"album": "A Night at the Opera",
|
|
"year": 1975,
|
|
"genre": "Rock",
|
|
})
|
|
electricLadyland := template(_t{
|
|
"albumartist": "Jimi Hendrix",
|
|
"artist": "Jimi Hendrix",
|
|
"album": "Electric Ladyland",
|
|
"year": 1968,
|
|
"genre": "Rock;Blues",
|
|
})
|
|
newsOfWorld := template(_t{
|
|
"albumartist": "Queen",
|
|
"artist": "Queen",
|
|
"album": "News of the World",
|
|
"year": 1977,
|
|
"genre": "Rock;Pop",
|
|
"compilation": "1",
|
|
})
|
|
|
|
fs := storagetest.FakeFS{}
|
|
fs.SetFiles(fstest.MapFS{
|
|
"Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together",
|
|
_t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks",
|
|
"replaygain_track_gain": "-7.1 dB", "replaygain_track_peak": "0.95"})),
|
|
"Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something",
|
|
_t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks",
|
|
"replaygain_track_gain": "-6.0 dB", "replaygain_track_peak": "0.92"})),
|
|
// Stairway To Heaven has track gain but no album gain, to distinguish the two fields
|
|
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven",
|
|
_t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac",
|
|
"bitrate": 900, "samplerate": 44100, "bitdepth": 16,
|
|
"replaygain_track_gain": "-8.25 dB", "replaygain_track_peak": "0.99"})),
|
|
"Rock/Led Zeppelin/IV/02 - Black Dog.flac": ledZepIV(track(2, "Black Dog",
|
|
_t{"genre": "Rock;Blues", "composer": "Page/Plant/Jones", "bpm": 150, "suffix": "flac",
|
|
"bitrate": 900, "samplerate": 44100, "bitdepth": 16})),
|
|
"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What",
|
|
_t{"bpm": 136})),
|
|
"Rock/Queen/A Night at the Opera/01 - Bohemian Rhapsody.mp3": nightAtOpera(track(1, "Bohemian Rhapsody",
|
|
_t{"composer": "Freddie Mercury", "bpm": 72})),
|
|
"Rock/Jimi Hendrix/Electric Ladyland/01 - All Along the Watchtower.mp3": electricLadyland(track(1, "All Along the Watchtower",
|
|
_t{"composer": "Bob Dylan", "bpm": 112})),
|
|
"Rock/Queen/News of the World/01 - We Are the Champions.mp3": newsOfWorld(track(1, "We Are the Champions",
|
|
_t{"composer": "Freddie Mercury", "bpm": 64})),
|
|
})
|
|
storagetest.Register("fake", &fs)
|
|
}
|
|
|
|
func findMediaFileByTitle(title string) string {
|
|
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
|
Filters: squirrel.Eq{"media_file.title": title},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mfs).To(HaveLen(1), "expected exactly one media file with title %q", title)
|
|
return mfs[0].ID
|
|
}
|
|
|
|
func evaluateRule(jsonRule string) []string {
|
|
titles := evaluateRuleOrderedAs(adminUser, jsonRule)
|
|
sort.Strings(titles)
|
|
return titles
|
|
}
|
|
|
|
func evaluateRuleOrdered(jsonRule string) []string {
|
|
return evaluateRuleOrderedAs(adminUser, jsonRule)
|
|
}
|
|
|
|
func evaluateRuleAs(owner model.User, jsonRule string) []string {
|
|
titles := evaluateRuleOrderedAs(owner, jsonRule)
|
|
sort.Strings(titles)
|
|
return titles
|
|
}
|
|
|
|
func evaluateRuleOrderedAs(owner model.User, jsonRule string) []string {
|
|
userCtx := request.WithUser(GinkgoT().Context(), owner)
|
|
var rules criteria.Criteria
|
|
err := json.Unmarshal([]byte(jsonRule), &rules)
|
|
Expect(err).ToNot(HaveOccurred(), "invalid criteria JSON: %s", jsonRule)
|
|
|
|
pls := &model.Playlist{
|
|
Name: "test-smart-playlist",
|
|
OwnerID: owner.ID,
|
|
Rules: &rules,
|
|
}
|
|
err = ds.Playlist(userCtx).Put(pls)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
loaded, err := ds.Playlist(userCtx).GetWithTracks(pls.ID, true, false)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
titles := make([]string, len(loaded.Tracks))
|
|
for i, t := range loaded.Tracks {
|
|
titles[i] = t.Title
|
|
}
|
|
return titles
|
|
}
|
|
|
|
func createPlaylist(owner model.User, public bool, titles ...string) string {
|
|
pls := &model.Playlist{
|
|
Name: "ref-playlist",
|
|
OwnerID: owner.ID,
|
|
Public: public,
|
|
}
|
|
for _, title := range titles {
|
|
mfID := findMediaFileByTitle(title)
|
|
pls.AddMediaFilesByID([]string{mfID})
|
|
}
|
|
Expect(ds.Playlist(ctx).Put(pls)).To(Succeed())
|
|
return pls.ID
|
|
}
|
|
|
|
func createPublicPlaylist(owner model.User, titles ...string) string {
|
|
return createPlaylist(owner, true, titles...)
|
|
}
|
|
|
|
func createPrivatePlaylist(owner model.User, titles ...string) string {
|
|
return createPlaylist(owner, false, titles...)
|
|
}
|
|
|
|
func createPublicSmartPlaylist(owner model.User, jsonRule string) string {
|
|
return createSmartPlaylist(owner, true, jsonRule)
|
|
}
|
|
|
|
func createPrivateSmartPlaylist(owner model.User, jsonRule string) string {
|
|
return createSmartPlaylist(owner, false, jsonRule)
|
|
}
|
|
|
|
func createSmartPlaylist(owner model.User, public bool, jsonRule string) string {
|
|
var rules criteria.Criteria
|
|
Expect(json.Unmarshal([]byte(jsonRule), &rules)).To(Succeed())
|
|
pls := &model.Playlist{
|
|
Name: "ref-smart-playlist",
|
|
OwnerID: owner.ID,
|
|
Public: public,
|
|
Rules: &rules,
|
|
}
|
|
Expect(ds.Playlist(ctx).Put(pls)).To(Succeed())
|
|
return pls.ID
|
|
}
|
|
|
|
var _ = BeforeSuite(func() {
|
|
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
|
tmpDir := GinkgoT().TempDir()
|
|
dbFilePath = filepath.Join(tmpDir, "smartplaylist-e2e.db")
|
|
snapshotPath = filepath.Join(tmpDir, "smartplaylist-e2e.db.snapshot")
|
|
conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL"
|
|
db.Db().SetMaxOpenConns(1)
|
|
|
|
conf.Server.MusicFolder = "fake:///music"
|
|
conf.Server.DevExternalScanner = false
|
|
conf.Server.SmartPlaylistRefreshDelay = 0
|
|
|
|
db.Init(ctx)
|
|
|
|
initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
|
|
|
userWithPass := adminUser
|
|
userWithPass.NewPassword = "password"
|
|
Expect(initDS.User(ctx).Put(&userWithPass)).To(Succeed())
|
|
|
|
regularUserWithPass := regularUser
|
|
regularUserWithPass.NewPassword = "password"
|
|
Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed())
|
|
|
|
lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"}
|
|
Expect(initDS.Library(ctx).Put(&lib)).To(Succeed())
|
|
Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
|
Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed())
|
|
|
|
loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
adminUser.Libraries = loadedUser.Libraries
|
|
|
|
loadedOther, err := initDS.User(ctx).FindByUsername(regularUser.UserName)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
regularUser.Libraries = loadedOther.Libraries
|
|
|
|
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
|
|
|
buildTestFS()
|
|
s := scanner.New(ctx, initDS, events.NoopBroker(),
|
|
playlists.NewPlaylists(initDS, artwork.NewUploader(initDS)), metrics.NewNoopInstance())
|
|
_, err = s.ScanAll(ctx, true)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
|
|
|
comeTogetherID := findMediaFileByTitle("Come Together")
|
|
Expect(ds.MediaFile(ctx).SetStar(true, comeTogetherID)).To(Succeed())
|
|
Expect(ds.MediaFile(ctx).SetStar(true, findMediaFileByTitle("So What"))).To(Succeed())
|
|
Expect(ds.MediaFile(ctx).SetRating(3, findMediaFileByTitle("Stairway To Heaven"))).To(Succeed())
|
|
Expect(ds.MediaFile(ctx).SetRating(5, findMediaFileByTitle("Bohemian Rhapsody"))).To(Succeed())
|
|
for range 10 {
|
|
Expect(ds.MediaFile(ctx).IncPlayCount(comeTogetherID, time.Now())).To(Succeed())
|
|
}
|
|
Expect(ds.MediaFile(ctx).IncPlayCount(findMediaFileByTitle("Black Dog"), time.Now())).To(Succeed())
|
|
|
|
rows, err := db.Db().Query("SELECT name FROM 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()
|
|
for rows.Next() {
|
|
var name string
|
|
Expect(rows.Scan(&name)).To(Succeed())
|
|
snapshotTables = append(snapshotTables, name)
|
|
}
|
|
Expect(rows.Err()).ToNot(HaveOccurred())
|
|
|
|
_, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
data, err := os.ReadFile(dbFilePath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed())
|
|
})
|
|
|
|
var _ = AfterSuite(func() {
|
|
db.Close(ctx)
|
|
})
|
|
|
|
func restoreDB() {
|
|
sqlDB := db.Db()
|
|
|
|
_, err := sqlDB.Exec("PRAGMA foreign_keys = OFF")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _, _ = sqlDB.Exec("PRAGMA foreign_keys = ON") }()
|
|
|
|
_, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _, _ = sqlDB.Exec("DETACH DATABASE snapshot") }()
|
|
|
|
_, err = sqlDB.Exec("BEGIN TRANSACTION")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _, _ = sqlDB.Exec("ROLLBACK") }()
|
|
|
|
for _, table := range snapshotTables {
|
|
_, 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("COMMIT")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
}
|
|
|
|
func setupTestDB() {
|
|
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.MusicFolder = "fake:///music"
|
|
conf.Server.DevExternalScanner = false
|
|
conf.Server.SmartPlaylistRefreshDelay = 0
|
|
|
|
restoreDB()
|
|
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
|
}
|