Compare commits

...

7 Commits

Author SHA1 Message Date
crazygolem
be3d0131b0
Merge 07b0584a664a55e6a49f95ef028094c681b301b9 into ca83ebbb53d536cac1c15d6f41101d4ca1b9269f 2025-11-26 23:01:38 +09:00
Deluan
ca83ebbb53 feat: add DevOptimizeDB flag to control SQLite optimization
Added a new DevOptimizeDB configuration flag (default true) that controls
whether SQLite PRAGMA OPTIMIZE and ANALYZE commands are executed. This allows
disabling database optimization operations for debugging or testing purposes.

The flag guards optimization commands in:
- db/db.go: Initial connection, post-migration, and shutdown optimization
- persistence/library_repository.go: Post-scan optimization
- db/migrations/migration.go: ANALYZE during forced full rescans

Set ND_DEVOPTIMIZEDB=false to disable all database optimization commands.
2025-11-25 19:49:03 -05:00
dependabot[bot]
dc07dc413d
chore(deps): bump golangci/golangci-lint-action in /.github/workflows (#4673)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 8 to 9.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v8...v9)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 23:36:19 -05:00
zacaj
3294bcacfc
feat: add Rated At field - #4653 (#4660)
* feat(model): add Rated At field - #4653

Signed-off-by: zacaj <zacaj@zacaj.com>

* fix(ui): ignore empty dates in rating/love tooltips - #4653

* refactor(ui): add isDateSet util function

Signed-off-by: zacaj <zacaj@zacaj.com>

* feat: add tests for isDateSet and rated_at sort mappings

Added comprehensive tests for isDateSet and urlValidate functions in
ui/src/utils/validations.test.js covering falsy values, Go zero date handling,
valid date strings, Date objects, and edge cases.

Added rated_at sort mapping to album, artist, and mediafile repositories,
following the same pattern as starred_at (sorting by rating first, then by
timestamp). This enables proper sorting by rating date in the UI.

---------

Signed-off-by: zacaj <zacaj@zacaj.com>
Co-authored-by: zacaj <zacaj@zacaj.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-11-24 23:18:05 -05:00
Deluan
228211f925 test: add smart playlist tag criteria tests for issue #4728
Add integration tests verifying the workaround for checking if a tag has any
value in smart playlists. The tests confirm that using 'contains' with an empty
string generates SQL that matches any non-empty tag value (value LIKE '%%'),
which is the recommended workaround for issue #4728.

Tests added:
- Verify contains with empty string matches tracks with tag values
- Verify notContains with empty string excludes tracks with tag values

Also updated test context to use GinkgoT().Context() instead of context.TODO().
2025-11-24 21:16:28 -05:00
dependabot[bot]
a6a682b385
chore(deps): bump actions/checkout from 5 to 6 in /.github/workflows (#4730)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 13:18:34 -05:00
Jeremiah Menétrey
07b0584a66 Rename external auth options
ReverseProxyWhitelist was regularly confusing users that enabled it for
non-authenticating reverse proxy setups.

The new option name makes it clear that it's related to authentication, not
just reverse proxies.
2025-08-01 16:43:35 +02:00
28 changed files with 315 additions and 55 deletions

View File

@ -25,7 +25,7 @@ jobs:
git_tag: ${{ steps.git-version.outputs.GIT_TAG }} git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
git_sha: ${{ steps.git-version.outputs.GIT_SHA }} git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true fetch-tags: true
@ -63,7 +63,7 @@ jobs:
name: Lint Go code name: Lint Go code
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Download TagLib - name: Download TagLib
uses: ./.github/actions/download-taglib uses: ./.github/actions/download-taglib
@ -71,7 +71,7 @@ jobs:
version: ${{ env.CROSS_TAGLIB_VERSION }} version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v8 uses: golangci/golangci-lint-action@v9
with: with:
version: latest version: latest
problem-matchers: true problem-matchers: true
@ -93,7 +93,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code into the Go module directory - name: Check out code into the Go module directory
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Download TagLib - name: Download TagLib
uses: ./.github/actions/download-taglib uses: ./.github/actions/download-taglib
@ -114,7 +114,7 @@ jobs:
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: "--max_old_space_size=4096"
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: 24 node-version: 24
@ -145,7 +145,7 @@ jobs:
name: Lint i18n files name: Lint i18n files
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- run: | - run: |
set -e set -e
for file in resources/i18n/*.json; do for file in resources/i18n/*.json; do
@ -191,7 +191,7 @@ jobs:
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_') PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Prepare Docker Buildx - name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker uses: ./.github/actions/prepare-docker
@ -264,7 +264,7 @@ jobs:
env: env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }} REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Download digests - name: Download digests
uses: actions/download-artifact@v6 uses: actions/download-artifact@v6
@ -318,7 +318,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- uses: actions/download-artifact@v6 - uses: actions/download-artifact@v6
with: with:
@ -352,7 +352,7 @@ jobs:
outputs: outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }} package_list: ${{ steps.set-package-list.outputs.package_list }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true fetch-tags: true

View File

@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }} if: ${{ github.repository_owner == 'navidrome' }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Get updated translations - name: Get updated translations
id: poeditor id: poeditor
env: env:

View File

@ -86,8 +86,7 @@ type configOptions struct {
AuthRequestLimit int AuthRequestLimit int
AuthWindowLength time.Duration AuthWindowLength time.Duration
PasswordEncryptionKey string PasswordEncryptionKey string
ReverseProxyUserHeader string ExtAuth extAuthOptions
ReverseProxyWhitelist string
Plugins pluginsOptions Plugins pluginsOptions
PluginConfig map[string]map[string]string PluginConfig map[string]map[string]string
HTTPSecurityHeaders secureOptions `json:",omitzero"` HTTPSecurityHeaders secureOptions `json:",omitzero"`
@ -131,6 +130,7 @@ type configOptions struct {
DevEnablePluginsInsights bool DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64 DevExternalArtistFetchMultiplier float64
DevOptimizeDB bool
} }
type scannerOptions struct { type scannerOptions struct {
@ -228,6 +228,11 @@ type pluginsOptions struct {
CacheSize string CacheSize string
} }
type extAuthOptions struct {
TrustedSources string
UserHeader string
}
var ( var (
Server = &configOptions{} Server = &configOptions{}
hooks []func() hooks []func()
@ -245,6 +250,7 @@ func LoadFromFile(confFile string) {
func Load(noConfigDump bool) { func Load(noConfigDump bool) {
parseIniFileConfiguration() parseIniFileConfiguration()
mapDeprecatedOptions()
err := viper.Unmarshal(&Server) err := viper.Unmarshal(&Server)
if err != nil { if err != nil {
@ -349,6 +355,7 @@ func Load(noConfigDump bool) {
logDeprecatedOptions("Scanner.GenreSeparators") logDeprecatedOptions("Scanner.GenreSeparators")
logDeprecatedOptions("Scanner.GroupAlbumReleases") logDeprecatedOptions("Scanner.GroupAlbumReleases")
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
logDeprecatedOptions("ReverseProxyWhitelist", "ReverseProxyUserHeader")
// Call init hooks // Call init hooks
for _, hook := range hooks { for _, hook := range hooks {
@ -368,6 +375,17 @@ func logDeprecatedOptions(options ...string) {
} }
} }
// mapDeprecatedOptions is used to provide backwards compatibility for deprecated options. It should be called after
// the config has been read by viper, but before unmarshalling it into the Config struct.
func mapDeprecatedOptions() {
if viper.IsSet("ReverseProxyWhitelist") {
viper.Set("ExtAuth.TrustedSources", viper.Get("ReverseProxyWhitelist"))
}
if viper.IsSet("ReverseProxyUserHeader") {
viper.Set("ExtAuth.UserHeader", viper.Get("ReverseProxyUserHeader"))
}
}
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it // parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default] // would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
// section into the root level. // section into the root level.
@ -427,7 +445,7 @@ func validatePurgeMissingOption() error {
} }
} }
if !valid { if !valid {
err := fmt.Errorf("Invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues) err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
log.Error(err.Error()) log.Error(err.Error())
Server.Scanner.PurgeMissing = consts.PurgeMissingNever Server.Scanner.PurgeMissing = consts.PurgeMissingNever
return err return err
@ -535,8 +553,8 @@ func setViperDefaults() {
viper.SetDefault("authrequestlimit", 5) viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second) viper.SetDefault("authwindowlength", 20*time.Second)
viper.SetDefault("passwordencryptionkey", "") viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("reverseproxyuserheader", "Remote-User") viper.SetDefault("extauth.userheader", "Remote-User")
viper.SetDefault("reverseproxywhitelist", "") viper.SetDefault("extauth.trustedsources", "")
viper.SetDefault("prometheus.enabled", false) viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath) viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "") viper.SetDefault("prometheus.password", "")
@ -609,6 +627,7 @@ func setViperDefaults() {
viper.SetDefault("devenablepluginsinsights", true) viper.SetDefault("devenablepluginsinsights", true)
viper.SetDefault("devplugincompilationtimeout", time.Minute) viper.SetDefault("devplugincompilationtimeout", time.Minute)
viper.SetDefault("devexternalartistfetchmultiplier", 1.5) viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
viper.SetDefault("devoptimizedb", true)
} }
func init() { func init() {

View File

@ -215,7 +215,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.ScanSchedule = conf.Server.Scanner.Schedule data.Config.ScanSchedule = conf.Server.Scanner.Schedule
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
data.Config.ReverseProxyConfigured = conf.Server.ReverseProxyWhitelist != "" data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != "" data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
data.Config.HasCustomTags = len(conf.Server.Tags) > 0 data.Config.HasCustomTags = len(conf.Server.Tags) > 0

View File

@ -45,10 +45,12 @@ func Db() *sql.DB {
if err != nil { if err != nil {
log.Fatal("Error opening database", err) log.Fatal("Error opening database", err)
} }
_, err = db.Exec("PRAGMA optimize=0x10002") if conf.Server.DevOptimizeDB {
if err != nil { _, err = db.Exec("PRAGMA optimize=0x10002")
log.Error("Error applying PRAGMA optimize", err) if err != nil {
return nil log.Error("Error applying PRAGMA optimize", err)
return nil
}
} }
return db return db
}) })
@ -99,7 +101,7 @@ func Init(ctx context.Context) func() {
log.Fatal(ctx, "Failed to apply new migrations", err) log.Fatal(ctx, "Failed to apply new migrations", err)
} }
if hasSchemaChanges { if hasSchemaChanges && conf.Server.DevOptimizeDB {
log.Debug(ctx, "Applying PRAGMA optimize after schema changes") log.Debug(ctx, "Applying PRAGMA optimize after schema changes")
_, err = db.ExecContext(ctx, "PRAGMA optimize") _, err = db.ExecContext(ctx, "PRAGMA optimize")
if err != nil { if err != nil {
@ -114,6 +116,9 @@ func Init(ctx context.Context) func() {
// Optimize runs PRAGMA optimize on each connection in the pool // Optimize runs PRAGMA optimize on each connection in the pool
func Optimize(ctx context.Context) { func Optimize(ctx context.Context) {
if !conf.Server.DevOptimizeDB {
return
}
numConns := Db().Stats().OpenConnections numConns := Db().Stats().OpenConnections
if numConns == 0 { if numConns == 0 {
log.Debug(ctx, "No open connections to optimize") log.Debug(ctx, "No open connections to optimize")

View File

@ -0,0 +1,7 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE annotation ADD COLUMN rated_at datetime;
-- +goose StatementEnd
-- +goose Down

View File

@ -7,6 +7,7 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
) )
@ -21,11 +22,13 @@ func notice(tx *sql.Tx, msg string) {
// Call this in migrations that requires a full rescan // Call this in migrations that requires a full rescan
func forceFullRescan(tx *sql.Tx) error { func forceFullRescan(tx *sql.Tx) error {
// If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`. // If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`.
_, err := tx.Exec(`ANALYZE;`) if conf.Server.DevOptimizeDB {
if err != nil { _, err := tx.Exec(`ANALYZE;`)
return err if err != nil {
return err
}
} }
_, err = tx.Exec(fmt.Sprintf(` _, err := tx.Exec(fmt.Sprintf(`
INSERT OR REPLACE into property (id, value) values ('%s', '1'); INSERT OR REPLACE into property (id, value) values ('%s', '1');
`, consts.FullScanAfterMigrationFlagKey)) `, consts.FullScanAfterMigrationFlagKey))
return err return err

View File

@ -29,8 +29,8 @@ var redacted = &Hook{
"(Secret:\")[\\w]*", "(Secret:\")[\\w]*",
"(Spotify.*ID:\")[\\w]*", "(Spotify.*ID:\")[\\w]*",
"(PasswordEncryptionKey:[\\s]*\")[^\"]*", "(PasswordEncryptionKey:[\\s]*\")[^\"]*",
"(ReverseProxyUserHeader:[\\s]*\")[^\"]*", "(UserHeader:[\\s]*\")[^\"]*",
"(ReverseProxyWhitelist:[\\s]*\")[^\"]*", "(TrustedSources:[\\s]*\")[^\"]*",
"(MetricsPath:[\\s]*\")[^\"]*", "(MetricsPath:[\\s]*\")[^\"]*",
"(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*", "(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*",
"(DevAutoLoginUsername:[\\s]*\")[^\"]*", "(DevAutoLoginUsername:[\\s]*\")[^\"]*",

View File

@ -6,6 +6,7 @@ type Annotations struct {
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"` PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" ` PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
Rating int `structs:"rating" json:"rating,omitempty" ` Rating int `structs:"rating" json:"rating,omitempty" `
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
Starred bool `structs:"starred" json:"starred,omitempty" ` Starred bool `structs:"starred" json:"starred,omitempty" `
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"` StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
} }

View File

@ -44,6 +44,7 @@ var fieldMap = map[string]*mappedField{
"loved": {field: "COALESCE(annotation.starred, false)"}, "loved": {field: "COALESCE(annotation.starred, false)"},
"dateloved": {field: "annotation.starred_at"}, "dateloved": {field: "annotation.starred_at"},
"lastplayed": {field: "annotation.play_date"}, "lastplayed": {field: "annotation.play_date"},
"daterated": {field: "annotation.rated_at"},
"playcount": {field: "COALESCE(annotation.play_count, 0)"}, "playcount": {field: "COALESCE(annotation.play_count, 0)"},
"rating": {field: "COALESCE(annotation.rating, 0)"}, "rating": {field: "COALESCE(annotation.rating, 0)"},
"mbz_album_id": {field: "media_file.mbz_album_id"}, "mbz_album_id": {field: "media_file.mbz_album_id"},

View File

@ -106,6 +106,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
"random": "random", "random": "random",
"recently_added": recentlyAddedSort(), "recently_added": recentlyAddedSort(),
"starred_at": "starred, starred_at", "starred_at": "starred, starred_at",
"rated_at": "rating, rated_at",
}) })
return r return r
} }

View File

@ -141,6 +141,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
r.setSortMappings(map[string]string{ r.setSortMappings(map[string]string{
"name": "order_artist_name", "name": "order_artist_name",
"starred_at": "starred, starred_at", "starred_at": "starred, starred_at",
"rated_at": "rating, rated_at",
"song_count": "stats->>'total'->>'m'", "song_count": "stats->>'total'->>'m'",
"album_count": "stats->>'total'->>'a'", "album_count": "stats->>'total'->>'a'",
"size": "stats->>'total'->>'s'", "size": "stats->>'total'->>'s'",

View File

@ -179,7 +179,9 @@ func (r *libraryRepository) ScanEnd(id int) error {
// https://www.sqlite.org/pragma.html#pragma_optimize // https://www.sqlite.org/pragma.html#pragma_optimize
// Use mask 0x10000 to check table sizes without running ANALYZE // Use mask 0x10000 to check table sizes without running ANALYZE
// Running ANALYZE can cause query planner issues with expression-based collation indexes // Running ANALYZE can cause query planner issues with expression-based collation indexes
_, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;")) if conf.Server.DevOptimizeDB {
_, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;"))
}
return err return err
} }

View File

@ -84,6 +84,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
"created_at": "media_file.created_at", "created_at": "media_file.created_at",
"recently_added": mediaFileRecentlyAddedSort(), "recently_added": mediaFileRecentlyAddedSort(),
"starred_at": "starred, starred_at", "starred_at": "starred, starred_at",
"rated_at": "rating, rated_at",
}) })
return r return r
} }

View File

@ -388,6 +388,7 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
"coalesce(play_count, 0) as play_count", "coalesce(play_count, 0) as play_count",
"play_date", "play_date",
"coalesce(rating, 0) as rating", "coalesce(rating, 0) as rating",
"rated_at",
"f.*", "f.*",
"playlist_tracks.*", "playlist_tracks.*",
"library.path as library_path", "library.path as library_path",

View File

@ -1,7 +1,6 @@
package persistence package persistence
import ( import (
"context"
"time" "time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
@ -11,13 +10,14 @@ import (
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
) )
var _ = Describe("PlaylistRepository", func() { var _ = Describe("PlaylistRepository", func() {
var repo model.PlaylistRepository var repo model.PlaylistRepository
BeforeEach(func() { BeforeEach(func() {
ctx := log.NewContext(context.TODO()) ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(ctx, GetDBXBuilder()) repo = NewPlaylistRepository(ctx, GetDBXBuilder())
}) })
@ -252,4 +252,118 @@ var _ = Describe("PlaylistRepository", func() {
Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11 Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
}) })
}) })
Describe("Smart Playlists with Tag Criteria", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string
var songWithGrouping, songWithoutGrouping model.MediaFile
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
// Register 'grouping' as a valid tag for smart playlists
criteria.AddTagNames([]string{"grouping"})
// Create a song with the grouping tag
songWithGrouping = model.MediaFile{
ID: "test-grouping-1",
Title: "Song With Grouping",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "/test/grouping/song1.mp3",
Tags: model.Tags{
"grouping": []string{"My Crate"},
},
Participants: model.Participants{},
LibraryID: 1,
Lyrics: "[]",
}
Expect(mfRepo.Put(&songWithGrouping)).To(Succeed())
// Create a song without the grouping tag
songWithoutGrouping = model.MediaFile{
ID: "test-grouping-2",
Title: "Song Without Grouping",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "/test/grouping/song2.mp3",
Tags: model.Tags{},
Participants: model.Participants{},
LibraryID: 1,
Lyrics: "[]",
}
Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed())
})
AfterEach(func() {
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
// Clean up test media files
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute()
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute()
})
It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() {
By("creating a smart playlist that checks if grouping tag has any value")
// This is the workaround for issue #4728: using 'contains' with empty string
// generates SQL: value LIKE '%%' which matches any non-empty string
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"grouping": ""},
},
}
newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying only the track with grouping tag is matched")
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID))
})
It("excludes tracks with a tag value using 'notContains' with empty string", func() {
By("creating a smart playlist that checks if grouping tag is NOT set")
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.NotContains{"grouping": ""},
},
}
newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying the track with grouping is NOT in the playlist")
for _, track := range pls.Tracks {
Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID))
}
By("verifying the track without grouping IS in the playlist")
var foundWithoutGrouping bool
for _, track := range pls.Tracks {
if track.MediaFileID == songWithoutGrouping.ID {
foundWithoutGrouping = true
break
}
}
Expect(foundWithoutGrouping).To(BeTrue())
})
})
}) })

View File

@ -97,6 +97,7 @@ func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
"coalesce(rating, 0) as rating", "coalesce(rating, 0) as rating",
"starred_at", "starred_at",
"play_date", "play_date",
"rated_at",
"f.*", "f.*",
"playlist_tracks.*", "playlist_tracks.*",
). ).

View File

@ -28,6 +28,7 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
"coalesce(rating, 0) as rating", "coalesce(rating, 0) as rating",
"starred_at", "starred_at",
"play_date", "play_date",
"rated_at",
) )
if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" { if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" {
query = query.Columns( query = query.Columns(
@ -77,7 +78,8 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error {
} }
func (r sqlRepository) SetRating(rating int, itemID string) error { func (r sqlRepository) SetRating(rating int, itemID string) error {
return r.annUpsert(map[string]interface{}{"rating": rating}, itemID) ratedAt := time.Now()
return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
} }
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error { func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {

View File

@ -193,24 +193,24 @@ func UsernameFromToken(r *http.Request) string {
return token.Subject() return token.Subject()
} }
func UsernameFromReverseProxyHeader(r *http.Request) string { func UsernameFromExtAuthHeader(r *http.Request) string {
if conf.Server.ReverseProxyWhitelist == "" { if conf.Server.ExtAuth.TrustedSources == "" {
return "" return ""
} }
reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context()) reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context())
if !ok { if !ok {
log.Error("ReverseProxyWhitelist enabled but no proxy IP found in request context. Please report this error.") log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.")
return "" return ""
} }
if !validateIPAgainstList(reverseProxyIp, conf.Server.ReverseProxyWhitelist) { if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
log.Warn(r.Context(), "IP is not whitelisted for reverse proxy login", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr) log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
return "" return ""
} }
username := r.Header.Get(conf.Server.ReverseProxyUserHeader) username := r.Header.Get(conf.Server.ExtAuth.UserHeader)
if username == "" { if username == "" {
return "" return ""
} }
log.Trace(r, "Found username in ReverseProxyUserHeader", "username", username) log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username)
return username return username
} }
@ -256,7 +256,7 @@ func authenticateRequest(ds model.DataStore, r *http.Request, findUsernameFns ..
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromReverseProxyHeader) ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader)
if err != nil { if err != nil {
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return return
@ -291,7 +291,7 @@ func JWTRefresher(next http.Handler) http.Handler {
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} { func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} {
username := UsernameFromConfig(r) username := UsernameFromConfig(r)
if username == "" { if username == "" {
username = UsernameFromReverseProxyHeader(r) username = UsernameFromExtAuthHeader(r)
if username == "" { if username == "" {
return nil return nil
} }

View File

@ -80,7 +80,7 @@ var _ = Describe("Auth", func() {
req.Header.Add("Remote-User", "janedoe") req.Header.Add("Remote-User", "janedoe")
resp = httptest.NewRecorder() resp = httptest.NewRecorder()
conf.Server.UILoginBackgroundURL = "" conf.Server.UILoginBackgroundURL = ""
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16,2001:4860:4860::/48" conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16,2001:4860:4860::/48"
}) })
It("sets auth data if IPv4 matches whitelist", func() { It("sets auth data if IPv4 matches whitelist", func() {
@ -155,7 +155,7 @@ var _ = Describe("Auth", func() {
It("does not set auth data when listening on unix socket without whitelist", func() { It("does not set auth data when listening on unix socket without whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test" conf.Server.Address = "unix:/tmp/navidrome-test"
conf.Server.ReverseProxyWhitelist = "" conf.Server.ExtAuth.TrustedSources = ""
// No ReverseProxyIp in request context // No ReverseProxyIp in request context
serveIndex(ds, fs, nil)(resp, req) serveIndex(ds, fs, nil)(resp, req)
@ -176,7 +176,7 @@ var _ = Describe("Auth", func() {
It("sets auth data when listening on unix socket with correct whitelist", func() { It("sets auth data when listening on unix socket with correct whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test" conf.Server.Address = "unix:/tmp/navidrome-test"
conf.Server.ReverseProxyWhitelist = conf.Server.ReverseProxyWhitelist + ",@" conf.Server.ExtAuth.TrustedSources = conf.Server.ExtAuth.TrustedSources + ",@"
req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@")) req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@"))
serveIndex(ds, fs, nil)(resp, req) serveIndex(ds, fs, nil)(resp, req)
@ -302,8 +302,8 @@ var _ = Describe("Auth", func() {
ds = &tests.MockDataStore{} ds = &tests.MockDataStore{}
req = httptest.NewRequest("GET", "/", nil) req = httptest.NewRequest("GET", "/", nil)
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP)) req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP))
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16" conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16"
conf.Server.ReverseProxyUserHeader = "Remote-User" conf.Server.ExtAuth.UserHeader = "Remote-User"
}) })
It("makes the first user an admin", func() { It("makes the first user an admin", func() {

View File

@ -168,7 +168,7 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler {
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's // realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
// context if navidrome is behind a trusted reverse proxy. // context if navidrome is behind a trusted reverse proxy.
func realIPMiddleware(next http.Handler) http.Handler { func realIPMiddleware(next http.Handler) http.Handler {
if conf.Server.ReverseProxyWhitelist != "" { if conf.Server.ExtAuth.TrustedSources != "" {
return chi.Chain( return chi.Chain(
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }), reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
middleware.RealIP, middleware.RealIP,

View File

@ -56,7 +56,7 @@ func fromInternalOrProxyAuth(r *http.Request) (string, bool) {
return username, true return username, true
} }
return server.UsernameFromReverseProxyHeader(r), false return server.UsernameFromExtAuthHeader(r), false
} }
func checkRequiredParameters(next http.Handler) http.Handler { func checkRequiredParameters(next http.Handler) http.Handler {

View File

@ -95,8 +95,8 @@ var _ = Describe("Middlewares", func() {
}) })
It("passes when all required params are available (reverse-proxy case)", func() { It("passes when all required params are available (reverse-proxy case)", func() {
conf.Server.ReverseProxyWhitelist = "127.0.0.234/32" conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32"
conf.Server.ReverseProxyUserHeader = "Remote-User" conf.Server.ExtAuth.UserHeader = "Remote-User"
r := newGetRequest("v=1.15", "c=test") r := newGetRequest("v=1.15", "c=test")
r.Header.Add("Remote-User", "user") r.Header.Add("Remote-User", "user")
@ -254,8 +254,8 @@ var _ = Describe("Middlewares", func() {
When("using reverse proxy authentication", func() { When("using reverse proxy authentication", func() {
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.ReverseProxyWhitelist = "192.168.1.1/24" conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24"
conf.Server.ReverseProxyUserHeader = "Remote-User" conf.Server.ExtAuth.UserHeader = "Remote-User"
}) })
It("passes authentication with correct IP and header", func() { It("passes authentication with correct IP and header", func() {

View File

@ -1,10 +1,11 @@
import React from 'react' import React from 'react'
import { isDateSet } from '../utils/validations'
import { DateField as RADateField } from 'react-admin' import { DateField as RADateField } from 'react-admin'
export const DateField = (props) => { export const DateField = (props) => {
const { record, source } = props const { record, source } = props
const value = record?.[source] const value = record?.[source]
if (value === '0001-01-01T00:00:00Z' || value === null) return null if (!isDateSet(value)) return null
return <RADateField {...props} /> return <RADateField {...props} />
} }

View File

@ -7,6 +7,7 @@ import { makeStyles } from '@material-ui/core/styles'
import { useToggleLove } from './useToggleLove' import { useToggleLove } from './useToggleLove'
import { useRecordContext } from 'react-admin' import { useRecordContext } from 'react-admin'
import config from '../config' import config from '../config'
import { isDateSet } from '../utils/validations'
const useStyles = makeStyles({ const useStyles = makeStyles({
love: { love: {
@ -46,8 +47,13 @@ export const LoveButton = ({
<Button <Button
onClick={handleToggleLove} onClick={handleToggleLove}
size={'small'} size={'small'}
disabled={disabled || loading || record?.missing} disabled={disabled || loading || record.missing}
className={classes.love} className={classes.love}
title={
isDateSet(record.starredAt)
? new Date(record.starredAt).toLocaleString()
: undefined
}
{...rest} {...rest}
> >
{record.starred ? ( {record.starred ? (

View File

@ -2,6 +2,7 @@ import React, { useCallback } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import Rating from '@material-ui/lab/Rating' import Rating from '@material-ui/lab/Rating'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { isDateSet } from '../utils/validations'
import StarBorderIcon from '@material-ui/icons/StarBorder' import StarBorderIcon from '@material-ui/icons/StarBorder'
import clsx from 'clsx' import clsx from 'clsx'
import { useRating } from './useRating' import { useRating } from './useRating'
@ -45,7 +46,14 @@ export const RatingField = ({
) )
return ( return (
<span onClick={(e) => stopPropagation(e)}> <span
onClick={(e) => stopPropagation(e)}
title={
isDateSet(record.ratedAt)
? new Date(record.ratedAt).toLocaleString()
: undefined
}
>
<Rating <Rating
name={record.mediaFileId || record.id} name={record.mediaFileId || record.id}
className={clsx( className={clsx(

View File

@ -10,3 +10,16 @@ export const urlValidate = (value) => {
return 'ra.validation.url' return 'ra.validation.url'
} }
} }
export function isDateSet(date) {
if (!date) {
return false
}
if (typeof date === 'string') {
return date !== '0001-01-01T00:00:00Z'
}
if (date instanceof Date) {
return date.toISOString() !== '0001-01-01T00:00:00Z'
}
return !!date
}

View File

@ -0,0 +1,73 @@
import { isDateSet, urlValidate } from './validations'
describe('urlValidate', () => {
it('returns undefined for valid URLs', () => {
expect(urlValidate('https://example.com')).toBeUndefined()
expect(urlValidate('http://localhost:3000')).toBeUndefined()
expect(urlValidate('ftp://files.example.com')).toBeUndefined()
})
it('returns undefined for empty values', () => {
expect(urlValidate('')).toBeUndefined()
expect(urlValidate(null)).toBeUndefined()
expect(urlValidate(undefined)).toBeUndefined()
})
it('returns error for invalid URLs', () => {
expect(urlValidate('not-a-url')).toEqual('ra.validation.url')
expect(urlValidate('example.com')).toEqual('ra.validation.url')
expect(urlValidate('://missing-protocol')).toEqual('ra.validation.url')
})
})
describe('isDateSet', () => {
describe('with falsy values', () => {
it('returns false for null', () => {
expect(isDateSet(null)).toBe(false)
})
it('returns false for undefined', () => {
expect(isDateSet(undefined)).toBe(false)
})
it('returns false for empty string', () => {
expect(isDateSet('')).toBe(false)
})
})
describe('with Go zero date string', () => {
it('returns false for Go zero date', () => {
expect(isDateSet('0001-01-01T00:00:00Z')).toBe(false)
})
})
describe('with valid date strings', () => {
it('returns true for ISO date strings', () => {
expect(isDateSet('2024-01-15T10:30:00Z')).toBe(true)
expect(isDateSet('2023-12-25T00:00:00Z')).toBe(true)
})
it('returns true for other date formats', () => {
expect(isDateSet('2024-01-15')).toBe(true)
})
})
describe('with Date objects', () => {
it('returns true for valid Date objects', () => {
expect(isDateSet(new Date())).toBe(true)
expect(isDateSet(new Date('2024-01-15T10:30:00Z'))).toBe(true)
})
// Note: Date objects representing Go zero date would return true because
// toISOString() adds milliseconds (0001-01-01T00:00:00.000Z).
// In practice, dates from the API come as strings, not Date objects,
// so this edge case doesn't occur.
})
describe('with other truthy values', () => {
it('returns true for non-date truthy values', () => {
expect(isDateSet(123)).toBe(true)
expect(isDateSet({})).toBe(true)
})
})
})