2026-04-24 19:03:10 -08:00
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"
"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
2026-04-25 10:59:06 -08:00
adminUser = model . User {
2026-04-24 19:03:10 -08:00
ID : "sp-test-user-1" ,
UserName : "sptestuser" ,
Name : "SP Test User" ,
IsAdmin : true ,
}
2026-04-25 10:59:06 -08:00
regularUser = model . User {
2026-04-24 19:03:10 -08:00
ID : "sp-test-user-2" ,
UserName : "spotheruser" ,
Name : "SP Other User" ,
IsAdmin : false ,
}
)
func buildTestFS ( ) {
abbeyRoad := template ( _t {
fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields (#5585)
* fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields
ReplayGain values are stored in dedicated nullable columns (rg_album_gain,
rg_album_peak, rg_track_gain, rg_track_peak) rather than in the media_file.tags
JSON blob. The isMissing/isPresent operators previously only supported tag and
role fields, causing two failure modes:
1. Using the documented alias names (replaygain_album_gain etc.) from PR #5256:
these got registered as JSON tags from mappings.yaml, so isMissing queried
json_tree(media_file.tags, '$.replaygain_album_gain') which is always empty
-> the playlist matched ALL songs.
2. Using the canonical field names (rgalbumgain etc.): not a tag/role, so SQL
generation returned an error. Because refreshSmartPlaylist deletes old tracks
before regenerating, the abort left the playlist empty.
Fix: add Nullable bool to FieldInfo and mark the four ReplayGain fields. Add
static alias entries (replaygain_album_gain -> rgalbumgain etc.) with
Numeric+Nullable set; because AddTagNames skips names already in the field map,
these static entries take precedence over the mappings.yaml tag registration.
missingExpr now emits IS NULL / IS NOT NULL for nullable column fields instead
of the json_tree lookup.
Fixes #5584
* chore(smartplaylist): address code review feedback
- Simplify the isMissing/isPresent unsupported-field error message,
removing the internal "nullable fields" jargon
- Standardize comments on mappings.yaml (the actual filename)
- Clarify the alias precedence comment in the LookupField test
2026-06-10 17:12:31 -08:00
"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" ,
2026-04-24 19:03:10 -08:00
} )
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" ,
fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields (#5585)
* fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields
ReplayGain values are stored in dedicated nullable columns (rg_album_gain,
rg_album_peak, rg_track_gain, rg_track_peak) rather than in the media_file.tags
JSON blob. The isMissing/isPresent operators previously only supported tag and
role fields, causing two failure modes:
1. Using the documented alias names (replaygain_album_gain etc.) from PR #5256:
these got registered as JSON tags from mappings.yaml, so isMissing queried
json_tree(media_file.tags, '$.replaygain_album_gain') which is always empty
-> the playlist matched ALL songs.
2. Using the canonical field names (rgalbumgain etc.): not a tag/role, so SQL
generation returned an error. Because refreshSmartPlaylist deletes old tracks
before regenerating, the abort left the playlist empty.
Fix: add Nullable bool to FieldInfo and mark the four ReplayGain fields. Add
static alias entries (replaygain_album_gain -> rgalbumgain etc.) with
Numeric+Nullable set; because AddTagNames skips names already in the field map,
these static entries take precedence over the mappings.yaml tag registration.
missingExpr now emits IS NULL / IS NOT NULL for nullable column fields instead
of the json_tree lookup.
Fixes #5584
* chore(smartplaylist): address code review feedback
- Simplify the isMissing/isPresent unsupported-field error message,
removing the internal "nullable fields" jargon
- Standardize comments on mappings.yaml (the actual filename)
- Clarify the alias precedence comment in the LookupField test
2026-06-10 17:12:31 -08:00
_t { "genre" : "Rock;Blues" , "composer" : "Lennon/McCartney" , "bpm" : 120 , "grouping" : "Beatles Tracks" ,
"replaygain_track_gain" : "-7.1 dB" , "replaygain_track_peak" : "0.95" } ) ) ,
2026-04-24 19:03:10 -08:00
"Rock/The Beatles/Abbey Road/02 - Something.mp3" : abbeyRoad ( track ( 2 , "Something" ,
fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields (#5585)
* fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields
ReplayGain values are stored in dedicated nullable columns (rg_album_gain,
rg_album_peak, rg_track_gain, rg_track_peak) rather than in the media_file.tags
JSON blob. The isMissing/isPresent operators previously only supported tag and
role fields, causing two failure modes:
1. Using the documented alias names (replaygain_album_gain etc.) from PR #5256:
these got registered as JSON tags from mappings.yaml, so isMissing queried
json_tree(media_file.tags, '$.replaygain_album_gain') which is always empty
-> the playlist matched ALL songs.
2. Using the canonical field names (rgalbumgain etc.): not a tag/role, so SQL
generation returned an error. Because refreshSmartPlaylist deletes old tracks
before regenerating, the abort left the playlist empty.
Fix: add Nullable bool to FieldInfo and mark the four ReplayGain fields. Add
static alias entries (replaygain_album_gain -> rgalbumgain etc.) with
Numeric+Nullable set; because AddTagNames skips names already in the field map,
these static entries take precedence over the mappings.yaml tag registration.
missingExpr now emits IS NULL / IS NOT NULL for nullable column fields instead
of the json_tree lookup.
Fixes #5584
* chore(smartplaylist): address code review feedback
- Simplify the isMissing/isPresent unsupported-field error message,
removing the internal "nullable fields" jargon
- Standardize comments on mappings.yaml (the actual filename)
- Clarify the alias precedence comment in the LookupField test
2026-06-10 17:12:31 -08:00
_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
2026-04-24 19:03:10 -08:00
"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" ,
fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields (#5585)
* fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields
ReplayGain values are stored in dedicated nullable columns (rg_album_gain,
rg_album_peak, rg_track_gain, rg_track_peak) rather than in the media_file.tags
JSON blob. The isMissing/isPresent operators previously only supported tag and
role fields, causing two failure modes:
1. Using the documented alias names (replaygain_album_gain etc.) from PR #5256:
these got registered as JSON tags from mappings.yaml, so isMissing queried
json_tree(media_file.tags, '$.replaygain_album_gain') which is always empty
-> the playlist matched ALL songs.
2. Using the canonical field names (rgalbumgain etc.): not a tag/role, so SQL
generation returned an error. Because refreshSmartPlaylist deletes old tracks
before regenerating, the abort left the playlist empty.
Fix: add Nullable bool to FieldInfo and mark the four ReplayGain fields. Add
static alias entries (replaygain_album_gain -> rgalbumgain etc.) with
Numeric+Nullable set; because AddTagNames skips names already in the field map,
these static entries take precedence over the mappings.yaml tag registration.
missingExpr now emits IS NULL / IS NOT NULL for nullable column fields instead
of the json_tree lookup.
Fixes #5584
* chore(smartplaylist): address code review feedback
- Simplify the isMissing/isPresent unsupported-field error message,
removing the internal "nullable fields" jargon
- Standardize comments on mappings.yaml (the actual filename)
- Clarify the alias precedence comment in the LookupField test
2026-06-10 17:12:31 -08:00
"bitrate" : 900 , "samplerate" : 44100 , "bitdepth" : 16 ,
"replaygain_track_gain" : "-8.25 dB" , "replaygain_track_peak" : "0.99" } ) ) ,
2026-04-24 19:03:10 -08:00
"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 {
2026-04-25 10:59:06 -08:00
titles := evaluateRuleOrderedAs ( adminUser , jsonRule )
2026-04-24 19:03:10 -08:00
sort . Strings ( titles )
return titles
}
func evaluateRuleOrdered ( jsonRule string ) [ ] string {
2026-04-25 10:59:06 -08:00
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 )
2026-04-24 19:03:10 -08:00
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" ,
2026-04-25 10:59:06 -08:00
OwnerID : owner . ID ,
2026-04-24 19:03:10 -08:00
Rules : & rules ,
}
2026-04-25 10:59:06 -08:00
err = ds . Playlist ( userCtx ) . Put ( pls )
2026-04-24 19:03:10 -08:00
Expect ( err ) . ToNot ( HaveOccurred ( ) )
2026-04-25 10:59:06 -08:00
loaded , err := ds . Playlist ( userCtx ) . GetWithTracks ( pls . ID , true , false )
2026-04-24 19:03:10 -08:00
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 {
2026-04-25 10:59:06 -08:00
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 {
2026-04-24 19:03:10 -08:00
var rules criteria . Criteria
Expect ( json . Unmarshal ( [ ] byte ( jsonRule ) , & rules ) ) . To ( Succeed ( ) )
pls := & model . Playlist {
Name : "ref-smart-playlist" ,
OwnerID : owner . ID ,
2026-04-25 10:59:06 -08:00
Public : public ,
2026-04-24 19:03:10 -08:00
Rules : & rules ,
}
Expect ( ds . Playlist ( ctx ) . Put ( pls ) ) . To ( Succeed ( ) )
return pls . ID
}
var _ = BeforeSuite ( func ( ) {
2026-04-25 10:59:06 -08:00
ctx = request . WithUser ( GinkgoT ( ) . Context ( ) , adminUser )
2026-04-24 19:03:10 -08:00
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 ( ) ) }
2026-04-25 10:59:06 -08:00
userWithPass := adminUser
2026-04-24 19:03:10 -08:00
userWithPass . NewPassword = "password"
Expect ( initDS . User ( ctx ) . Put ( & userWithPass ) ) . To ( Succeed ( ) )
2026-04-25 10:59:06 -08:00
regularUserWithPass := regularUser
regularUserWithPass . NewPassword = "password"
Expect ( initDS . User ( ctx ) . Put ( & regularUserWithPass ) ) . To ( Succeed ( ) )
2026-04-24 19:03:10 -08:00
lib = model . Library { ID : 1 , Name : "Music Library" , Path : "fake:///music" }
Expect ( initDS . Library ( ctx ) . Put ( & lib ) ) . To ( Succeed ( ) )
2026-04-25 10:59:06 -08:00
Expect ( initDS . User ( ctx ) . SetUserLibraries ( adminUser . ID , [ ] int { lib . ID } ) ) . To ( Succeed ( ) )
Expect ( initDS . User ( ctx ) . SetUserLibraries ( regularUser . ID , [ ] int { lib . ID } ) ) . To ( Succeed ( ) )
2026-04-24 19:03:10 -08:00
2026-04-25 10:59:06 -08:00
loadedUser , err := initDS . User ( ctx ) . FindByUsername ( adminUser . UserName )
2026-04-24 19:03:10 -08:00
Expect ( err ) . ToNot ( HaveOccurred ( ) )
2026-04-25 10:59:06 -08:00
adminUser . Libraries = loadedUser . Libraries
2026-04-24 19:03:10 -08:00
2026-04-25 10:59:06 -08:00
loadedOther , err := initDS . User ( ctx ) . FindByUsername ( regularUser . UserName )
2026-04-24 19:03:10 -08:00
Expect ( err ) . ToNot ( HaveOccurred ( ) )
2026-04-25 10:59:06 -08:00
regularUser . Libraries = loadedOther . Libraries
2026-04-24 19:03:10 -08:00
2026-04-25 10:59:06 -08:00
ctx = request . WithUser ( GinkgoT ( ) . Context ( ) , adminUser )
2026-04-24 19:03:10 -08:00
buildTestFS ( )
s := scanner . New ( ctx , initDS , artwork . NoopCacheWarmer ( ) , events . NoopBroker ( ) ,
playlists . NewPlaylists ( initDS , core . NewImageUploadService ( ) ) , 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 ( ) {
2026-04-25 10:59:06 -08:00
ctx = request . WithUser ( GinkgoT ( ) . Context ( ) , adminUser )
2026-04-24 19:03:10 -08:00
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 ( ) ) }
}