mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
7 Commits
623d1802da
...
be3d0131b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be3d0131b0 | ||
|
|
ca83ebbb53 | ||
|
|
dc07dc413d | ||
|
|
3294bcacfc | ||
|
|
228211f925 | ||
|
|
a6a682b385 | ||
|
|
07b0584a66 |
20
.github/workflows/pipeline.yml
vendored
20
.github/workflows/pipeline.yml
vendored
@ -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
|
||||||
|
|||||||
2
.github/workflows/update-translations.yml
vendored
2
.github/workflows/update-translations.yml
vendored
@ -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:
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
15
db/db.go
15
db/db.go
@ -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")
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE annotation ADD COLUMN rated_at datetime;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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]*\")[^\"]*",
|
||||||
|
|||||||
@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"},
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'",
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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.*",
|
||||||
).
|
).
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
73
ui/src/utils/validations.test.js
Normal file
73
ui/src/utils/validations.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user