mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-15 13:57:09 +02:00
Adding tracks to a playlist skipped the media_file lookup for admins and for users who can see every library, so ids matching no track were stored as orphan playlist_tracks rows and reported as added. The native API returned an inflated "added" count, and the hidden rows left gaps in the positions, so Subsonic songIndexToRemove could delete a hidden row instead of the song at that index. keepAccessible now always checks the ids against media_file. applyLibraryFilter is already a no-op for those users, so the check only drops unknown ids. Rows stored before this fix are still removed by the existing GC removeOrphans step on the next full scan, or on any scan that finds changes. Fixes #6310
375 lines
14 KiB
Go
375 lines
14 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/db"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"github.com/pocketbase/dbx"
|
|
)
|
|
|
|
func TestPersistence(t *testing.T) {
|
|
tests.Init(t, true)
|
|
|
|
//os.Remove("./test-123.db")
|
|
//conf.Server.DbPath = "./test-123.db"
|
|
conf.Server.DbPath = "file::memory:?cache=shared&_foreign_keys=on"
|
|
defer db.Init(context.Background())()
|
|
log.SetLevel(log.LevelFatal)
|
|
RegisterFailHandler(Fail)
|
|
RunSpecs(t, "Persistence Suite")
|
|
}
|
|
|
|
func mf(mf model.MediaFile) model.MediaFile {
|
|
mf.Tags = model.Tags{}
|
|
mf.LibraryID = 1
|
|
mf.LibraryPath = "music" // Default folder
|
|
mf.LibraryName = "Music Library"
|
|
mf.Participants = model.Participants{
|
|
model.RoleArtist: model.ParticipantList{
|
|
model.Participant{Artist: model.Artist{ID: mf.ArtistID, Name: mf.Artist}},
|
|
},
|
|
}
|
|
if mf.Lyrics == "" {
|
|
mf.Lyrics = "[]"
|
|
}
|
|
return mf
|
|
}
|
|
|
|
func al(al model.Album) model.Album {
|
|
al.LibraryID = 1
|
|
al.LibraryPath = "music"
|
|
al.LibraryName = "Music Library"
|
|
al.Discs = model.Discs{}
|
|
al.Tags = model.Tags{}
|
|
al.Participants = model.Participants{}
|
|
return al
|
|
}
|
|
|
|
func alWithTags(a model.Album, tags model.Tags) model.Album {
|
|
a = al(a)
|
|
a.Tags = tags
|
|
return a
|
|
}
|
|
|
|
var (
|
|
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
|
|
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
|
|
artistCJK = model.Artist{ID: "4", Name: "シートベルツ", SortArtistName: "Seatbelts", OrderArtistName: "seatbelts"}
|
|
artistPunctuation = model.Artist{ID: "5", Name: "The Roots", OrderArtistName: "roots"}
|
|
testArtists = model.Artists{
|
|
artistKraftwerk,
|
|
artistBeatles,
|
|
artistCJK,
|
|
artistPunctuation,
|
|
}
|
|
)
|
|
|
|
var (
|
|
albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
|
|
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
|
|
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("kraft/radio/radio.mp3"), SongCount: 2})
|
|
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("test/multi/disc1/track1.mp3"), SongCount: 4})
|
|
albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1})
|
|
albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019},
|
|
model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}})
|
|
albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("roots/things/track1.mp3"), SongCount: 1})
|
|
testAlbums = model.Albums{
|
|
albumSgtPeppers,
|
|
albumAbbeyRoad,
|
|
albumRadioactivity,
|
|
albumMultiDisc,
|
|
albumCJK,
|
|
albumWithVersion,
|
|
albumPunctuation,
|
|
}
|
|
)
|
|
|
|
var (
|
|
songDayInALife = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("beatles/1/sgt/a day.mp3")})
|
|
songComeTogether = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("beatles/1/come together.mp3")})
|
|
songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("kraft/radio/radio.mp3")})
|
|
songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
|
|
AlbumID: "103",
|
|
Path: p("kraft/radio/antenna.mp3"),
|
|
RGAlbumGain: new(1.0), RGAlbumPeak: new(2.0), RGTrackGain: new(3.0), RGTrackPeak: new(4.0),
|
|
})
|
|
songAntennaWithLyrics = mf(model.MediaFile{
|
|
ID: "1005",
|
|
Title: "Antenna",
|
|
ArtistID: "2",
|
|
Artist: "Kraftwerk",
|
|
AlbumID: "103",
|
|
Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`,
|
|
})
|
|
songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
|
|
// Multi-disc album tracks (intentionally out of order to test sorting)
|
|
songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
|
songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
|
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
|
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
|
songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("seatbelts/cowboy-bebop/track1.mp3")})
|
|
songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("beatles/2/come together.mp3")})
|
|
songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("roots/things/track1.mp3")})
|
|
testSongs = model.MediaFiles{
|
|
songDayInALife,
|
|
songComeTogether,
|
|
songRadioactivity,
|
|
songAntenna,
|
|
songAntennaWithLyrics,
|
|
songAntenna2,
|
|
songDisc2Track11,
|
|
songDisc1Track01,
|
|
songDisc2Track01,
|
|
songDisc1Track02,
|
|
songCJK,
|
|
songVersioned,
|
|
songPunctuation,
|
|
}
|
|
)
|
|
|
|
var (
|
|
radioWithoutHomePage = model.Radio{ID: "1235", StreamUrl: "https://example.com:8000/1/stream.mp3", HomePageUrl: "", Name: "No Homepage"}
|
|
radioWithHomePage = model.Radio{ID: "5010", StreamUrl: "https://example.com/stream.mp3", Name: "Example Radio", HomePageUrl: "https://example.com"}
|
|
testRadios = model.Radios{radioWithoutHomePage, radioWithHomePage}
|
|
)
|
|
|
|
var (
|
|
plsBest model.Playlist
|
|
plsCool model.Playlist
|
|
testPlaylists []*model.Playlist
|
|
)
|
|
|
|
var (
|
|
adminUser = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true}
|
|
regularUser = model.User{ID: "2222", UserName: "regular-user", Name: "Regular User", Email: "regular@example.com"}
|
|
thirdUser = model.User{ID: "3333", UserName: "third-user", Name: "Third User", Email: "third@example.com"}
|
|
testUsers = model.Users{adminUser, regularUser, thirdUser}
|
|
)
|
|
|
|
var (
|
|
firstScrobble = model.Scrobble{ID: 1, MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Unix()}
|
|
secondScrobble = model.Scrobble{ID: 2, MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC).Unix()}
|
|
thirdScrobble = model.Scrobble{ID: 3, MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC).Unix()}
|
|
scrobbles = model.Scrobbles{firstScrobble, secondScrobble, thirdScrobble}
|
|
)
|
|
|
|
func p(path string) string {
|
|
return filepath.FromSlash(path)
|
|
}
|
|
|
|
// restrictedFixture creates a second library plus a non-admin user granted library 1 only, so
|
|
// specs can assert that a query filters by library. Cleans itself up after the spec.
|
|
func restrictedFixture(name string) (context.Context, model.Library, model.User) {
|
|
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
|
db := GetDBXBuilder()
|
|
|
|
lib := model.Library{Name: name + " Library", Path: "/" + name}
|
|
lr := NewLibraryRepository(db)
|
|
Expect(lr.Put(adminCtx, &lib)).To(Succeed())
|
|
|
|
user := createUserWithLibraries(name+"-restricted", []int{1})
|
|
ur := NewUserRepository(db)
|
|
Expect(ur.Put(adminCtx, &user)).To(Succeed())
|
|
Expect(ur.SetUserLibraries(adminCtx, user.ID, []int{1})).To(Succeed())
|
|
|
|
DeferCleanup(func() {
|
|
_ = NewUserRepository(db).Delete(adminCtx, user.ID)
|
|
_ = NewLibraryRepository(db).(*libraryRepository).delete(adminCtx, squirrel.Eq{"id": lib.ID})
|
|
})
|
|
return adminCtx, lib, user
|
|
}
|
|
|
|
var _ = BeforeSuite(func() {
|
|
conn := GetDBXBuilder()
|
|
ctx := log.NewContext(context.TODO())
|
|
ctx = request.WithUser(ctx, adminUser)
|
|
|
|
ur := NewUserRepository(conn)
|
|
for i := range testUsers {
|
|
err := ur.Put(ctx, &testUsers[i])
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// Associate users with library 1 (default test library)
|
|
for i := range testUsers {
|
|
err := ur.SetUserLibraries(ctx, testUsers[i].ID, []int{1})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
alr := NewAlbumRepository(conn).(*albumRepository)
|
|
for i := range testAlbums {
|
|
err := alr.Put(ctx, new(testAlbums[i]))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
arr := NewArtistRepository(conn)
|
|
for i := range testArtists {
|
|
err := arr.Put(ctx, new(testArtists[i]))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// Associate artists with library 1 (default test library)
|
|
lr := NewLibraryRepository(conn)
|
|
for i := range testArtists {
|
|
err := lr.AddArtist(ctx, 1, testArtists[i].ID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// Populate album_artists based on the AlbumArtistID relationships in testAlbums
|
|
artistIDs := map[string]bool{}
|
|
for _, a := range testArtists {
|
|
artistIDs[a.ID] = true
|
|
}
|
|
for i := range testAlbums {
|
|
a := testAlbums[i]
|
|
if a.AlbumArtistID == "" || !artistIDs[a.AlbumArtistID] {
|
|
continue
|
|
}
|
|
_, err := alr.executeSQL(ctx, squirrel.Insert("album_artists").SetMap(map[string]any{
|
|
"album_id": a.ID,
|
|
"artist_id": a.AlbumArtistID,
|
|
"role": "artist",
|
|
"sub_role": "",
|
|
}))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
mr := NewMediaFileRepository(conn)
|
|
for i := range testSongs {
|
|
err := mr.Put(ctx, &testSongs[i])
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
rar := NewRadioRepository(conn)
|
|
for i := range testRadios {
|
|
err := rar.Put(ctx, new(testRadios[i]))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
plsBest = model.Playlist{
|
|
Name: "Best",
|
|
Comment: "No Comments",
|
|
OwnerID: "userid",
|
|
OwnerName: "userid",
|
|
Public: true,
|
|
SongCount: 2,
|
|
}
|
|
plsBest.AddMediaFilesByID([]string{"1001", "1003"})
|
|
plsCool = model.Playlist{Name: "Cool", OwnerID: "userid", OwnerName: "userid"}
|
|
plsCool.AddMediaFilesByID([]string{"1004"})
|
|
testPlaylists = []*model.Playlist{&plsBest, &plsCool}
|
|
|
|
pr := NewPlaylistRepository(conn)
|
|
for i := range testPlaylists {
|
|
err := pr.Put(ctx, testPlaylists[i])
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// Prepare annotations
|
|
if err := arr.SetStar(ctx, true, artistBeatles.ID); err != nil {
|
|
panic(err)
|
|
}
|
|
ar, err := arr.Get(ctx, artistBeatles.ID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if ar == nil {
|
|
panic("artist not found after SetStar")
|
|
}
|
|
artistBeatles.Starred = true
|
|
artistBeatles.StarredAt = ar.StarredAt
|
|
testArtists[1] = artistBeatles
|
|
|
|
if err := alr.SetStar(ctx, true, albumRadioactivity.ID); err != nil {
|
|
panic(err)
|
|
}
|
|
al, err := alr.Get(ctx, albumRadioactivity.ID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if al == nil {
|
|
panic("album not found after SetStar")
|
|
}
|
|
albumRadioactivity.Starred = true
|
|
albumRadioactivity.StarredAt = al.StarredAt
|
|
testAlbums[2] = albumRadioactivity
|
|
|
|
if err := mr.SetStar(ctx, true, songComeTogether.ID); err != nil {
|
|
panic(err)
|
|
}
|
|
mf, err := mr.Get(ctx, songComeTogether.ID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
songComeTogether.Starred = true
|
|
songComeTogether.StarredAt = mf.StarredAt
|
|
testSongs[1] = songComeTogether
|
|
|
|
scrobbleRepo := NewScrobbleRepository(conn).(*scrobbleRepository)
|
|
for _, s := range scrobbles {
|
|
_, err := scrobbleRepo.executeSQL(ctx, squirrel.Insert("scrobbles").SetMap(map[string]any{
|
|
"media_file_id": s.MediaFileID,
|
|
"user_id": s.UserID,
|
|
"submission_time": s.SubmissionTime,
|
|
}))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
})
|
|
|
|
func GetDBXBuilder() *dbx.DB {
|
|
return dbx.NewFromDB(db.Db(), db.Dialect)
|
|
}
|
|
|
|
// storedRows reads playlist_tracks without joining media_file, so it also shows rows pointing to no track.
|
|
func storedRows(playlistID string) []string {
|
|
GinkgoHelper()
|
|
var rows []string
|
|
Expect(GetDBXBuilder().NewQuery("SELECT id || ':' || media_file_id FROM playlist_tracks WHERE playlist_id = {:id} ORDER BY id").
|
|
Bind(dbx.Params{"id": playlistID}).Column(&rows)).To(Succeed())
|
|
return rows
|
|
}
|
|
|
|
// collectCursor takes the cursor's underlying func type so the named cursor types
|
|
// (model.AlbumCursor, ...) infer T.
|
|
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {
|
|
GinkgoHelper()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
var out []T
|
|
for item, err := range cursor {
|
|
Expect(err).ToNot(HaveOccurred())
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|