navidrome/persistence/persistence_suite_test.go
Deluan 74e96a2bb7 fix(playlists): drop unknown track ids when adding to a playlist
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
2026-10-10 11:28:58 -04:00

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
}