Merge branch 'master' into kwg43w-codex/implement-starred/loved-playlists-functionality

This commit is contained in:
Deluan Quintão
2025-06-04 20:47:44 -04:00
committed by GitHub
53 changed files with 4744 additions and 443 deletions

View File

@@ -6,8 +6,6 @@ import (
"os"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/persistence"
@@ -70,7 +68,7 @@ func runScanner(ctx context.Context) {
ds := persistence.New(sqlDB)
pls := core.NewPlaylists(ds)
progress, err := scanner.CallScan(ctx, ds, artwork.NoopCacheWarmer(), pls, metrics.NewNoopInstance(), fullScan)
progress, err := scanner.CallScan(ctx, ds, pls, fullScan)
if err != nil {
log.Fatal(ctx, "Failed to scan", err)
}

View File

@@ -66,6 +66,7 @@ type configOptions struct {
CoverArtPriority string
CoverJpegQuality int
ArtistArtPriority string
LyricsPriority string
EnableGravatar bool
EnableFavourites bool
EnableStarRating bool
@@ -86,25 +87,23 @@ type configOptions struct {
PasswordEncryptionKey string
ReverseProxyUserHeader string
ReverseProxyWhitelist string
HTTPSecurityHeaders secureOptions
Prometheus prometheusOptions
Scanner scannerOptions
Jukebox jukeboxOptions
Backup backupOptions
PID pidOptions
Inspect inspectOptions
Subsonic subsonicOptions
LyricsPriority string
HTTPSecurityHeaders secureOptions `json:",omitzero"`
Prometheus prometheusOptions `json:",omitzero"`
Scanner scannerOptions `json:",omitzero"`
Jukebox jukeboxOptions `json:",omitzero"`
Backup backupOptions `json:",omitzero"`
PID pidOptions `json:",omitzero"`
Inspect inspectOptions `json:",omitzero"`
Subsonic subsonicOptions `json:",omitzero"`
LastFM lastfmOptions `json:",omitzero"`
Spotify spotifyOptions `json:",omitzero"`
ListenBrainz listenBrainzOptions `json:",omitzero"`
Tags map[string]TagConf `json:",omitempty"`
Agents string
LastFM lastfmOptions
Spotify spotifyOptions
ListenBrainz listenBrainzOptions
Tags map[string]TagConf
// DevFlags. These are used to enable/disable debugging and incomplete features
DevLogLevels map[string]string `json:",omitempty"`
DevLogSourceLine bool
DevLogLevels map[string]string
DevEnableProfiler bool
DevAutoCreateAdminPassword string
DevAutoLoginUsername string
@@ -112,6 +111,7 @@ type configOptions struct {
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevShowArtistPage bool
DevUIShowConfig bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
@@ -145,12 +145,12 @@ type subsonicOptions struct {
}
type TagConf struct {
Ignore bool `yaml:"ignore"`
Aliases []string `yaml:"aliases"`
Type string `yaml:"type"`
MaxLength int `yaml:"maxLength"`
Split []string `yaml:"split"`
Album bool `yaml:"album"`
Ignore bool `yaml:"ignore" json:",omitempty"`
Aliases []string `yaml:"aliases" json:",omitempty"`
Type string `yaml:"type" json:",omitempty"`
MaxLength int `yaml:"maxLength" json:",omitempty"`
Split []string `yaml:"split" json:",omitempty"`
Album bool `yaml:"album" json:",omitempty"`
}
type lastfmOptions struct {
@@ -478,7 +478,7 @@ func setViperDefaults() {
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
viper.SetDefault("ffmpegpath", "")
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s")
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
viper.SetDefault("coverjpegquality", 75)
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
@@ -553,6 +553,7 @@ func setViperDefaults() {
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
viper.SetDefault("devsidebarplaylists", true)
viper.SetDefault("devshowartistpage", true)
viper.SetDefault("devuishowconfig", true)
viper.SetDefault("devoffsetoptimize", 50000)
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)

View File

@@ -20,6 +20,12 @@ import (
"github.com/navidrome/navidrome/utils/str"
)
const (
// maxArtistFolderTraversalDepth defines how many directory levels to search
// when looking for artist images (artist folder + parent directories)
maxArtistFolderTraversalDepth = 3
)
type artistReader struct {
cacheKey
a *artwork
@@ -108,36 +114,52 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
return func() (io.ReadCloser, string, error) {
fsys := os.DirFS(artistFolder)
current := artistFolder
for i := 0; i < maxArtistFolderTraversalDepth; i++ {
if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
return reader, path, nil
}
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories`, pattern, artistFolder)
}
}
func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadCloser, string, error) {
log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", folder)
fsys := os.DirFS(folder)
matches, err := fs.Glob(fsys, pattern)
if err != nil {
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", artistFolder)
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", folder, err)
return nil, "", err
}
if len(matches) == 0 {
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, artistFolder)
}
for _, m := range matches {
filePath := filepath.Join(artistFolder, m)
if !model.IsImageFile(m) {
continue
}
filePath := filepath.Join(folder, m)
f, err := os.Open(filePath)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
return nil, "", err
continue
}
return f, filePath, nil
}
return nil, "", nil
}
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, folder)
}
func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) {
if len(albums) == 0 {
return "", time.Time{}, nil
}
libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library
libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries
folderPath := str.LongestCommonPrefix(paths)
if !strings.HasSuffix(folderPath, string(filepath.Separator)) {

View File

@@ -3,6 +3,8 @@ package artwork
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"time"
@@ -108,6 +110,254 @@ var _ = Describe("artistArtworkReader", func() {
})
})
})
var _ = Describe("fromArtistFolder", func() {
var (
ctx context.Context
tempDir string
testFunc sourceFunc
)
BeforeEach(func() {
ctx = context.Background()
tempDir = GinkgoT().TempDir()
})
When("artist folder contains matching image", func() {
BeforeEach(func() {
// Create test structure: /temp/artist/artist.jpg
artistDir := filepath.Join(tempDir, "artist")
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
artistImagePath := filepath.Join(artistDir, "artist.jpg")
Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
})
It("finds and returns the image", func() {
reader, path, err := testFunc()
Expect(err).ToNot(HaveOccurred())
Expect(reader).ToNot(BeNil())
Expect(path).To(ContainSubstring("artist.jpg"))
// Verify we can read the content
data, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(Equal("fake image data"))
reader.Close()
})
})
When("artist folder is empty but parent contains image", func() {
BeforeEach(func() {
// Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/
parentDir := filepath.Join(tempDir, "parent")
artistDir := filepath.Join(parentDir, "artist")
albumDir := filepath.Join(artistDir, "album")
Expect(os.MkdirAll(albumDir, 0755)).To(Succeed())
// Put artist image in parent directory
artistImagePath := filepath.Join(parentDir, "artist.jpg")
Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
})
It("finds image in parent directory", func() {
reader, path, err := testFunc()
Expect(err).ToNot(HaveOccurred())
Expect(reader).ToNot(BeNil())
Expect(path).To(ContainSubstring("parent" + string(filepath.Separator) + "artist.jpg"))
data, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(Equal("parent image"))
reader.Close()
})
})
When("image is two levels up", func() {
BeforeEach(func() {
// Create test structure: /temp/grandparent/artist.jpg and /temp/grandparent/parent/artist/
grandparentDir := filepath.Join(tempDir, "grandparent")
parentDir := filepath.Join(grandparentDir, "parent")
artistDir := filepath.Join(parentDir, "artist")
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
// Put artist image in grandparent directory
artistImagePath := filepath.Join(grandparentDir, "artist.jpg")
Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
})
It("finds image in grandparent directory", func() {
reader, path, err := testFunc()
Expect(err).ToNot(HaveOccurred())
Expect(reader).ToNot(BeNil())
Expect(path).To(ContainSubstring("grandparent" + string(filepath.Separator) + "artist.jpg"))
data, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(Equal("grandparent image"))
reader.Close()
})
})
When("images exist at multiple levels", func() {
BeforeEach(func() {
// Create test structure with images at multiple levels
grandparentDir := filepath.Join(tempDir, "grandparent")
parentDir := filepath.Join(grandparentDir, "parent")
artistDir := filepath.Join(parentDir, "artist")
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
// Put artist images at all levels
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist level"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
})
It("prioritizes the closest (artist folder) image", func() {
reader, path, err := testFunc()
Expect(err).ToNot(HaveOccurred())
Expect(reader).ToNot(BeNil())
Expect(path).To(ContainSubstring("artist" + string(filepath.Separator) + "artist.jpg"))
data, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(Equal("artist level"))
reader.Close()
})
})
When("pattern matches multiple files", func() {
BeforeEach(func() {
artistDir := filepath.Join(tempDir, "artist")
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
// Create multiple matching files
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.txt"), []byte("text file"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
})
It("returns the first valid image file", func() {
reader, path, err := testFunc()
Expect(err).ToNot(HaveOccurred())
Expect(reader).ToNot(BeNil())
// Should return an image file, not the text file
Expect(path).To(SatisfyAny(
ContainSubstring("artist.jpg"),
ContainSubstring("artist.png"),
))
Expect(path).ToNot(ContainSubstring("artist.txt"))
reader.Close()
})
})
When("no matching files exist anywhere", func() {
BeforeEach(func() {
artistDir := filepath.Join(tempDir, "artist")
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
// Create non-matching files
Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
})
It("returns an error", func() {
reader, path, err := testFunc()
Expect(err).To(HaveOccurred())
Expect(reader).To(BeNil())
Expect(path).To(BeEmpty())
Expect(err.Error()).To(ContainSubstring("no matches for 'artist.*'"))
Expect(err.Error()).To(ContainSubstring("parent directories"))
})
})
When("directory traversal reaches filesystem root", func() {
BeforeEach(func() {
// Start from a shallow directory to test root boundary
artistDir := filepath.Join(tempDir, "artist")
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
})
It("handles root boundary gracefully", func() {
reader, path, err := testFunc()
Expect(err).To(HaveOccurred())
Expect(reader).To(BeNil())
Expect(path).To(BeEmpty())
// Should not panic or cause infinite loop
})
})
When("file exists but cannot be opened", func() {
BeforeEach(func() {
artistDir := filepath.Join(tempDir, "artist")
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
// Create a file that cannot be opened (permission denied)
restrictedFile := filepath.Join(artistDir, "artist.jpg")
Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
})
It("logs warning and continues searching", func() {
// This test depends on the ability to restrict file permissions
// For now, we'll just ensure it doesn't panic and returns appropriate error
reader, _, err := testFunc()
// The file should be readable in test environment, so this will succeed
// In a real scenario with permission issues, it would continue searching
if err == nil {
Expect(reader).ToNot(BeNil())
reader.Close()
}
})
})
When("single album artist scenario (original issue)", func() {
BeforeEach(func() {
// Simulate the exact folder structure from the issue:
// /music/artist/album1/ (single album)
// /music/artist/artist.jpg (artist image that should be found)
artistDir := filepath.Join(tempDir, "music", "artist")
albumDir := filepath.Join(artistDir, "album1")
Expect(os.MkdirAll(albumDir, 0755)).To(Succeed())
// Create artist.jpg in the artist folder (this was not being found before)
artistImagePath := filepath.Join(artistDir, "artist.jpg")
Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed())
// The fromArtistFolder is called with the artist folder path
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
})
It("finds artist.jpg in artist folder for single album artist", func() {
reader, path, err := testFunc()
Expect(err).ToNot(HaveOccurred())
Expect(reader).ToNot(BeNil())
Expect(path).To(ContainSubstring("artist.jpg"))
Expect(path).To(ContainSubstring("artist"))
// Verify the content
data, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(Equal("single album artist image"))
reader.Close()
})
})
})
})
type fakeFolderRepo struct {

View File

@@ -10,11 +10,15 @@ import (
"strings"
"sync"
"github.com/kballard/go-shellquote"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
func start(ctx context.Context, args []string) (Executor, error) {
if len(args) == 0 {
return Executor{}, fmt.Errorf("no command arguments provided")
}
log.Debug("Executing mpv command", "cmd", args)
j := Executor{args: args}
j.PipeReader, j.out = io.Pipe()
@@ -71,28 +75,32 @@ func (j *Executor) wait() {
// Path will always be an absolute path
func createMPVCommand(deviceName string, filename string, socketName string) []string {
split := strings.Split(fixCmd(conf.Server.MPVCmdTemplate), " ")
for i, s := range split {
s = strings.ReplaceAll(s, "%d", deviceName)
s = strings.ReplaceAll(s, "%f", filename)
s = strings.ReplaceAll(s, "%s", socketName)
split[i] = s
}
return split
// Parse the template structure using shell parsing to handle quoted arguments
templateArgs, err := shellquote.Split(conf.Server.MPVCmdTemplate)
if err != nil {
log.Error("Failed to parse MPV command template", "template", conf.Server.MPVCmdTemplate, err)
return nil
}
func fixCmd(cmd string) string {
split := strings.Split(cmd, " ")
var result []string
cmdPath, _ := mpvCommand()
for _, s := range split {
if s == "mpv" || s == "mpv.exe" {
result = append(result, cmdPath)
} else {
result = append(result, s)
// Replace placeholders in each parsed argument to preserve spaces in substituted values
for i, arg := range templateArgs {
arg = strings.ReplaceAll(arg, "%d", deviceName)
arg = strings.ReplaceAll(arg, "%f", filename)
arg = strings.ReplaceAll(arg, "%s", socketName)
templateArgs[i] = arg
}
// Replace mpv executable references with the configured path
if len(templateArgs) > 0 {
cmdPath, err := mpvCommand()
if err == nil {
if templateArgs[0] == "mpv" || templateArgs[0] == "mpv.exe" {
templateArgs[0] = cmdPath
}
}
return strings.Join(result, " ")
}
return templateArgs
}
// This is a 1:1 copy of the stuff in ffmpeg.go, need to be unified.

View File

@@ -0,0 +1,17 @@
package mpv
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMPV(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "MPV Suite")
}

View File

@@ -0,0 +1,390 @@
package mpv
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("MPV", func() {
var (
testScript string
tempDir string
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Reset MPV cache
mpvOnce = sync.Once{}
mpvPath = ""
mpvErr = nil
// Create temporary directory for test files
var err error
tempDir, err = os.MkdirTemp("", "mpv_test_*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(tempDir) })
// Create mock MPV script that outputs arguments to stdout
testScript = createMockMPVScript(tempDir)
// Configure test MPV path
conf.Server.MPVPath = testScript
})
Describe("createMPVCommand", func() {
Context("with default template", func() {
BeforeEach(func() {
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
})
It("creates correct command with simple paths", func() {
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
Expect(args).To(Equal([]string{
testScript,
"--audio-device=auto",
"--no-audio-display",
"--pause",
"/music/test.mp3",
"--input-ipc-server=/tmp/socket",
}))
})
It("handles paths with spaces", func() {
args := createMPVCommand("auto", "/music/My Album/01 - Song.mp3", "/tmp/socket")
Expect(args).To(Equal([]string{
testScript,
"--audio-device=auto",
"--no-audio-display",
"--pause",
"/music/My Album/01 - Song.mp3",
"--input-ipc-server=/tmp/socket",
}))
})
It("handles complex device names", func() {
deviceName := "coreaudio/AppleUSBAudioEngine:Cambridge Audio :Cambridge Audio USB Audio 1.0:0000:1"
args := createMPVCommand(deviceName, "/music/test.mp3", "/tmp/socket")
Expect(args).To(Equal([]string{
testScript,
"--audio-device=" + deviceName,
"--no-audio-display",
"--pause",
"/music/test.mp3",
"--input-ipc-server=/tmp/socket",
}))
})
})
Context("with snapcast template (issue #3619)", func() {
BeforeEach(func() {
// This is the template that fails with naive space splitting
conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
})
It("creates correct command for snapcast integration", func() {
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
Expect(args).To(Equal([]string{
testScript,
"--no-audio-display",
"--pause",
"/music/test.mp3",
"--input-ipc-server=/tmp/socket",
"--audio-channels=stereo",
"--audio-samplerate=48000",
"--audio-format=s16",
"--ao=pcm",
"--ao-pcm-file=/audio/snapcast_fifo",
}))
})
})
Context("with wrapper script template", func() {
BeforeEach(func() {
// Test case that would break with naive splitting due to quoted arguments
conf.Server.MPVCmdTemplate = `/tmp/mpv.sh --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo`
})
It("handles wrapper script paths", func() {
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
Expect(args).To(Equal([]string{
"/tmp/mpv.sh",
"--no-audio-display",
"--pause",
"/music/test.mp3",
"--input-ipc-server=/tmp/socket",
"--audio-channels=stereo",
}))
})
})
Context("with extra spaces in template", func() {
BeforeEach(func() {
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
})
It("handles extra spaces correctly", func() {
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
Expect(args).To(Equal([]string{
testScript,
"--audio-device=auto",
"--no-audio-display",
"--pause",
"/music/test.mp3",
"--input-ipc-server=/tmp/socket",
}))
})
})
Context("with paths containing spaces in template arguments", func() {
BeforeEach(func() {
// Template with spaces in the path arguments themselves
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --ao-pcm-file="/audio/my folder/snapcast_fifo" --input-ipc-server=%s`
})
It("handles spaces in quoted template argument paths", func() {
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
// This test reveals the limitation of strings.Fields() - it will split on all spaces
// Expected behavior would be to keep the path as one argument
Expect(args).To(Equal([]string{
testScript,
"--no-audio-display",
"--pause",
"/music/test.mp3",
"--ao-pcm-file=/audio/my folder/snapcast_fifo", // This should be one argument
"--input-ipc-server=/tmp/socket",
}))
})
})
Context("with malformed template", func() {
BeforeEach(func() {
// Template with unmatched quotes that will cause shell parsing to fail
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote`
})
It("returns nil when shell parsing fails", func() {
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
Expect(args).To(BeNil())
})
})
Context("with empty template", func() {
BeforeEach(func() {
conf.Server.MPVCmdTemplate = ""
})
It("returns empty slice for empty template", func() {
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
Expect(args).To(Equal([]string{}))
})
})
})
Describe("start", func() {
BeforeEach(func() {
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
})
It("executes MPV command and captures arguments correctly", func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
deviceName := "auto"
filename := "/music/test.mp3"
socketName := "/tmp/test_socket"
args := createMPVCommand(deviceName, filename, socketName)
executor, err := start(ctx, args)
Expect(err).ToNot(HaveOccurred())
// Read all the output from stdout (this will block until the process finishes or is canceled)
output, err := io.ReadAll(executor)
Expect(err).ToNot(HaveOccurred())
// Parse the captured arguments
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
Expect(lines).To(HaveLen(6))
Expect(lines[0]).To(Equal(testScript))
Expect(lines[1]).To(Equal("--audio-device=auto"))
Expect(lines[2]).To(Equal("--no-audio-display"))
Expect(lines[3]).To(Equal("--pause"))
Expect(lines[4]).To(Equal("/music/test.mp3"))
Expect(lines[5]).To(Equal("--input-ipc-server=/tmp/test_socket"))
})
It("handles file paths with spaces", func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
deviceName := "auto"
filename := "/music/My Album/01 - My Song.mp3"
socketName := "/tmp/test socket"
args := createMPVCommand(deviceName, filename, socketName)
executor, err := start(ctx, args)
Expect(err).ToNot(HaveOccurred())
// Read all the output from stdout (this will block until the process finishes or is canceled)
output, err := io.ReadAll(executor)
Expect(err).ToNot(HaveOccurred())
// Parse the captured arguments
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
Expect(lines).To(ContainElement("/music/My Album/01 - My Song.mp3"))
Expect(lines).To(ContainElement("--input-ipc-server=/tmp/test socket"))
})
Context("with complex snapcast configuration", func() {
BeforeEach(func() {
conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
})
It("passes all snapcast arguments correctly", func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
deviceName := "auto"
filename := "/music/album/track.flac"
socketName := "/tmp/mpv-ctrl-test.socket"
args := createMPVCommand(deviceName, filename, socketName)
executor, err := start(ctx, args)
Expect(err).ToNot(HaveOccurred())
// Read all the output from stdout (this will block until the process finishes or is canceled)
output, err := io.ReadAll(executor)
Expect(err).ToNot(HaveOccurred())
// Parse the captured arguments
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
// Verify all expected arguments are present
Expect(lines).To(ContainElement("--no-audio-display"))
Expect(lines).To(ContainElement("--pause"))
Expect(lines).To(ContainElement("/music/album/track.flac"))
Expect(lines).To(ContainElement("--input-ipc-server=/tmp/mpv-ctrl-test.socket"))
Expect(lines).To(ContainElement("--audio-channels=stereo"))
Expect(lines).To(ContainElement("--audio-samplerate=48000"))
Expect(lines).To(ContainElement("--audio-format=s16"))
Expect(lines).To(ContainElement("--ao=pcm"))
Expect(lines).To(ContainElement("--ao-pcm-file=/audio/snapcast_fifo"))
})
})
Context("with nil args", func() {
It("returns error when args is nil", func() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
_, err := start(ctx, nil)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(Equal("no command arguments provided"))
})
It("returns error when args is empty", func() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
_, err := start(ctx, []string{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(Equal("no command arguments provided"))
})
})
})
Describe("mpvCommand", func() {
BeforeEach(func() {
// Reset the mpv command cache
mpvOnce = sync.Once{}
mpvPath = ""
mpvErr = nil
})
It("finds the configured MPV path", func() {
conf.Server.MPVPath = testScript
path, err := mpvCommand()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(testScript))
})
})
Describe("NewTrack integration", func() {
var testMediaFile model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.MPVPath = testScript
// Create a test media file
testMediaFile = model.MediaFile{
ID: "test-id",
Path: "/music/test.mp3",
}
})
Context("with malformed template", func() {
BeforeEach(func() {
// Template with unmatched quotes that will cause shell parsing to fail
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote`
})
It("returns error when createMPVCommand fails", func() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
playbackDone := make(chan bool, 1)
_, err := NewTrack(ctx, playbackDone, "auto", testMediaFile)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(Equal("no mpv command arguments provided"))
})
})
})
})
// createMockMPVScript creates a mock script that outputs arguments to stdout
func createMockMPVScript(tempDir string) string {
var scriptContent string
var scriptExt string
if runtime.GOOS == "windows" {
scriptExt = ".bat"
scriptContent = `@echo off
echo %0
:loop
if "%~1"=="" goto end
echo %~1
shift
goto loop
:end
`
} else {
scriptExt = ".sh"
scriptContent = `#!/bin/bash
echo "$0"
for arg in "$@"; do
echo "$arg"
done
`
}
scriptPath := filepath.Join(tempDir, "mock_mpv"+scriptExt)
err := os.WriteFile(scriptPath, []byte(scriptContent), 0755) // nolint:gosec
if err != nil {
panic(fmt.Sprintf("Failed to create mock script: %v", err))
}
return scriptPath
}

View File

@@ -34,7 +34,10 @@ func NewTrack(ctx context.Context, playbackDoneChannel chan bool, deviceName str
tmpSocketName := socketName("mpv-ctrl-", ".socket")
args := createMPVCommand(deviceName, mf.Path, tmpSocketName)
args := createMPVCommand(deviceName, mf.AbsolutePath(), tmpSocketName)
if len(args) == 0 {
return nil, fmt.Errorf("no mpv command arguments provided")
}
exe, err := start(ctx, args)
if err != nil {
log.Error("Error starting mpv process", err)

2
go.mod
View File

@@ -34,6 +34,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1
github.com/jellydator/ttlcache/v3 v3.3.0
github.com/kardianos/service v1.2.2
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/matoous/go-nanoid/v2 v2.1.0
@@ -85,7 +86,6 @@ require (
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect

View File

@@ -104,6 +104,7 @@ type PlaylistRepository interface {
FindByPath(path string) (*Playlist, error)
Delete(id string) error
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
GetPlaylists(mediaFileId string) (Playlists, error)
}
type PlaylistTrack struct {

View File

@@ -203,6 +203,25 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli
return playlists, err
}
func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) {
sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}).
Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id").
Where(And{Eq{"playlist_tracks.media_file_id": mediaFileId}, r.userFilter()})
var res []dbPlaylist
err := r.queryAll(sel, &res)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return model.Playlists{}, nil
}
return nil, err
}
playlists := make(model.Playlists, len(res))
for i, p := range res {
playlists[i] = p.Playlist
}
return playlists, nil
}
func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder {
query := r.newSelect(options...).Join("user on user.id = owner_id").
Columns(r.tableName+".*", "user.user_name as owner_name")

View File

@@ -152,6 +152,21 @@ var _ = Describe("PlaylistRepository", func() {
})
})
Describe("GetPlaylists", func() {
It("returns playlists for a track", func() {
pls, err := repo.GetPlaylists(songRadioactivity.ID)
Expect(err).ToNot(HaveOccurred())
Expect(pls).To(HaveLen(1))
Expect(pls[0].ID).To(Equal(plsBest.ID))
})
It("returns empty when none", func() {
pls, err := repo.GetPlaylists("9999")
Expect(err).ToNot(HaveOccurred())
Expect(pls).To(HaveLen(0))
})
})
Context("Smart Playlists", func() {
var rules *criteria.Criteria
BeforeEach(func() {

View File

@@ -99,10 +99,10 @@ func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
"playlist_tracks.*",
).
Join("media_file f on f.id = media_file_id").
Where(And{Eq{"playlist_id": r.playlistId}, Eq{"id": id}})
Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}})
var trk dbPlaylistTrack
err := r.queryOne(sel, &trk)
return trk.PlaylistTrack.MediaFile, err
return trk.PlaylistTrack, err
}
func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) {

View File

@@ -94,7 +94,7 @@
"recentlyPlayed": "Recientes",
"mostPlayed": "Más reproducidos",
"starred": "Favoritos",
"topRated": "Los mejores calificados"
"topRated": "Mejor calificados"
}
},
"artist": {

View File

@@ -197,11 +197,17 @@
"export": "Exportar",
"makePublic": "Pública",
"makePrivate": "Pessoal",
"saveQueue": "Salvar fila em nova Playlist"
"saveQueue": "Salvar fila em nova Playlist",
"searchOrCreate": "Buscar playlists ou criar nova...",
"pressEnterToCreate": "Pressione Enter para criar nova playlist",
"removeFromSelection": "Remover da seleção",
"removeSymbol": "×"
},
"message": {
"duplicate_song": "Adicionar músicas duplicadas",
"song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?"
"song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?",
"noPlaylistsFound": "Nenhuma playlist encontrada",
"noPlaylists": "Nenhuma playlist disponível"
}
},
"radio": {
@@ -496,6 +502,21 @@
"disabled": "Desligado",
"waiting": "Aguardando"
}
},
"tabs": {
"about": "Sobre",
"config": "Configuração"
},
"config": {
"configName": "Nome da Configuração",
"environmentVariable": "Variável de Ambiente",
"currentValue": "Valor Atual",
"configurationFile": "Arquivo de Configuração",
"exportToml": "Exportar Configuração (TOML)",
"exportSuccess": "Configuração exportada para o clipboard em formato TOML",
"exportFailed": "Falha ao copiar configuração",
"devFlagsHeader": "Flags de Desenvolvimento (sujeitas a mudança/remoção)",
"devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras"
}
},
"activity": {

View File

@@ -63,13 +63,12 @@ func (s *controller) getScanner() scanner {
if conf.Server.DevExternalScanner {
return &scannerExternal{}
}
return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls, metrics: s.metrics}
return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls}
}
// CallScan starts an in-process scan of the music library.
// This is meant to be called from the command line (see cmd/scan.go).
func CallScan(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, pls core.Playlists,
metrics metrics.Metrics, fullScan bool) (<-chan *ProgressInfo, error) {
func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool) (<-chan *ProgressInfo, error) {
release, err := lockScan(ctx)
if err != nil {
return nil, err
@@ -80,7 +79,7 @@ func CallScan(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, p
progress := make(chan *ProgressInfo, 100)
go func() {
defer close(progress)
scanner := &scannerImpl{ds: ds, cw: cw, pls: pls, metrics: metrics}
scanner := &scannerImpl{ds: ds, cw: artwork.NoopCacheWarmer(), pls: pls}
scanner.scanAll(ctx, fullScan, progress)
}()
return progress, nil
@@ -230,9 +229,11 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
}
// Send the final scan status event, with totals
if count, folderCount, err := s.getCounters(ctx); err != nil {
s.metrics.WriteAfterScanMetrics(ctx, false)
return scanWarnings, err
} else {
scanType, elapsed, lastErr := s.getScanInfo(ctx)
s.metrics.WriteAfterScanMetrics(ctx, true)
s.sendMessage(ctx, &events.ScanStatus{
Scanning: false,
Count: count,

View File

@@ -11,7 +11,6 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -22,7 +21,6 @@ type scannerImpl struct {
ds model.DataStore
cw artwork.CacheWarmer
pls core.Playlists
metrics metrics.Metrics
}
// scanState holds the state of an in-progress scan, to be passed to the various phases
@@ -111,7 +109,6 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err)
_ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, err.Error())
state.sendError(err)
s.metrics.WriteAfterScanMetrics(ctx, false)
return
}
@@ -121,7 +118,6 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
state.sendProgress(&ProgressInfo{ChangesDetected: true})
}
s.metrics.WriteAfterScanMetrics(ctx, err == nil)
log.Info(ctx, "Scanner: Finished scanning all libraries", "duration", time.Since(startTime))
}

View File

@@ -171,7 +171,7 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m
return u, nil
}
func jwtVerifier(next http.Handler) http.Handler {
func JWTVerifier(next http.Handler) http.Handler {
return jwtauth.Verify(auth.TokenAuth, tokenFromHeader, jwtauth.TokenFromCookie, jwtauth.TokenFromQuery)(next)
}

138
server/nativeapi/config.go Normal file
View File

@@ -0,0 +1,138 @@
package nativeapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/request"
)
// sensitiveFieldsPartialMask contains configuration field names that should be redacted
// using partial masking (first and last character visible, middle replaced with *).
// For values with 7+ characters: "secretvalue123" becomes "s***********3"
// For values with <7 characters: "short" becomes "****"
// Add field paths using dot notation (e.g., "LastFM.ApiKey", "Spotify.Secret")
var sensitiveFieldsPartialMask = []string{
"LastFM.ApiKey",
"LastFM.Secret",
"Prometheus.MetricsPath",
"Spotify.ID",
"Spotify.Secret",
"DevAutoLoginUsername",
}
// sensitiveFieldsFullMask contains configuration field names that should always be
// completely masked with "****" regardless of their length.
// Add field paths using dot notation for any fields that should never show any content.
var sensitiveFieldsFullMask = []string{
"DevAutoCreateAdminPassword",
"PasswordEncryptionKey",
"Prometheus.Password",
}
type configResponse struct {
ID string `json:"id"`
ConfigFile string `json:"configFile"`
Config map[string]interface{} `json:"config"`
}
func redactValue(key string, value string) string {
// Return empty values as-is
if len(value) == 0 {
return value
}
// Check if this field should be fully masked
for _, field := range sensitiveFieldsFullMask {
if field == key {
return "****"
}
}
// Check if this field should be partially masked
for _, field := range sensitiveFieldsPartialMask {
if field == key {
if len(value) < 7 {
return "****"
}
// Show first and last character with * in between
return string(value[0]) + strings.Repeat("*", len(value)-2) + string(value[len(value)-1])
}
}
// Return original value if not sensitive
return value
}
// applySensitiveFieldMasking recursively applies masking to sensitive fields in the configuration map
func applySensitiveFieldMasking(ctx context.Context, config map[string]interface{}, prefix string) {
for key, value := range config {
fullKey := key
if prefix != "" {
fullKey = prefix + "." + key
}
switch v := value.(type) {
case map[string]interface{}:
// Recursively process nested maps
applySensitiveFieldMasking(ctx, v, fullKey)
case string:
// Apply masking to string values
config[key] = redactValue(fullKey, v)
default:
// For other types (numbers, booleans, etc.), convert to string and check for masking
if str := fmt.Sprint(v); str != "" {
masked := redactValue(fullKey, str)
if masked != str {
// Only replace if masking was applied
config[key] = masked
}
}
}
}
}
func getConfig(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, _ := request.UserFrom(ctx)
if !user.IsAdmin {
http.Error(w, "Config endpoint is only available to admin users", http.StatusUnauthorized)
return
}
// Marshal the actual configuration struct to preserve original field names
configBytes, err := json.Marshal(*conf.Server)
if err != nil {
log.Error(ctx, "Error marshaling config", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Unmarshal back to map to get the structure with proper field names
var configMap map[string]interface{}
err = json.Unmarshal(configBytes, &configMap)
if err != nil {
log.Error(ctx, "Error unmarshaling config to map", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Apply sensitive field masking
applySensitiveFieldMasking(ctx, configMap, "")
resp := configResponse{
ID: "config",
ConfigFile: conf.Server.ConfigFile,
Config: configMap,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Error(ctx, "Error encoding config response", err)
}
}

View File

@@ -0,0 +1,147 @@
package nativeapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("getConfig", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
Context("when user is not admin", func() {
It("returns unauthorized", func() {
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: false})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
Context("when user is admin", func() {
It("returns config successfully", func() {
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
Expect(resp.ID).To(Equal("config"))
Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile))
Expect(resp.Config).ToNot(BeEmpty())
})
It("redacts sensitive fields", func() {
conf.Server.LastFM.ApiKey = "secretapikey123"
conf.Server.Spotify.Secret = "spotifysecret456"
conf.Server.PasswordEncryptionKey = "encryptionkey789"
conf.Server.DevAutoCreateAdminPassword = "adminpassword123"
conf.Server.Prometheus.Password = "prometheuspass"
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
// Check LastFM.ApiKey (partially masked)
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
// Check Spotify.Secret (partially masked)
spotify, ok := resp.Config["Spotify"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(spotify["Secret"]).To(Equal("s**************6"))
// Check PasswordEncryptionKey (fully masked)
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****"))
// Check DevAutoCreateAdminPassword (fully masked)
Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****"))
// Check Prometheus.Password (fully masked)
prometheus, ok := resp.Config["Prometheus"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(prometheus["Password"]).To(Equal("****"))
})
It("handles empty sensitive values", func() {
conf.Server.LastFM.ApiKey = ""
conf.Server.PasswordEncryptionKey = ""
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
// Check LastFM.ApiKey - should be preserved because it's sensitive
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(lastfm["ApiKey"]).To(Equal(""))
// Empty sensitive values should remain empty - should be preserved because it's sensitive
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal(""))
})
})
})
var _ = Describe("redactValue function", func() {
It("partially masks long sensitive values", func() {
Expect(redactValue("LastFM.ApiKey", "ba46f0e84a")).To(Equal("b********a"))
Expect(redactValue("Spotify.Secret", "verylongsecret123")).To(Equal("v***************3"))
})
It("fully masks long sensitive values that should be completely hidden", func() {
Expect(redactValue("PasswordEncryptionKey", "1234567890")).To(Equal("****"))
Expect(redactValue("DevAutoCreateAdminPassword", "1234567890")).To(Equal("****"))
Expect(redactValue("Prometheus.Password", "1234567890")).To(Equal("****"))
})
It("fully masks short sensitive values", func() {
Expect(redactValue("LastFM.Secret", "short")).To(Equal("****"))
Expect(redactValue("Spotify.ID", "abc")).To(Equal("****"))
Expect(redactValue("PasswordEncryptionKey", "12345")).To(Equal("****"))
Expect(redactValue("DevAutoCreateAdminPassword", "short")).To(Equal("****"))
Expect(redactValue("Prometheus.Password", "short")).To(Equal("****"))
})
It("does not mask non-sensitive values", func() {
Expect(redactValue("MusicFolder", "/path/to/music")).To(Equal("/path/to/music"))
Expect(redactValue("Port", "4533")).To(Equal("4533"))
Expect(redactValue("SomeOtherField", "secretvalue")).To(Equal("secretvalue"))
})
It("handles empty values", func() {
Expect(redactValue("LastFM.ApiKey", "")).To(Equal(""))
Expect(redactValue("NonSensitive", "")).To(Equal(""))
})
It("handles edge case values", func() {
Expect(redactValue("LastFM.ApiKey", "a")).To(Equal("****"))
Expect(redactValue("LastFM.ApiKey", "ab")).To(Equal("****"))
Expect(redactValue("LastFM.ApiKey", "abcdefg")).To(Equal("a*****g"))
})
})

View File

@@ -59,23 +59,12 @@ func (n *Router) routes() http.Handler {
n.addPlaylistRoute(r)
n.addPlaylistTrackRoute(r)
n.addSongPlaylistsRoute(r)
n.addMissingFilesRoute(r)
n.addInspectRoute(r)
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
})
// Insights status endpoint
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
last, success := n.insights.LastRun(r.Context())
if conf.Server.EnableInsightsCollector {
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
} else {
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`))
}
})
n.addConfigRoute(r)
n.addKeepAliveRoute(r)
n.addInsightsRoute(r)
})
return r
@@ -144,6 +133,9 @@ func (n *Router) addPlaylistTrackRoute(r chi.Router) {
})
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
getPlaylistTrack(n.ds)(w, r)
})
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
reorderItem(n.ds)(w, r)
})
@@ -154,6 +146,12 @@ func (n *Router) addPlaylistTrackRoute(r chi.Router) {
})
}
func (n *Router) addSongPlaylistsRoute(r chi.Router) {
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
getSongPlaylists(n.ds)(w, r)
})
}
func (n *Router) addMissingFilesRoute(r chi.Router) {
r.Route("/missing", func(r chi.Router) {
n.RX(r, "/", newMissingRepository(n.ds), false)
@@ -196,3 +194,26 @@ func (n *Router) addInspectRoute(r chi.Router) {
})
}
}
func (n *Router) addConfigRoute(r chi.Router) {
if conf.Server.DevUIShowConfig {
r.Get("/config/*", getConfig)
}
}
func (n *Router) addKeepAliveRoute(r chi.Router) {
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
})
}
func (n *Router) addInsightsRoute(r chi.Router) {
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
last, success := n.insights.LastRun(r.Context())
if conf.Server.EnableInsightsCollector {
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
} else {
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`))
}
})
}

View File

@@ -0,0 +1,464 @@
package nativeapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Simple mock implementations for missing types
type mockShare struct {
core.Share
}
func (m *mockShare) NewRepository(ctx context.Context) rest.Repository {
return &tests.MockShareRepo{}
}
type mockPlaylists struct {
core.Playlists
}
func (m *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
return &model.Playlist{}, nil
}
type mockInsights struct {
metrics.Insights
}
func (m *mockInsights) LastRun(ctx context.Context) (time.Time, bool) {
return time.Now(), true
}
var _ = Describe("Song Endpoints", func() {
var (
router http.Handler
ds *tests.MockDataStore
mfRepo *tests.MockMediaFileRepo
userRepo *tests.MockedUserRepo
w *httptest.ResponseRecorder
testUser model.User
testSongs model.MediaFiles
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SessionTimeout = time.Minute
// Setup mock repositories
mfRepo = tests.CreateMockMediaFileRepo()
userRepo = tests.CreateMockUserRepo()
ds = &tests.MockDataStore{
MockedMediaFile: mfRepo,
MockedUser: userRepo,
MockedProperty: &tests.MockedPropertyRepo{},
}
// Initialize auth system
auth.Init(ds)
// Create test user
testUser = model.User{
ID: "user-1",
UserName: "testuser",
Name: "Test User",
IsAdmin: false,
NewPassword: "testpass",
}
err := userRepo.Put(&testUser)
Expect(err).ToNot(HaveOccurred())
// Create test songs
testSongs = model.MediaFiles{
{
ID: "song-1",
Title: "Test Song 1",
Artist: "Test Artist 1",
Album: "Test Album 1",
AlbumID: "album-1",
ArtistID: "artist-1",
Duration: 180.5,
BitRate: 320,
Path: "/music/song1.mp3",
Suffix: "mp3",
Size: 5242880,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
{
ID: "song-2",
Title: "Test Song 2",
Artist: "Test Artist 2",
Album: "Test Album 2",
AlbumID: "album-2",
ArtistID: "artist-2",
Duration: 240.0,
BitRate: 256,
Path: "/music/song2.mp3",
Suffix: "mp3",
Size: 7340032,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
mfRepo.SetData(testSongs)
// Setup router with mocked dependencies
mockShareImpl := &mockShare{}
mockPlaylistsImpl := &mockPlaylists{}
mockInsightsImpl := &mockInsights{}
// Create the native API router and wrap it with the JWTVerifier middleware
nativeRouter := New(ds, mockShareImpl, mockPlaylistsImpl, mockInsightsImpl)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})
// Helper function to create unauthenticated request
createUnauthenticatedRequest := func(method, path string, body []byte) *http.Request {
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
return req
}
// Helper function to create authenticated request with JWT token
createAuthenticatedRequest := func(method, path string, body []byte) *http.Request {
req := createUnauthenticatedRequest(method, path, body)
// Create JWT token for the test user
token, err := auth.CreateToken(&testUser)
Expect(err).ToNot(HaveOccurred())
// Add JWT token to Authorization header
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
return req
}
Describe("GET /song", func() {
Context("when user is authenticated", func() {
It("returns all songs", func() {
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response).To(HaveLen(2))
Expect(response[0].ID).To(Equal("song-1"))
Expect(response[0].Title).To(Equal("Test Song 1"))
Expect(response[1].ID).To(Equal("song-2"))
Expect(response[1].Title).To(Equal("Test Song 2"))
})
It("handles repository errors gracefully", func() {
mfRepo.SetError(true)
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Context("when user is not authenticated", func() {
It("returns unauthorized", func() {
req := createUnauthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
Describe("GET /song/{id}", func() {
Context("when user is authenticated", func() {
It("returns the specific song", func() {
req := createAuthenticatedRequest("GET", "/song/song-1", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response.ID).To(Equal("song-1"))
Expect(response.Title).To(Equal("Test Song 1"))
Expect(response.Artist).To(Equal("Test Artist 1"))
})
It("returns 404 for non-existent song", func() {
req := createAuthenticatedRequest("GET", "/song/non-existent", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("handles repository errors gracefully", func() {
mfRepo.SetError(true)
req := createAuthenticatedRequest("GET", "/song/song-1", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Context("when user is not authenticated", func() {
It("returns unauthorized", func() {
req := createUnauthenticatedRequest("GET", "/song/song-1", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
Describe("Song endpoints are read-only", func() {
Context("POST /song", func() {
It("should not be available (songs are not persistable)", func() {
newSong := model.MediaFile{
Title: "New Song",
Artist: "New Artist",
Album: "New Album",
Duration: 200.0,
}
body, _ := json.Marshal(newSong)
req := createAuthenticatedRequest("POST", "/song", body)
router.ServeHTTP(w, req)
// Should return 405 Method Not Allowed or 404 Not Found
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
})
})
Context("PUT /song/{id}", func() {
It("should not be available (songs are not persistable)", func() {
updatedSong := model.MediaFile{
ID: "song-1",
Title: "Updated Song",
Artist: "Updated Artist",
Album: "Updated Album",
Duration: 250.0,
}
body, _ := json.Marshal(updatedSong)
req := createAuthenticatedRequest("PUT", "/song/song-1", body)
router.ServeHTTP(w, req)
// Should return 405 Method Not Allowed or 404 Not Found
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
})
})
Context("DELETE /song/{id}", func() {
It("should not be available (songs are not persistable)", func() {
req := createAuthenticatedRequest("DELETE", "/song/song-1", nil)
router.ServeHTTP(w, req)
// Should return 405 Method Not Allowed or 404 Not Found
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
})
})
})
Describe("Query parameters and filtering", func() {
Context("when using query parameters", func() {
It("handles pagination parameters", func() {
req := createAuthenticatedRequest("GET", "/song?_start=0&_end=1", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
// Should still return all songs since our mock doesn't implement pagination
// but the request should be processed successfully
Expect(len(response)).To(BeNumerically(">=", 1))
})
It("handles sort parameters", func() {
req := createAuthenticatedRequest("GET", "/song?_sort=title&_order=ASC", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response).To(HaveLen(2))
})
It("handles filter parameters", func() {
// Properly encode the URL with query parameters
baseURL := "/song"
params := url.Values{}
params.Add("title", "Test Song 1")
fullURL := baseURL + "?" + params.Encode()
req := createAuthenticatedRequest("GET", fullURL, nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
// Mock doesn't implement filtering, but request should be processed
Expect(len(response)).To(BeNumerically(">=", 1))
})
})
})
Describe("Response headers and content type", func() {
It("sets correct content type for JSON responses", func() {
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json"))
})
It("includes total count header when available", func() {
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
// The X-Total-Count header might be set by the REST framework
// We just verify the request is processed successfully
})
})
Describe("Edge cases and error handling", func() {
Context("when repository is unavailable", func() {
It("handles database connection errors", func() {
mfRepo.SetError(true)
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Context("when no songs exist", func() {
It("returns empty array when no songs are found", func() {
mfRepo.SetData(model.MediaFiles{}) // Empty dataset
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response).To(HaveLen(0))
})
})
})
Describe("Authentication middleware integration", func() {
Context("with different user types", func() {
It("works with admin users", func() {
adminUser := model.User{
ID: "admin-1",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
NewPassword: "adminpass",
}
err := userRepo.Put(&adminUser)
Expect(err).ToNot(HaveOccurred())
// Create JWT token for admin user
token, err := auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
req := createUnauthenticatedRequest("GET", "/song", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
})
It("works with regular users", func() {
regularUser := model.User{
ID: "user-2",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
NewPassword: "userpass",
}
err := userRepo.Put(&regularUser)
Expect(err).ToNot(HaveOccurred())
// Create JWT token for regular user
token, err := auth.CreateToken(&regularUser)
Expect(err).ToNot(HaveOccurred())
req := createUnauthenticatedRequest("GET", "/song", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
})
})
Context("with missing authentication context", func() {
It("rejects requests without user context", func() {
req := createUnauthenticatedRequest("GET", "/song", nil)
// No authentication header added
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("rejects requests with invalid JWT tokens", func() {
req := createUnauthenticatedRequest("GET", "/song", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer invalid.token.here")
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
})

View File

@@ -45,6 +45,23 @@ func getPlaylist(ds model.DataStore) http.HandlerFunc {
}
}
func getPlaylistTrack(ds model.DataStore) http.HandlerFunc {
// Add a middleware to capture the playlistId
wrapper := func(handler restHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
constructor := func(ctx context.Context) rest.Repository {
plsRepo := ds.Playlist(ctx)
plsId := chi.URLParam(r, "playlistId")
return plsRepo.Tracks(plsId, true)
}
handler(constructor).ServeHTTP(w, r)
}
}
return wrapper(rest.Get)
}
func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -207,3 +224,21 @@ func reorderItem(ds model.DataStore) http.HandlerFunc {
}
}
}
func getSongPlaylists(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := req.Params(r)
trackId, _ := p.String(":id")
playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data, err := json.Marshal(playlists)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write(data)
}
}

View File

@@ -65,6 +65,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
"devSidebarPlaylists": conf.Server.DevSidebarPlaylists,
"lastFMEnabled": conf.Server.LastFM.Enabled,
"devShowArtistPage": conf.Server.DevShowArtistPage,
"devUIShowConfig": conf.Server.DevUIShowConfig,
"listenBrainzEnabled": conf.Server.ListenBrainz.Enabled,
"enableExternalServices": conf.Server.EnableExternalServices,
"enableReplayGain": conf.Server.EnableReplayGain,

View File

@@ -304,6 +304,17 @@ var _ = Describe("serveIndex", func() {
Expect(config).To(HaveKeyWithValue("devShowArtistPage", true))
})
It("sets the devUIShowConfig", func() {
conf.Server.DevUIShowConfig = true
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("devUIShowConfig", true))
})
It("sets the listenBrainzEnabled", func() {
conf.Server.ListenBrainz.Enabled = true
r := httptest.NewRequest("GET", "/index.html", nil)

View File

@@ -173,7 +173,7 @@ func (s *Server) initRoutes() {
clientUniqueIDMiddleware,
compressMiddleware(),
loggerInjector,
jwtVerifier,
JWTVerifier,
}
// Mount the Native API /events endpoint with all default middlewares, adding the authentication middlewares

View File

@@ -4,26 +4,40 @@ import (
"net/http"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
)
// buildUserResponse creates a User response object from a User model
func buildUserResponse(user model.User) responses.User {
userResponse := responses.User{
Username: user.UserName,
AdminRole: user.IsAdmin,
Email: user.Email,
StreamRole: true,
ScrobblingEnabled: true,
DownloadRole: conf.Server.EnableDownloads,
ShareRole: conf.Server.EnableSharing,
}
if conf.Server.Jukebox.Enabled {
userResponse.JukeboxRole = !conf.Server.Jukebox.AdminOnly || user.IsAdmin
}
return userResponse
}
// TODO This is a placeholder. The real one has to read this info from a config file or the database
func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) {
loggedUser, ok := request.UserFrom(r.Context())
if !ok {
return nil, newError(responses.ErrorGeneric, "Internal error")
}
response := newResponse()
response.User = &responses.User{}
response.User.Username = loggedUser.UserName
response.User.AdminRole = loggedUser.IsAdmin
response.User.Email = loggedUser.Email
response.User.StreamRole = true
response.User.ScrobblingEnabled = true
response.User.DownloadRole = conf.Server.EnableDownloads
response.User.ShareRole = conf.Server.EnableSharing
response.User.JukeboxRole = conf.Server.Jukebox.Enabled
user := buildUserResponse(loggedUser)
response.User = &user
return response, nil
}
@@ -32,17 +46,8 @@ func (api *Router) GetUsers(r *http.Request) (*responses.Subsonic, error) {
if !ok {
return nil, newError(responses.ErrorGeneric, "Internal error")
}
user := responses.User{}
user.Username = loggedUser.Name
user.AdminRole = loggedUser.IsAdmin
user.Email = loggedUser.Email
user.StreamRole = true
user.ScrobblingEnabled = true
user.DownloadRole = conf.Server.EnableDownloads
user.ShareRole = conf.Server.EnableSharing
if conf.Server.Jukebox.Enabled {
user.JukeboxRole = !conf.Server.Jukebox.AdminOnly || loggedUser.IsAdmin
}
user := buildUserResponse(loggedUser)
response := newResponse()
response.Users = &responses.Users{User: []responses.User{user}}
return response, nil

View File

@@ -0,0 +1,96 @@
package subsonic
import (
"context"
"net/http/httptest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Users", func() {
var router *Router
var testUser model.User
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
router = &Router{}
testUser = model.User{
ID: "user123",
UserName: "testuser",
Name: "Test User",
Email: "test@example.com",
IsAdmin: false,
}
})
Describe("Happy path", func() {
It("should return consistent user data in both GetUser and GetUsers", func() {
conf.Server.EnableDownloads = true
conf.Server.EnableSharing = true
conf.Server.Jukebox.Enabled = false
// Create request with user in context
req := httptest.NewRequest("GET", "/rest/getUser", nil)
ctx := request.WithUser(context.Background(), testUser)
req = req.WithContext(ctx)
userResponse, err1 := router.GetUser(req)
usersResponse, err2 := router.GetUsers(req)
Expect(err1).ToNot(HaveOccurred())
Expect(err2).ToNot(HaveOccurred())
// Verify GetUser response structure
Expect(userResponse.Status).To(Equal(responses.StatusOK))
Expect(userResponse.User).ToNot(BeNil())
Expect(userResponse.User.Username).To(Equal("testuser"))
Expect(userResponse.User.Email).To(Equal("test@example.com"))
Expect(userResponse.User.AdminRole).To(BeFalse())
Expect(userResponse.User.StreamRole).To(BeTrue())
Expect(userResponse.User.ScrobblingEnabled).To(BeTrue())
Expect(userResponse.User.DownloadRole).To(BeTrue())
Expect(userResponse.User.ShareRole).To(BeTrue())
// Verify GetUsers response structure
Expect(usersResponse.Status).To(Equal(responses.StatusOK))
Expect(usersResponse.Users).ToNot(BeNil())
Expect(usersResponse.Users.User).To(HaveLen(1))
// Verify both methods return identical user data
singleUser := userResponse.User
userFromList := &usersResponse.Users.User[0]
Expect(singleUser.Username).To(Equal(userFromList.Username))
Expect(singleUser.Email).To(Equal(userFromList.Email))
Expect(singleUser.AdminRole).To(Equal(userFromList.AdminRole))
Expect(singleUser.StreamRole).To(Equal(userFromList.StreamRole))
Expect(singleUser.ScrobblingEnabled).To(Equal(userFromList.ScrobblingEnabled))
Expect(singleUser.DownloadRole).To(Equal(userFromList.DownloadRole))
Expect(singleUser.ShareRole).To(Equal(userFromList.ShareRole))
Expect(singleUser.JukeboxRole).To(Equal(userFromList.JukeboxRole))
})
})
DescribeTable("Jukebox role permissions",
func(jukeboxEnabled, adminOnly, isAdmin, expectedJukeboxRole bool) {
conf.Server.Jukebox.Enabled = jukeboxEnabled
conf.Server.Jukebox.AdminOnly = adminOnly
testUser.IsAdmin = isAdmin
response := buildUserResponse(testUser)
Expect(response.JukeboxRole).To(Equal(expectedJukeboxRole))
},
Entry("jukebox disabled", false, false, false, false),
Entry("jukebox enabled, not admin-only, regular user", true, false, false, true),
Entry("jukebox enabled, not admin-only, admin user", true, false, true, true),
Entry("jukebox enabled, admin-only, regular user", true, true, false, false),
Entry("jukebox enabled, admin-only, admin user", true, true, true, true),
)
})

View File

@@ -217,9 +217,34 @@ func (db *MockDataStore) WithTxImmediate(block func(tx model.DataStore) error, l
return block(db)
}
func (db *MockDataStore) Resource(context.Context, any) model.ResourceRepository {
func (db *MockDataStore) Resource(ctx context.Context, m any) model.ResourceRepository {
switch m.(type) {
case model.MediaFile, *model.MediaFile:
return db.MediaFile(ctx).(model.ResourceRepository)
case model.Album, *model.Album:
return db.Album(ctx).(model.ResourceRepository)
case model.Artist, *model.Artist:
return db.Artist(ctx).(model.ResourceRepository)
case model.User, *model.User:
return db.User(ctx).(model.ResourceRepository)
case model.Playlist, *model.Playlist:
return db.Playlist(ctx).(model.ResourceRepository)
case model.Radio, *model.Radio:
return db.Radio(ctx).(model.ResourceRepository)
case model.Share, *model.Share:
return db.Share(ctx).(model.ResourceRepository)
case model.Genre, *model.Genre:
return db.Genre(ctx).(model.ResourceRepository)
case model.Tag, *model.Tag:
return db.Tag(ctx).(model.ResourceRepository)
case model.Transcoding, *model.Transcoding:
return db.Transcoding(ctx).(model.ResourceRepository)
case model.Player, *model.Player:
return db.Player(ctx).(model.ResourceRepository)
default:
return struct{ model.ResourceRepository }{}
}
}
func (db *MockDataStore) GC(context.Context) error {
return nil

View File

@@ -7,6 +7,7 @@ import (
"slices"
"time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils/slice"
@@ -76,9 +77,14 @@ func (m *MockMediaFileRepo) GetAll(...model.QueryOptions) (model.MediaFiles, err
return nil, errors.New("error")
}
values := slices.Collect(maps.Values(m.Data))
return slice.Map(values, func(p *model.MediaFile) model.MediaFile {
result := slice.Map(values, func(p *model.MediaFile) model.MediaFile {
return *p
}), nil
})
// Sort by ID to ensure deterministic ordering for tests
slices.SortFunc(result, func(a, b model.MediaFile) int {
return cmp.Compare(a.ID, b.ID)
})
return result, nil
}
func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error {
@@ -196,4 +202,30 @@ func (m *MockMediaFileRepo) DeleteAllMissing() (int64, error) {
return count, nil
}
// ResourceRepository methods
func (m *MockMediaFileRepo) Count(...rest.QueryOptions) (int64, error) {
return m.CountAll()
}
func (m *MockMediaFileRepo) Read(id string) (interface{}, error) {
mf, err := m.Get(id)
if errors.Is(err, model.ErrNotFound) {
return nil, rest.ErrNotFound
}
return mf, err
}
func (m *MockMediaFileRepo) ReadAll(...rest.QueryOptions) (interface{}, error) {
return m.GetAll()
}
func (m *MockMediaFileRepo) EntityName() string {
return "mediafile"
}
func (m *MockMediaFileRepo) NewInstance() interface{} {
return &model.MediaFile{}
}
var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil)
var _ model.ResourceRepository = (*MockMediaFileRepo)(nil)

View File

@@ -137,6 +137,9 @@ const Admin = (props) => {
<Resource name="playlistTrack" />,
<Resource name="keepalive" />,
<Resource name="insights" />,
permissions === 'admin' && config.devUIShowConfig ? (
<Resource name="config" />
) : null,
<Player />,
]}
</RAAdmin>

View File

@@ -38,16 +38,14 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
const subtitle = song.tags?.['subtitle']
const title = song.title + (subtitle ? ` (${subtitle})` : '')
return (
<Link
to={
audioInfo.isRadio
const linkTo = audioInfo.isRadio
? `/radio/${audioInfo.trackId}/show`
: song.playlistId
? `/playlist/${song.playlistId}/show`
: `/album/${song.albumId}/show`
}
className={className}
ref={dragSongRef}
>
return (
<Link to={linkTo} className={className} ref={dragSongRef}>
<span>
<span className={clsx(classes.songTitle, 'songTitle')}>{title}</span>
{isDesktop && (

View File

@@ -0,0 +1,57 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import AudioTitle from './AudioTitle'
vi.mock('@material-ui/core', async () => {
const actual = await import('@material-ui/core')
return {
...actual,
useMediaQuery: vi.fn(),
}
})
vi.mock('react-router-dom', () => ({
Link: ({ to, children, ...props }) => (
<a href={to} {...props}>
{children}
</a>
),
}))
vi.mock('react-dnd', () => ({
useDrag: vi.fn(() => [null, () => {}]),
}))
describe('<AudioTitle />', () => {
const baseSong = {
id: 'song-1',
albumId: 'album-1',
playlistId: 'playlist-1',
title: 'Test Song',
artist: 'Artist',
album: 'Album',
year: '2020',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('links to playlist when playlistId is provided', () => {
const audioInfo = { trackId: 'track-1', song: baseSong }
render(<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />)
const link = screen.getByRole('link')
expect(link.getAttribute('href')).toBe('/playlist/playlist-1/show')
})
it('falls back to album link when no playlistId', () => {
const audioInfo = {
trackId: 'track-1',
song: { ...baseSong, playlistId: undefined },
}
render(<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />)
const link = screen.getByRole('link')
expect(link.getAttribute('href')).toBe('/album/album-1/show')
})
})

View File

@@ -57,7 +57,7 @@ const useStyles = makeStyles((theme) => ({
const PlayerToolbar = ({ id, isRadio }) => {
const dispatch = useDispatch()
const { data, loading } = useGetOne('song', id, { enabled: !!id })
const { data, loading } = useGetOne('song', id, { enabled: !!id && !isRadio })
const [toggleLove, toggling] = useToggleLove('song', data)
const isDesktop = useMediaQuery('(min-width:810px)')
const classes = useStyles()

View File

@@ -38,15 +38,16 @@ export const RatingField = ({
const handleRating = useCallback(
(e, val) => {
rate(val ?? 0, e.target.name)
const targetId = record.mediaFileId || record.id
rate(val ?? 0, targetId)
},
[rate],
[rate, record.mediaFileId, record.id],
)
return (
<span onClick={(e) => stopPropagation(e)}>
<Rating
name={record.id}
name={record.mediaFileId || record.id}
className={clsx(
className,
classes.rating,

View File

@@ -1,7 +1,12 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { useDispatch } from 'react-redux'
import { useNotify, usePermissions, useTranslate } from 'react-admin'
import {
useNotify,
usePermissions,
useTranslate,
useDataProvider,
} from 'react-admin'
import { IconButton, Menu, MenuItem } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import MoreVertIcon from '@material-ui/icons/MoreVert'
@@ -20,7 +25,7 @@ import {
import { LoveButton } from './LoveButton'
import config from '../config'
import { formatBytes } from '../utils'
import { httpClient } from '../dataProvider'
import { useRedirect } from 'react-admin'
const useStyles = makeStyles({
noWrap: {
@@ -57,8 +62,13 @@ export const SongContextMenu = ({
const dispatch = useDispatch()
const translate = useTranslate()
const notify = useNotify()
const dataProvider = useDataProvider()
const [anchorEl, setAnchorEl] = useState(null)
const [playlistAnchorEl, setPlaylistAnchorEl] = useState(null)
const [playlists, setPlaylists] = useState([])
const [playlistsLoaded, setPlaylistsLoaded] = useState(false)
const { permissions } = usePermissions()
const redirect = useRedirect()
const options = {
playNow: {
@@ -87,6 +97,15 @@ export const SongContextMenu = ({
}),
),
},
showInPlaylist: {
enabled: true,
label:
translate('resources.song.actions.showInPlaylist') +
(playlists.length > 0 ? ' ►' : ''),
action: (record, e) => {
setPlaylistAnchorEl(e.currentTarget)
},
},
share: {
enabled: config.enableSharing,
label: translate('ra.action.share'),
@@ -113,8 +132,8 @@ export const SongContextMenu = ({
if (permissions === 'admin' && !record.missing) {
try {
let id = record.mediaFileId ?? record.id
const data = await httpClient(`/api/inspect?id=${id}`)
fullRecord = { ...record, rawTags: data.json.rawTags }
const data = await dataProvider.inspect(id)
fullRecord = { ...record, rawTags: data.data.rawTags }
} catch (error) {
notify(
translate('ra.notification.http_error') + ': ' + error.message,
@@ -134,6 +153,21 @@ export const SongContextMenu = ({
const handleClick = (e) => {
setAnchorEl(e.currentTarget)
if (!playlistsLoaded) {
const id = record.mediaFileId || record.id
dataProvider
.getPlaylists(id)
.then((res) => {
setPlaylists(res.data)
setPlaylistsLoaded(true)
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error('Failed to fetch playlists:', error)
setPlaylists([])
setPlaylistsLoaded(true)
})
}
e.stopPropagation()
}
@@ -144,12 +178,39 @@ export const SongContextMenu = ({
const handleItemClick = (e) => {
e.preventDefault()
setAnchorEl(null)
const key = e.target.getAttribute('value')
options[key].action(record)
const action = options[key].action
if (key === 'showInPlaylist') {
// For showInPlaylist, we keep the main menu open and show submenu
action(record, e)
} else {
// For other actions, close the main menu
setAnchorEl(null)
action(record)
}
e.stopPropagation()
}
const handlePlaylistClose = (e) => {
setPlaylistAnchorEl(null)
if (e) {
e.stopPropagation()
}
}
const handleMainMenuClose = (e) => {
setAnchorEl(null)
setPlaylistAnchorEl(null) // Close both menus
e.stopPropagation()
}
const handlePlaylistClick = (id, e) => {
e.stopPropagation()
redirect(`/playlist/${id}/show`)
handlePlaylistClose()
}
const open = Boolean(anchorEl)
if (!record) {
@@ -170,17 +231,41 @@ export const SongContextMenu = ({
id={'menu' + record.id}
anchorEl={anchorEl}
open={open}
onClose={handleClose}
onClose={handleMainMenuClose}
>
{Object.keys(options).map(
(key) =>
options[key].enabled && (
<MenuItem value={key} key={key} onClick={handleItemClick}>
<MenuItem
value={key}
key={key}
onClick={handleItemClick}
disabled={key === 'showInPlaylist' && !playlists.length}
>
{options[key].label}
</MenuItem>
),
)}
</Menu>
<Menu
anchorEl={playlistAnchorEl}
open={Boolean(playlistAnchorEl)}
onClose={handlePlaylistClose}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
{playlists.map((p) => (
<MenuItem key={p.id} onClick={(e) => handlePlaylistClick(p.id, e)}>
{p.name}
</MenuItem>
))}
</Menu>
</span>
)
}

View File

@@ -0,0 +1,82 @@
import React from 'react'
import { render, fireEvent, screen, waitFor } from '@testing-library/react'
import { TestContext } from 'ra-test'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { SongContextMenu } from './SongContextMenu'
vi.mock('../dataProvider', () => ({
httpClient: vi.fn(),
}))
vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() }))
vi.mock('react-admin', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
useRedirect: () => (url) => {
window.location.hash = `#${url}`
},
useDataProvider: () => ({
getPlaylists: vi.fn().mockResolvedValue({
data: [{ id: 'pl1', name: 'Pl 1' }],
}),
inspect: vi.fn().mockResolvedValue({
data: { rawTags: {} },
}),
}),
}
})
describe('SongContextMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
window.location.hash = ''
})
it('navigates to playlist when selected', async () => {
render(
<TestContext>
<SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" />
</TestContext>,
)
fireEvent.click(screen.getAllByRole('button')[1])
await waitFor(() =>
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
)
fireEvent.click(
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
)
await waitFor(() => screen.getByText('Pl 1'))
fireEvent.click(screen.getByText('Pl 1'))
expect(window.location.hash).toBe('#/playlist/pl1/show')
})
it('stops event propagation when playlist submenu is closed', async () => {
const mockOnClick = vi.fn()
render(
<TestContext>
<div onClick={mockOnClick}>
<SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" />
</div>
</TestContext>,
)
// Open main menu
fireEvent.click(screen.getAllByRole('button')[1])
await waitFor(() =>
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
)
// Open playlist submenu
fireEvent.click(
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
)
await waitFor(() => screen.getByText('Pl 1'))
// Click outside the playlist submenu (should close it without triggering parent click)
fireEvent.click(document.body)
expect(mockOnClick).not.toHaveBeenCalled()
})
})

View File

@@ -138,7 +138,7 @@ export const SongInfo = (props) => {
</Tabs>
)}
<div
hidden={tab == 1}
hidden={tab === 1}
id="mapped-tags-body"
aria-labelledby={record.rawTags ? 'mapped-tags-tab' : undefined}
>

View File

@@ -17,18 +17,42 @@ export const useRating = (resource, record) => {
}, [])
const refreshRating = useCallback(() => {
dataProvider
.getOne(resource, { id: record.id })
.then(() => {
if (mountedRef.current) {
setLoading(false)
}
})
// For playlist tracks, refresh both resources to keep data in sync
if (record.mediaFileId) {
// This is a playlist track - refresh both the playlist track and the song
const promises = [
dataProvider.getOne('song', { id: record.mediaFileId }),
dataProvider.getOne('playlistTrack', {
id: record.id,
filter: { playlist_id: record.playlistId },
}),
]
Promise.all(promises)
.catch((e) => {
// eslint-disable-next-line no-console
console.log('Error encountered: ' + e)
})
}, [dataProvider, record, resource])
.finally(() => {
if (mountedRef.current) {
setLoading(false)
}
})
} else {
// Regular song or other resource
dataProvider
.getOne(resource, { id: record.id })
.catch((e) => {
// eslint-disable-next-line no-console
console.log('Error encountered: ' + e)
})
.finally(() => {
if (mountedRef.current) {
setLoading(false)
}
})
}
}, [dataProvider, record.id, record.mediaFileId, record.playlistId, resource])
const rate = (val, id) => {
setLoading(true)

View File

@@ -0,0 +1,165 @@
import { renderHook, act } from '@testing-library/react-hooks'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { useRating } from './useRating'
import subsonic from '../subsonic'
import { useDataProvider } from 'react-admin'
vi.mock('../subsonic', () => ({
default: {
setRating: vi.fn(() => Promise.resolve()),
},
}))
vi.mock('react-admin', async () => {
const actual = await vi.importActual('react-admin')
return {
...actual,
useDataProvider: vi.fn(),
useNotify: vi.fn(() => vi.fn()),
}
})
describe('useRating', () => {
let getOne
beforeEach(() => {
getOne = vi.fn(() => Promise.resolve())
useDataProvider.mockReturnValue({ getOne })
vi.clearAllMocks()
})
it('returns rating value from record', () => {
const record = { id: 'sg-1', rating: 3 }
const { result } = renderHook(() => useRating('song', record))
const [rate, rating, loading] = result.current
expect(rating).toBe(3)
expect(loading).toBe(false)
expect(typeof rate).toBe('function')
})
it('sets rating using targetId and calls setRating API', async () => {
const record = { id: 'sg-1', rating: 0 }
const { result } = renderHook(() => useRating('song', record))
await act(async () => {
await result.current[0](4, 'sg-1')
})
expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 4)
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
it('handles zero rating (unrate)', async () => {
const record = { id: 'sg-1', rating: 5 }
const { result } = renderHook(() => useRating('song', record))
await act(async () => {
await result.current[0](0, 'sg-1')
})
expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 0)
})
describe('playlist track scenarios', () => {
it('refreshes both playlist track and song for playlist tracks', async () => {
const record = {
id: 'pt-1',
mediaFileId: 'sg-1',
playlistId: 'pl-1',
rating: 2,
}
const { result } = renderHook(() => useRating('playlistTrack', record))
await act(async () => {
await result.current[0](5, 'sg-1')
})
// Should rate using the media file ID
expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5)
// Should refresh both the playlist track and the song
expect(getOne).toHaveBeenCalledTimes(2)
expect(getOne).toHaveBeenCalledWith('playlistTrack', {
id: 'pt-1',
filter: { playlist_id: 'pl-1' },
})
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
it('includes playlist_id filter when refreshing playlist tracks', async () => {
const record = {
id: 'pt-5',
mediaFileId: 'sg-10',
playlistId: 'pl-123',
rating: 1,
}
const { result } = renderHook(() => useRating('playlistTrack', record))
await act(async () => {
await result.current[0](3, 'sg-10')
})
// Should rate using the media file ID
expect(subsonic.setRating).toHaveBeenCalledWith('sg-10', 3)
// Should refresh playlist track with correct playlist_id filter
expect(getOne).toHaveBeenCalledWith('playlistTrack', {
id: 'pt-5',
filter: { playlist_id: 'pl-123' },
})
// Should also refresh the underlying song
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' })
})
it('only refreshes original resource when no mediaFileId present', async () => {
const record = { id: 'sg-1', rating: 4 }
const { result } = renderHook(() => useRating('song', record))
await act(async () => {
await result.current[0](2, 'sg-1')
})
// Should only refresh the original resource (song)
expect(getOne).toHaveBeenCalledTimes(1)
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
it('does not include playlist_id filter for non-playlist resources', async () => {
const record = { id: 'sg-1', rating: 0 }
const { result } = renderHook(() => useRating('song', record))
await act(async () => {
await result.current[0](5, 'sg-1')
})
// Should refresh without any filter
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
})
describe('component integration scenarios', () => {
it('handles mediaFileId fallback correctly for playlist tracks', async () => {
const record = {
id: 'pt-1',
mediaFileId: 'sg-1',
playlistId: 'pl-1',
rating: 0,
}
const { result } = renderHook(() => useRating('playlistTrack', record))
// Simulate RatingField component behavior: uses mediaFileId || record.id
const targetId = record.mediaFileId || record.id
await act(async () => {
await result.current[0](4, targetId)
})
expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 4)
})
it('handles regular song rating without mediaFileId', async () => {
const record = { id: 'sg-1', rating: 2 }
const { result } = renderHook(() => useRating('song', record))
// Simulate RatingField component behavior: uses mediaFileId || record.id
const targetId = record.mediaFileId || record.id
await act(async () => {
await result.current[0](5, targetId)
})
expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5)
expect(getOne).toHaveBeenCalledTimes(1)
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
})
})

View File

@@ -17,18 +17,38 @@ export const useToggleLove = (resource, record = {}) => {
const dataProvider = useDataProvider()
const refreshRecord = useCallback(() => {
dataProvider.getOne(resource, { id: record.id }).then(() => {
const promises = []
// Always refresh the original resource
const params = { id: record.id }
if (record.playlistId) {
params.filter = { playlist_id: record.playlistId }
}
promises.push(dataProvider.getOne(resource, params))
// If we have a mediaFileId, also refresh the song
if (record.mediaFileId) {
promises.push(dataProvider.getOne('song', { id: record.mediaFileId }))
}
Promise.all(promises)
.catch((e) => {
// eslint-disable-next-line no-console
console.log('Error encountered: ' + e)
})
.finally(() => {
if (mountedRef.current) {
setLoading(false)
}
})
}, [dataProvider, record.id, resource])
}, [dataProvider, record.mediaFileId, record.id, record.playlistId, resource])
const toggleLove = () => {
const toggle = record.starred ? subsonic.unstar : subsonic.star
const id = record.mediaFileId || record.id
setLoading(true)
toggle(record.id)
toggle(id)
.then(refreshRecord)
.catch((e) => {
// eslint-disable-next-line no-console

View File

@@ -0,0 +1,136 @@
import { renderHook, act } from '@testing-library/react-hooks'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { useToggleLove } from './useToggleLove'
import subsonic from '../subsonic'
import { useDataProvider } from 'react-admin'
vi.mock('../subsonic', () => ({
default: {
star: vi.fn(() => Promise.resolve()),
unstar: vi.fn(() => Promise.resolve()),
},
}))
vi.mock('react-admin', async () => {
const actual = await vi.importActual('react-admin')
return {
...actual,
useDataProvider: vi.fn(),
useNotify: vi.fn(() => vi.fn()),
}
})
describe('useToggleLove', () => {
let getOne
beforeEach(() => {
getOne = vi.fn(() => Promise.resolve())
useDataProvider.mockReturnValue({ getOne })
vi.clearAllMocks()
})
it('uses mediaFileId when present', async () => {
const record = { id: 'pt-1', mediaFileId: 'sg-1', starred: false }
const { result } = renderHook(() => useToggleLove('song', record))
await act(async () => {
await result.current[0]()
})
expect(subsonic.star).toHaveBeenCalledWith('sg-1')
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
it('falls back to id when mediaFileId not present', async () => {
const record = { id: 'sg-1', starred: false }
const { result } = renderHook(() => useToggleLove('song', record))
await act(async () => {
await result.current[0]()
})
expect(subsonic.star).toHaveBeenCalledWith('sg-1')
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
it('calls unstar when record is already loved', async () => {
const record = { id: 'sg-1', starred: true }
const { result } = renderHook(() => useToggleLove('song', record))
await act(async () => {
await result.current[0]()
})
expect(subsonic.unstar).toHaveBeenCalledWith('sg-1')
})
describe('playlist track scenarios', () => {
it('refreshes both playlist track and song for playlist tracks', async () => {
const record = {
id: 'pt-1',
mediaFileId: 'sg-1',
playlistId: 'pl-1',
starred: false,
}
const { result } = renderHook(() =>
useToggleLove('playlistTrack', record),
)
await act(async () => {
await result.current[0]()
})
// Should star using the media file ID
expect(subsonic.star).toHaveBeenCalledWith('sg-1')
// Should refresh both the playlist track and the song
expect(getOne).toHaveBeenCalledTimes(2)
expect(getOne).toHaveBeenCalledWith('playlistTrack', {
id: 'pt-1',
filter: { playlist_id: 'pl-1' },
})
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
it('includes playlist_id filter when refreshing playlist tracks', async () => {
const record = {
id: 'pt-5',
mediaFileId: 'sg-10',
playlistId: 'pl-123',
starred: true,
}
const { result } = renderHook(() =>
useToggleLove('playlistTrack', record),
)
await act(async () => {
await result.current[0]()
})
// Should unstar using the media file ID
expect(subsonic.unstar).toHaveBeenCalledWith('sg-10')
// Should refresh playlist track with correct playlist_id filter
expect(getOne).toHaveBeenCalledWith('playlistTrack', {
id: 'pt-5',
filter: { playlist_id: 'pl-123' },
})
// Should also refresh the underlying song
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' })
})
it('only refreshes original resource when no mediaFileId present', async () => {
const record = { id: 'sg-1', starred: false }
const { result } = renderHook(() => useToggleLove('song', record))
await act(async () => {
await result.current[0]()
})
// Should only refresh the original resource (song)
expect(getOne).toHaveBeenCalledTimes(1)
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
it('does not include playlist_id filter for non-playlist resources', async () => {
const record = { id: 'sg-1', starred: false }
const { result } = renderHook(() => useToggleLove('song', record))
await act(async () => {
await result.current[0]()
})
// Should refresh without any filter
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
})
})

View File

@@ -30,6 +30,7 @@ const defaultConfig = {
enableExternalServices: true,
enableCoverAnimation: true,
devShowArtistPage: true,
devUIShowConfig: true,
enableReplayGain: true,
defaultDownsamplingFormat: 'opus',
publicBaseUrl: '/share',

View File

@@ -90,6 +90,16 @@ const wrapperDataProvider = {
body: JSON.stringify(data),
}).then(({ json }) => ({ data: json }))
},
getPlaylists: (songId) => {
return httpClient(`${REST_URL}/song/${songId}/playlists`).then(
({ json }) => ({ data: json }),
)
},
inspect: (songId) => {
return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({
data: json,
}))
},
}
export default wrapperDataProvider

View File

@@ -10,14 +10,63 @@ import TableRow from '@material-ui/core/TableRow'
import TableCell from '@material-ui/core/TableCell'
import Paper from '@material-ui/core/Paper'
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
import FileCopyIcon from '@material-ui/icons/FileCopy'
import Button from '@material-ui/core/Button'
import { humanize, underscore } from 'inflection'
import { useGetOne, usePermissions, useTranslate } from 'react-admin'
import { useGetOne, usePermissions, useTranslate, useNotify } from 'react-admin'
import { Tabs, Tab } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import config from '../config'
import { DialogTitle } from './DialogTitle'
import { DialogContent } from './DialogContent'
import { INSIGHTS_DOC_URL } from '../consts.js'
import subsonic from '../subsonic/index.js'
import { Typography } from '@material-ui/core'
import TableHead from '@material-ui/core/TableHead'
import { configToToml, separateAndSortConfigs } from './aboutUtils'
const useStyles = makeStyles((theme) => ({
configNameColumn: {
maxWidth: '200px',
width: '200px',
wordWrap: 'break-word',
overflowWrap: 'break-word',
},
envVarColumn: {
maxWidth: '200px',
width: '200px',
fontFamily: 'monospace',
wordWrap: 'break-word',
overflowWrap: 'break-word',
},
configFileValue: {
maxWidth: '300px',
width: '300px',
fontFamily: 'monospace',
wordBreak: 'break-all',
},
copyButton: {
marginBottom: theme.spacing(2),
marginTop: theme.spacing(1),
},
devSectionHeader: {
'& td': {
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
borderTop: `2px solid ${theme.palette.divider}`,
borderBottom: `1px solid ${theme.palette.divider}`,
textAlign: 'left',
fontWeight: 600,
},
},
configContainer: {
paddingTop: theme.spacing(1),
},
tableContainer: {
maxHeight: '60vh',
overflow: 'auto',
},
}))
const links = {
homepage: 'navidrome.org',
@@ -54,7 +103,6 @@ const LinkToVersion = ({ version }) => {
const ShowVersion = ({ uiVersion, serverVersion }) => {
const translate = useTranslate()
const showRefresh = uiVersion !== serverVersion
return (
@@ -73,12 +121,16 @@ const ShowVersion = ({ uiVersion, serverVersion }) => {
UI {translate('menu.version')}:
</TableCell>
<TableCell align="left">
<div>
<LinkToVersion version={uiVersion} />
</div>
<div>
<Link onClick={() => window.location.reload()}>
<Typography variant={'caption'}>
{' ' + translate('ra.notification.new_version')}
{translate('ra.notification.new_version')}
</Typography>
</Link>
</div>
</TableCell>
</TableRow>
)}
@@ -86,29 +138,16 @@ const ShowVersion = ({ uiVersion, serverVersion }) => {
)
}
const AboutDialog = ({ open, onClose }) => {
const AboutTabContent = ({
uiVersion,
serverVersion,
insightsData,
loading,
permissions,
}) => {
const translate = useTranslate()
const { permissions } = usePermissions()
const { data, loading } = useGetOne('insights', 'insights_status')
const [serverVersion, setServerVersion] = useState('')
const uiVersion = config.version
useEffect(() => {
subsonic
.ping()
.then((resp) => resp.json['subsonic-response'])
.then((data) => {
if (data.status === 'ok') {
setServerVersion(data.serverVersion)
}
})
.catch((e) => {
// eslint-disable-next-line no-console
console.error('error pinging server', e)
})
}, [setServerVersion])
const lastRun = !loading && data?.lastRun
const lastRun = !loading && insightsData?.lastRun
let insightsStatus = 'N/A'
if (lastRun === 'disabled') {
insightsStatus = translate('about.links.insights.disabled')
@@ -119,18 +158,9 @@ const AboutDialog = ({ open, onClose }) => {
}
return (
<Dialog onClose={onClose} aria-labelledby="about-dialog-title" open={open}>
<DialogTitle id="about-dialog-title" onClose={onClose}>
Navidrome Music Server
</DialogTitle>
<DialogContent dividers>
<TableContainer component={Paper}>
<Table aria-label={translate('menu.about')} size="small">
<TableBody>
<ShowVersion
uiVersion={uiVersion}
serverVersion={serverVersion}
/>
<ShowVersion uiVersion={uiVersion} serverVersion={serverVersion} />
{Object.keys(links).map((key) => {
return (
<TableRow key={key}>
@@ -186,7 +216,249 @@ const AboutDialog = ({ open, onClose }) => {
</TableRow>
</TableBody>
</Table>
)
}
const ConfigTabContent = ({ configData }) => {
const classes = useStyles()
const translate = useTranslate()
const notify = useNotify()
if (!configData || !configData.config) {
return null
}
// Use the shared separation and sorting logic
const { regularConfigs, devConfigs } = separateAndSortConfigs(
configData.config,
)
const handleCopyToml = async () => {
try {
const tomlContent = configToToml(configData, translate)
await navigator.clipboard.writeText(tomlContent)
notify(translate('about.config.exportSuccess'), 'info')
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to copy TOML:', err)
notify(translate('about.config.exportFailed'), 'error')
}
}
return (
<div className={classes.configContainer}>
<Button
variant="outlined"
startIcon={<FileCopyIcon />}
onClick={handleCopyToml}
className={classes.copyButton}
disabled={!configData}
size="small"
>
{translate('about.config.exportToml')}
</Button>
<TableContainer className={classes.tableContainer}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell
align="left"
component="th"
scope="col"
className={classes.configNameColumn}
>
{translate('about.config.configName')}
</TableCell>
<TableCell align="left" component="th" scope="col">
{translate('about.config.environmentVariable')}
</TableCell>
<TableCell align="left" component="th" scope="col">
{translate('about.config.currentValue')}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{configData?.configFile && (
<TableRow>
<TableCell
align="left"
component="th"
scope="row"
className={classes.configNameColumn}
>
{translate('about.config.configurationFile')}
</TableCell>
<TableCell align="left" className={classes.envVarColumn}>
ND_CONFIGFILE
</TableCell>
<TableCell align="left" className={classes.configFileValue}>
{configData.configFile}
</TableCell>
</TableRow>
)}
{regularConfigs.map(({ key, envVar, value }) => (
<TableRow key={key}>
<TableCell
align="left"
component="th"
scope="row"
className={classes.configNameColumn}
>
{key}
</TableCell>
<TableCell align="left" className={classes.envVarColumn}>
{envVar}
</TableCell>
<TableCell align="left">{String(value)}</TableCell>
</TableRow>
))}
{devConfigs.length > 0 && (
<TableRow className={classes.devSectionHeader}>
<TableCell colSpan={3}>
<Typography
variant="subtitle1"
component="div"
style={{ fontWeight: 600 }}
>
🚧 {translate('about.config.devFlagsHeader')}
</Typography>
</TableCell>
</TableRow>
)}
{devConfigs.map(({ key, envVar, value }) => (
<TableRow key={key}>
<TableCell
align="left"
component="th"
scope="row"
className={classes.configNameColumn}
>
{key}
</TableCell>
<TableCell align="left" className={classes.envVarColumn}>
{envVar}
</TableCell>
<TableCell align="left">{String(value)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
)
}
const TabContent = ({
tab,
setTab,
showConfigTab,
uiVersion,
serverVersion,
insightsData,
loading,
permissions,
configData,
}) => {
const translate = useTranslate()
return (
<TableContainer component={Paper}>
{showConfigTab && (
<Tabs value={tab} onChange={(_, value) => setTab(value)}>
<Tab
label={translate('about.tabs.about')}
id="about-tab"
aria-controls="about-panel"
/>
<Tab
label={translate('about.tabs.config')}
id="config-tab"
aria-controls="config-panel"
/>
</Tabs>
)}
<div
id="about-panel"
role="tabpanel"
aria-labelledby="about-tab"
hidden={showConfigTab && tab === 1}
>
<AboutTabContent
uiVersion={uiVersion}
serverVersion={serverVersion}
insightsData={insightsData}
loading={loading}
permissions={permissions}
/>
</div>
{showConfigTab && (
<div
id="config-panel"
role="tabpanel"
aria-labelledby="config-tab"
hidden={tab === 0}
>
<ConfigTabContent configData={configData} />
</div>
)}
</TableContainer>
)
}
const AboutDialog = ({ open, onClose }) => {
const { permissions } = usePermissions()
const { data: insightsData, loading } = useGetOne(
'insights',
'insights_status',
)
const [serverVersion, setServerVersion] = useState('')
const showConfigTab = permissions === 'admin' && config.devUIShowConfig
const [tab, setTab] = useState(0)
const { data: configData } = useGetOne('config', 'config', {
enabled: showConfigTab,
})
const expanded = showConfigTab && tab === 1
const uiVersion = config.version
useEffect(() => {
subsonic
.ping()
.then((resp) => resp.json['subsonic-response'])
.then((data) => {
if (data.status === 'ok') {
setServerVersion(data.serverVersion)
}
})
.catch((e) => {
// eslint-disable-next-line no-console
console.error('error pinging server', e)
})
}, [setServerVersion])
return (
<Dialog
onClose={onClose}
aria-labelledby="about-dialog-title"
open={open}
fullWidth={true}
maxWidth={expanded ? 'lg' : 'sm'}
style={{ transition: 'max-width 300ms ease' }}
>
<DialogTitle id="about-dialog-title" onClose={onClose}>
Navidrome Music Server
</DialogTitle>
<DialogContent dividers>
<TabContent
tab={tab}
setTab={setTab}
showConfigTab={showConfigTab}
uiVersion={uiVersion}
serverVersion={serverVersion}
insightsData={insightsData}
loading={loading}
permissions={permissions}
configData={configData}
/>
</DialogContent>
</Dialog>
)

View File

@@ -12,6 +12,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
makeStyles,
} from '@material-ui/core'
import {
closeAddToPlaylist,
@@ -23,7 +24,21 @@ import DuplicateSongDialog from './DuplicateSongDialog'
import { httpClient } from '../dataProvider'
import { REST_URL } from '../consts'
const useStyles = makeStyles({
dialogPaper: {
height: '26em',
maxHeight: '26em',
},
dialogContent: {
height: '17.5em',
overflowY: 'auto',
paddingTop: '0.5em',
paddingBottom: '0.5em',
},
})
export const AddToPlaylistDialog = () => {
const classes = useStyles()
const { open, selectedIds, onSuccess, duplicateSong, duplicateIds } =
useSelector((state) => state.addToPlaylistDialog)
const dispatch = useDispatch()
@@ -145,11 +160,14 @@ export const AddToPlaylistDialog = () => {
aria-labelledby="form-dialog-new-playlist"
fullWidth={true}
maxWidth={'sm'}
classes={{
paper: classes.dialogPaper,
}}
>
<DialogTitle id="form-dialog-new-playlist">
{translate('resources.playlist.actions.selectPlaylist')}
</DialogTitle>
<DialogContent>
<DialogContent className={classes.dialogContent}>
<SelectPlaylistInput onChange={handleChange} />
</DialogContent>
<DialogActions>

View File

@@ -88,12 +88,18 @@ describe('AddToPlaylistDialog', () => {
createTestUtils(mockDataProvider)
// Filter to see sample playlists
let textBox = screen.getByRole('textbox')
fireEvent.change(textBox, { target: { value: 'sample' } })
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
fireEvent.keyDown(textBox, { key: 'Enter' })
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
fireEvent.keyDown(textBox, { key: 'Enter' })
// Click on first playlist
const firstPlaylist = screen.getByText('sample playlist 1')
fireEvent.click(firstPlaylist)
// Click on second playlist
const secondPlaylist = screen.getByText('sample playlist 2')
fireEvent.click(secondPlaylist)
await waitFor(() => {
expect(screen.getByTestId('playlist-add')).not.toBeDisabled()
})
@@ -133,12 +139,11 @@ describe('AddToPlaylistDialog', () => {
createTestUtils(mockDataProvider)
// Type a new playlist name and press Enter to create it
let textBox = screen.getByRole('textbox')
fireEvent.change(textBox, { target: { value: 'sample' } })
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
fireEvent.keyDown(textBox, { key: 'Enter' })
await waitFor(() => {
expect(screen.getByTestId('playlist-add')).not.toBeDisabled()
})
@@ -171,14 +176,15 @@ describe('AddToPlaylistDialog', () => {
createTestUtils(mockDataProvider)
// Create first playlist
let textBox = screen.getByRole('textbox')
fireEvent.change(textBox, { target: { value: 'sample' } })
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
fireEvent.keyDown(textBox, { key: 'Enter' })
// Create second playlist
fireEvent.change(textBox, { target: { value: 'new playlist' } })
fireEvent.keyDown(textBox, { key: 'Enter' })
await waitFor(() => {
expect(screen.getByTestId('playlist-add')).not.toBeDisabled()
})

View File

@@ -1,26 +1,265 @@
import React from 'react'
import React, { useState } from 'react'
import TextField from '@material-ui/core/TextField'
import Checkbox from '@material-ui/core/Checkbox'
import CheckBoxIcon from '@material-ui/icons/CheckBox'
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
import Autocomplete, {
createFilterOptions,
} from '@material-ui/lab/Autocomplete'
import {
List,
ListItem,
ListItemIcon,
ListItemText,
Typography,
Box,
InputAdornment,
IconButton,
} from '@material-ui/core'
import AddIcon from '@material-ui/icons/Add'
import { useGetList, useTranslate } from 'react-admin'
import PropTypes from 'prop-types'
import { isWritable } from '../common'
import { makeStyles } from '@material-ui/core'
const filter = createFilterOptions()
const useStyles = makeStyles((theme) => ({
root: {
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
},
searchField: {
marginBottom: theme.spacing(2),
width: '100%',
flexShrink: 0,
},
playlistList: {
flex: 1,
minHeight: 0,
overflow: 'auto',
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.paper,
},
listItem: {
paddingTop: 0,
paddingBottom: 0,
},
createIcon: {
fontSize: '1.25rem',
margin: '9px',
},
selectedPlaylistsContainer: {
marginTop: theme.spacing(2),
flexShrink: 0,
maxHeight: '30%',
overflow: 'auto',
},
selectedPlaylist: {
display: 'inline-flex',
alignItems: 'center',
margin: theme.spacing(0.5),
padding: theme.spacing(0.5, 1),
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
borderRadius: theme.shape.borderRadius,
fontSize: '0.875rem',
},
removeButton: {
marginLeft: theme.spacing(0.5),
padding: 2,
color: 'inherit',
},
emptyMessage: {
padding: theme.spacing(2),
textAlign: 'center',
color: theme.palette.text.secondary,
},
}))
const useStyles = makeStyles({
root: { width: '100%' },
checkbox: { marginRight: 8 },
})
const PlaylistSearchField = ({
searchText,
onSearchChange,
onCreateNew,
onKeyDown,
canCreateNew,
}) => {
const classes = useStyles()
const translate = useTranslate()
return (
<TextField
autoFocus
variant="outlined"
className={classes.searchField}
label={translate('resources.playlist.fields.name')}
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={translate('resources.playlist.actions.searchOrCreate')}
InputProps={{
endAdornment: canCreateNew && (
<InputAdornment position="end">
<IconButton
onClick={onCreateNew}
size="small"
title={translate('resources.playlist.actions.addNewPlaylist', {
name: searchText,
})}
>
<AddIcon />
</IconButton>
</InputAdornment>
),
}}
/>
)
}
const EmptyPlaylistMessage = ({ searchText, canCreateNew }) => {
const classes = useStyles()
const translate = useTranslate()
return (
<div className={classes.emptyMessage}>
<Typography variant="body2">
{searchText
? translate('resources.playlist.message.noPlaylistsFound')
: translate('resources.playlist.message.noPlaylists')}
</Typography>
{canCreateNew && (
<Typography variant="body2" color="primary">
{translate('resources.playlist.actions.pressEnterToCreate')}
</Typography>
)}
</div>
)
}
const PlaylistListItem = ({ playlist, isSelected, onToggle }) => {
const classes = useStyles()
return (
<ListItem
className={classes.listItem}
button
onClick={() => onToggle(playlist)}
dense
>
<ListItemIcon>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
checked={isSelected}
tabIndex={-1}
disableRipple
/>
</ListItemIcon>
<ListItemText primary={playlist.name} />
</ListItem>
)
}
const CreatePlaylistItem = ({ searchText, onCreateNew }) => {
const classes = useStyles()
const translate = useTranslate()
return (
<ListItem className={classes.listItem} button onClick={onCreateNew} dense>
<ListItemIcon>
<AddIcon className={classes.createIcon} />
</ListItemIcon>
<ListItemText
primary={translate('resources.playlist.actions.addNewPlaylist', {
name: searchText,
})}
/>
</ListItem>
)
}
const PlaylistList = ({
filteredOptions,
selectedPlaylists,
onPlaylistToggle,
searchText,
canCreateNew,
onCreateNew,
}) => {
const classes = useStyles()
const isPlaylistSelected = (playlist) =>
selectedPlaylists.some((p) => p.id === playlist.id)
return (
<List className={classes.playlistList}>
{filteredOptions.length === 0 ? (
<EmptyPlaylistMessage
searchText={searchText}
canCreateNew={canCreateNew}
/>
) : (
filteredOptions.map((playlist) => (
<PlaylistListItem
key={playlist.id}
playlist={playlist}
isSelected={isPlaylistSelected(playlist)}
onToggle={onPlaylistToggle}
/>
))
)}
{canCreateNew && filteredOptions.length > 0 && (
<CreatePlaylistItem searchText={searchText} onCreateNew={onCreateNew} />
)}
</List>
)
}
const SelectedPlaylistChip = ({ playlist, onRemove }) => {
const classes = useStyles()
const translate = useTranslate()
return (
<span className={classes.selectedPlaylist}>
{playlist.name}
<IconButton
className={classes.removeButton}
size="small"
onClick={() => onRemove(playlist)}
title={translate('resources.playlist.actions.removeFromSelection')}
>
{translate('resources.playlist.actions.removeSymbol')}
</IconButton>
</span>
)
}
const SelectedPlaylistsDisplay = ({ selectedPlaylists, onRemoveSelected }) => {
const classes = useStyles()
const translate = useTranslate()
if (selectedPlaylists.length === 0) {
return null
}
return (
<Box className={classes.selectedPlaylistsContainer}>
<Box>
{selectedPlaylists.map((playlist, index) => (
<SelectedPlaylistChip
key={playlist.id || `new-${index}`}
playlist={playlist}
onRemove={onRemoveSelected}
/>
))}
</Box>
</Box>
)
}
export const SelectPlaylistInput = ({ onChange }) => {
const classes = useStyles()
const translate = useTranslate()
const [searchText, setSearchText] = useState('')
const [selectedPlaylists, setSelectedPlaylists] = useState([])
const { ids, data } = useGetList(
'playlist',
{ page: 1, perPage: -1 },
@@ -32,92 +271,131 @@ export const SelectPlaylistInput = ({ onChange }) => {
ids &&
ids.map((id) => data[id]).filter((option) => isWritable(option.ownerId))
const handleOnChange = (event, newValue) => {
let newState = []
if (newValue && newValue.length) {
newValue.forEach((playlistObject) => {
if (playlistObject.inputValue) {
newState.push({
name: playlistObject.inputValue,
})
} else if (typeof playlistObject === 'string') {
newState.push({
name: playlistObject,
})
// Filter playlists based on search text
const filteredOptions =
options?.filter((option) =>
option.name.toLowerCase().includes(searchText.toLowerCase()),
) || []
const handlePlaylistToggle = (playlist) => {
const isSelected = selectedPlaylists.some((p) => p.id === playlist.id)
let newSelection
if (isSelected) {
newSelection = selectedPlaylists.filter((p) => p.id !== playlist.id)
} else {
newState.push(playlistObject)
}
})
}
onChange(newState)
newSelection = [...selectedPlaylists, playlist]
}
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />
const checkedIcon = <CheckBoxIcon fontSize="small" />
setSelectedPlaylists(newSelection)
onChange(newSelection)
}
const handleRemoveSelected = (playlistToRemove) => {
const newSelection = selectedPlaylists.filter(
(p) => p.id !== playlistToRemove.id,
)
setSelectedPlaylists(newSelection)
onChange(newSelection)
}
const handleCreateNew = () => {
if (searchText.trim()) {
const newPlaylist = { name: searchText.trim() }
const newSelection = [...selectedPlaylists, newPlaylist]
setSelectedPlaylists(newSelection)
onChange(newSelection)
setSearchText('')
}
}
const handleKeyDown = (e) => {
if (e.key === 'Enter' && searchText.trim()) {
e.preventDefault()
handleCreateNew()
}
}
const canCreateNew = Boolean(
searchText.trim() &&
!filteredOptions.some(
(option) =>
option.name.toLowerCase() === searchText.toLowerCase().trim(),
) &&
!selectedPlaylists.some((p) => p.name === searchText.trim()),
)
return (
<Autocomplete
multiple
disableCloseOnSelect
onChange={handleOnChange}
filterOptions={(options, params) => {
const filtered = filter(options, params)
<div className={classes.root}>
<PlaylistSearchField
searchText={searchText}
onSearchChange={setSearchText}
onCreateNew={handleCreateNew}
onKeyDown={handleKeyDown}
canCreateNew={canCreateNew}
/>
// Suggest the creation of a new value
if (params.inputValue !== '') {
filtered.push({
inputValue: params.inputValue,
name: translate('resources.playlist.actions.addNewPlaylist', {
name: params.inputValue,
}),
})
}
<PlaylistList
filteredOptions={filteredOptions}
selectedPlaylists={selectedPlaylists}
onPlaylistToggle={handlePlaylistToggle}
searchText={searchText}
canCreateNew={canCreateNew}
onCreateNew={handleCreateNew}
/>
return filtered
}}
clearOnBlur
handleHomeEndKeys
openOnFocus
selectOnFocus
id="select-playlist-input"
options={options}
getOptionLabel={(option) => {
// Value selected with enter, right from the input
if (typeof option === 'string') {
return option
}
// Add "xxx" option created dynamically
if (option.inputValue) {
return option.inputValue
}
// Regular option
return option.name
}}
renderOption={(option, { selected }) => (
<React.Fragment>
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
className={classes.checkbox}
checked={selected}
/>
{option.name}
</React.Fragment>
)}
className={classes.root}
freeSolo
renderInput={(params) => (
<TextField
autoFocus
variant={'outlined'}
{...params}
label={translate('resources.playlist.fields.name')}
/>
)}
<SelectedPlaylistsDisplay
selectedPlaylists={selectedPlaylists}
onRemoveSelected={handleRemoveSelected}
/>
</div>
)
}
SelectPlaylistInput.propTypes = {
onChange: PropTypes.func.isRequired,
}
// PropTypes for sub-components
PlaylistSearchField.propTypes = {
searchText: PropTypes.string.isRequired,
onSearchChange: PropTypes.func.isRequired,
onCreateNew: PropTypes.func.isRequired,
onKeyDown: PropTypes.func.isRequired,
canCreateNew: PropTypes.bool.isRequired,
}
EmptyPlaylistMessage.propTypes = {
searchText: PropTypes.string.isRequired,
canCreateNew: PropTypes.bool.isRequired,
}
PlaylistListItem.propTypes = {
playlist: PropTypes.object.isRequired,
isSelected: PropTypes.bool.isRequired,
onToggle: PropTypes.func.isRequired,
}
CreatePlaylistItem.propTypes = {
searchText: PropTypes.string.isRequired,
onCreateNew: PropTypes.func.isRequired,
}
PlaylistList.propTypes = {
filteredOptions: PropTypes.array.isRequired,
selectedPlaylists: PropTypes.array.isRequired,
onPlaylistToggle: PropTypes.func.isRequired,
searchText: PropTypes.string.isRequired,
canCreateNew: PropTypes.bool.isRequired,
onCreateNew: PropTypes.func.isRequired,
}
SelectedPlaylistChip.propTypes = {
playlist: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
}
SelectedPlaylistsDisplay.propTypes = {
selectedPlaylists: PropTypes.array.isRequired,
onRemoveSelected: PropTypes.func.isRequired,
}

View File

@@ -11,51 +11,52 @@ import {
import { SelectPlaylistInput } from './SelectPlaylistInput'
import { describe, beforeAll, afterEach, it, expect, vi } from 'vitest'
describe('SelectPlaylistInput', () => {
beforeAll(() => localStorage.setItem('userId', 'admin'))
afterEach(cleanup)
const onChangeHandler = vi.fn()
it('should call the handler with the selections', async () => {
const mockData = [
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
{ id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
const mockPlaylists = [
{ id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
{ id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' },
{ id: 'playlist-3', name: 'Electronic Beats', ownerId: 'admin' },
{ id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' }, // Not writable by admin
]
const mockIndexedData = {
'sample-id1': {
id: 'sample-id1',
name: 'sample playlist 1',
ownerId: 'admin',
},
'sample-id2': {
id: 'sample-id2',
name: 'sample playlist 2',
'playlist-1': { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
'playlist-2': { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' },
'playlist-3': {
id: 'playlist-3',
name: 'Electronic Beats',
ownerId: 'admin',
},
'playlist-4': { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' },
}
const mockDataProvider = {
getList: vi
.fn()
.mockResolvedValue({ data: mockData, total: mockData.length }),
const createTestComponent = (
mockDataProvider = null,
onChangeMock = vi.fn(),
playlists = mockPlaylists,
indexedData = mockIndexedData,
) => {
const dataProvider = mockDataProvider || {
getList: vi.fn().mockResolvedValue({
data: playlists,
total: playlists.length,
}),
}
render(
<DataProviderContext.Provider value={mockDataProvider}>
return render(
<DataProviderContext.Provider value={dataProvider}>
<TestContext
initialState={{
addToPlaylistDialog: { open: true, duplicateSong: false },
admin: {
ui: { optimistic: false },
resources: {
playlist: {
data: mockIndexedData,
data: indexedData,
list: {
cachedRequests: {
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}':
{
ids: ['sample-id1', 'sample-id2'],
total: 2,
ids: Object.keys(indexedData),
total: Object.keys(indexedData).length,
},
},
},
@@ -64,62 +65,429 @@ describe('SelectPlaylistInput', () => {
},
}}
>
<SelectPlaylistInput onChange={onChangeHandler} />
<SelectPlaylistInput onChange={onChangeMock} />
</TestContext>
</DataProviderContext.Provider>,
)
}
describe('SelectPlaylistInput', () => {
beforeAll(() => localStorage.setItem('userId', 'admin'))
afterEach(cleanup)
describe('Basic Functionality', () => {
it('should render search field and playlist list', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(mockDataProvider.getList).toHaveBeenCalledWith('playlist', {
filter: { smart: false },
pagination: { page: 1, perPage: -1 },
sort: { field: 'name', order: 'ASC' },
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
expect(screen.getByText('Jazz Collection')).toBeInTheDocument()
expect(screen.getByText('Electronic Beats')).toBeInTheDocument()
})
// Should not show playlists not owned by admin (not writable)
expect(screen.queryByText('Chill Vibes')).not.toBeInTheDocument()
})
it('should filter playlists based on search input', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
})
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'rock' } })
await waitFor(() => {
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
expect(screen.queryByText('Jazz Collection')).not.toBeInTheDocument()
expect(screen.queryByText('Electronic Beats')).not.toBeInTheDocument()
})
})
let textBox = screen.getByRole('textbox')
fireEvent.change(textBox, { target: { value: 'sample' } })
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
fireEvent.keyDown(textBox, { key: 'Enter' })
it('should handle case-insensitive search', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(onChangeHandler).toHaveBeenCalledWith([
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
expect(screen.getByText('Jazz Collection')).toBeInTheDocument()
})
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'JAZZ' } })
await waitFor(() => {
expect(screen.getByText('Jazz Collection')).toBeInTheDocument()
expect(screen.queryByText('Rock Classics')).not.toBeInTheDocument()
})
})
})
describe('Playlist Selection', () => {
it('should select and deselect playlists by clicking', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
})
// Select first playlist
const rockPlaylist = screen.getByText('Rock Classics')
fireEvent.click(rockPlaylist)
await waitFor(() => {
expect(onChangeMock).toHaveBeenCalledWith([
{ id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
])
})
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
fireEvent.keyDown(textBox, { key: 'Enter' })
// Select second playlist
const jazzPlaylist = screen.getByText('Jazz Collection')
fireEvent.click(jazzPlaylist)
await waitFor(() => {
expect(onChangeHandler).toHaveBeenCalledWith([
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
{ id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
expect(onChangeMock).toHaveBeenCalledWith([
{ id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
{ id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' },
])
})
fireEvent.change(textBox, {
target: { value: 'new playlist' },
})
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
fireEvent.keyDown(textBox, { key: 'Enter' })
// Deselect first playlist
fireEvent.click(rockPlaylist)
await waitFor(() => {
expect(onChangeHandler).toHaveBeenCalledWith([
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
{ id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
{ name: 'new playlist' },
expect(onChangeMock).toHaveBeenCalledWith([
{ id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' },
])
})
})
it('should show selected playlists as chips', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
})
// Select a playlist
const rockPlaylist = screen.getByText('Rock Classics')
fireEvent.click(rockPlaylist)
await waitFor(() => {
// Should show the selected playlist as a chip
const chips = screen.getAllByText('Rock Classics')
expect(chips.length).toBeGreaterThan(1) // One in list, one in chip
})
})
it('should remove selected playlists via chip remove button', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
})
// Select a playlist
const rockPlaylist = screen.getByText('Rock Classics')
fireEvent.click(rockPlaylist)
await waitFor(() => {
// Should show selected playlist as chip
const chips = screen.getAllByText('Rock Classics')
expect(chips.length).toBeGreaterThan(1)
})
// Find and click the remove button (translation key)
const removeButton = screen.getByText(
'resources.playlist.actions.removeSymbol',
)
fireEvent.click(removeButton)
await waitFor(() => {
expect(onChangeMock).toHaveBeenCalledWith([])
// Should only have one instance (in the list) after removal
const remainingChips = screen.getAllByText('Rock Classics')
expect(remainingChips.length).toBe(1)
})
})
})
describe('Create New Playlist', () => {
it('should create new playlist by pressing Enter', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'My New Playlist' } })
fireEvent.keyDown(searchInput, { key: 'Enter' })
await waitFor(() => {
expect(onChangeMock).toHaveBeenCalledWith([{ name: 'My New Playlist' }])
})
// Input should be cleared after creating
expect(searchInput.value).toBe('')
})
it('should create new playlist by clicking add button', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'Another Playlist' } })
// Find the add button by the translation key title
const addButton = screen.getByTitle(
'resources.playlist.actions.addNewPlaylist',
)
fireEvent.click(addButton)
await waitFor(() => {
expect(onChangeMock).toHaveBeenCalledWith([
{ name: 'Another Playlist' },
])
})
})
it('should not show create option for existing playlist names', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'Rock Classics' } })
await waitFor(() => {
expect(
screen.queryByText('resources.playlist.actions.addNewPlaylist'),
).not.toBeInTheDocument()
})
})
it('should not create playlist with empty name', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: ' ' } }) // Only spaces
fireEvent.keyDown(searchInput, { key: 'Enter' })
// Should not call onChange
expect(onChangeMock).not.toHaveBeenCalled()
})
it('should show create options in appropriate contexts', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
const searchInput = screen.getByRole('textbox')
// When typing a new name, should show create options
fireEvent.change(searchInput, { target: { value: 'My New Playlist' } })
await waitFor(() => {
// Should show the add button in the search field
expect(
screen.getByTitle('resources.playlist.actions.addNewPlaylist'),
).toBeInTheDocument()
// Should also show hint in empty message when no matches
expect(
screen.getByText('resources.playlist.actions.pressEnterToCreate'),
).toBeInTheDocument()
})
})
})
describe('Mixed Operations', () => {
it('should handle selecting existing playlists and creating new ones', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
})
// Select existing playlist
const rockPlaylist = screen.getByText('Rock Classics')
fireEvent.click(rockPlaylist)
await waitFor(() => {
expect(onChangeMock).toHaveBeenCalledWith([
{ id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
])
})
fireEvent.change(textBox, {
target: { value: 'another new playlist' },
})
fireEvent.keyDown(textBox, { key: 'Enter' })
// Create new playlist
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'New Mix' } })
fireEvent.keyDown(searchInput, { key: 'Enter' })
await waitFor(() => {
expect(onChangeHandler).toHaveBeenCalledWith([
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
{ id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
{ name: 'new playlist' },
{ name: 'another new playlist' },
expect(onChangeMock).toHaveBeenCalledWith([
{ id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
{ name: 'New Mix' },
])
})
})
it('should maintain selections when searching', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
})
// Select a playlist
const rockPlaylist = screen.getByText('Rock Classics')
fireEvent.click(rockPlaylist)
// Filter the list
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'jazz' } })
await waitFor(() => {
// Should still show selected playlists section
// Rock Classics should still be visible as a selected chip even though filtered out
expect(screen.getByText('Rock Classics')).toBeInTheDocument() // In selected chips
expect(screen.getByText('Jazz Collection')).toBeInTheDocument()
})
})
})
describe('Empty States', () => {
it('should show empty message when no playlists exist', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock, [], {})
await waitFor(() => {
expect(
screen.getByText('resources.playlist.message.noPlaylists'),
).toBeInTheDocument()
})
})
it('should show "no results" message when search returns no matches', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, {
target: { value: 'NonExistentPlaylist' },
})
await waitFor(() => {
expect(
screen.getByText('resources.playlist.message.noPlaylistsFound'),
).toBeInTheDocument()
expect(
screen.getByText('resources.playlist.actions.pressEnterToCreate'),
).toBeInTheDocument()
})
})
})
describe('Keyboard Navigation', () => {
it('should not create playlist on Enter if input is empty', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
const searchInput = screen.getByRole('textbox')
fireEvent.keyDown(searchInput, { key: 'Enter' })
expect(onChangeMock).not.toHaveBeenCalled()
})
it('should handle other keys without side effects', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'test' } })
fireEvent.keyDown(searchInput, { key: 'ArrowDown' })
fireEvent.keyDown(searchInput, { key: 'Tab' })
fireEvent.keyDown(searchInput, { key: 'Escape' })
// Should not create playlist or trigger onChange
expect(onChangeMock).not.toHaveBeenCalled()
expect(searchInput.value).toBe('test')
})
})
describe('Integration Scenarios', () => {
it('should handle complex workflow: search, select, create, remove', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
})
// Search and select existing playlist
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'rock' } })
const rockPlaylist = screen.getByText('Rock Classics')
fireEvent.click(rockPlaylist)
// Clear search and create new playlist
fireEvent.change(searchInput, { target: { value: 'My Custom Mix' } })
fireEvent.keyDown(searchInput, { key: 'Enter' })
await waitFor(() => {
expect(onChangeMock).toHaveBeenCalledWith([
{ id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
{ name: 'My Custom Mix' },
])
})
// Remove the first selected playlist via chip
const removeButtons = screen.getAllByText(
'resources.playlist.actions.removeSymbol',
)
fireEvent.click(removeButtons[0])
await waitFor(() => {
expect(onChangeMock).toHaveBeenCalledWith([{ name: 'My Custom Mix' }])
})
})
})
})

View File

@@ -0,0 +1,278 @@
/**
* TOML utility functions for configuration export
*/
/**
* Flattens nested configuration object and generates environment variable names
* @param {Object} config - The nested configuration object from the backend
* @param {string} prefix - The current prefix for nested keys
* @returns {Array} - Array of config objects with key, envVar, and value properties
*/
export const flattenConfig = (config, prefix = '') => {
const result = []
if (!config || typeof config !== 'object') {
return result
}
Object.keys(config).forEach((key) => {
const value = config[key]
const currentKey = prefix ? `${prefix}.${key}` : key
if (value && typeof value === 'object' && !Array.isArray(value)) {
// Recursively flatten nested objects
result.push(...flattenConfig(value, currentKey))
} else {
// Generate environment variable name: ND_ + uppercase with dots replaced by underscores
const envVar = 'ND_' + currentKey.toUpperCase().replace(/\./g, '_')
// Convert value to string for display
let displayValue = value
if (
Array.isArray(value) ||
(typeof value === 'object' && value !== null)
) {
displayValue = JSON.stringify(value)
} else {
displayValue = String(value)
}
result.push({
key: currentKey,
envVar: envVar,
value: displayValue,
})
}
})
return result
}
/**
* Separates and sorts configuration entries into regular and dev configs
* @param {Array|Object} configEntries - Array of config objects with key and value, or nested config object
* @returns {Object} - Object with regularConfigs and devConfigs arrays, both sorted
*/
export const separateAndSortConfigs = (configEntries) => {
const regularConfigs = []
const devConfigs = []
// Handle both the old array format and new nested object format
let flattenedConfigs
if (Array.isArray(configEntries)) {
// Old format - already flattened
flattenedConfigs = configEntries
} else {
// New format - need to flatten
flattenedConfigs = flattenConfig(configEntries)
}
flattenedConfigs?.forEach((config) => {
// Skip configFile as it's displayed separately
if (config.key === 'ConfigFile') {
return
}
if (config.key.startsWith('Dev')) {
devConfigs.push(config)
} else {
regularConfigs.push(config)
}
})
// Sort configurations alphabetically
regularConfigs.sort((a, b) => a.key.localeCompare(b.key))
devConfigs.sort((a, b) => a.key.localeCompare(b.key))
return { regularConfigs, devConfigs }
}
/**
* Escapes TOML keys that contain special characters
* @param {string} key - The key to potentially escape
* @returns {string} - The escaped key if needed, or the original key
*/
export const escapeTomlKey = (key) => {
// Convert to string first to handle null/undefined
const keyStr = String(key)
// Empty strings always need quotes
if (keyStr === '') {
return '""'
}
// TOML bare keys can only contain letters, numbers, underscores, and hyphens
// If the key contains other characters, it needs to be quoted
if (/^[a-zA-Z0-9_-]+$/.test(keyStr)) {
return keyStr
}
// Escape quotes in the key and wrap in quotes
return `"${keyStr.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
/**
* Converts a value to proper TOML format
* @param {*} value - The value to format
* @returns {string} - The TOML-formatted value
*/
export const formatTomlValue = (value) => {
if (value === null || value === undefined) {
return '""'
}
const str = String(value)
// Boolean values
if (str === 'true' || str === 'false') {
return str
}
// Numbers (integers and floats)
if (/^-?\d+$/.test(str)) {
return str // Integer
}
if (/^-?\d*\.\d+$/.test(str)) {
return str // Float
}
// Duration values (like "300ms", "1s", "5m")
if (/^\d+(\.\d+)?(ns|us|µs|ms|s|m|h)$/.test(str)) {
return `"${str}"`
}
// Handle arrays and objects
if (str.startsWith('[') || str.startsWith('{')) {
try {
const parsed = JSON.parse(str)
// If it's an array, format as TOML array
if (Array.isArray(parsed)) {
const formattedItems = parsed.map((item) => {
if (typeof item === 'string') {
return `"${item.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
} else if (typeof item === 'number' || typeof item === 'boolean') {
return String(item)
} else {
return `"${String(item).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
})
if (formattedItems.length === 0) {
return '[ ]'
}
return `[ ${formattedItems.join(', ')} ]`
}
// For objects, keep the JSON string format with triple quotes
return `"""${str}"""`
} catch {
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
}
// String values (escape backslashes and quotes)
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
/**
* Converts nested keys to TOML sections
* @param {Array} configs - Array of config objects with key and value
* @returns {Object} - Object with sections and rootKeys
*/
export const buildTomlSections = (configs) => {
const sections = {}
const rootKeys = []
configs.forEach(({ key, value }) => {
if (key.includes('.')) {
const parts = key.split('.')
const sectionName = parts[0]
const keyName = parts.slice(1).join('.')
if (!sections[sectionName]) {
sections[sectionName] = []
}
sections[sectionName].push({ key: keyName, value })
} else {
rootKeys.push({ key, value })
}
})
return { sections, rootKeys }
}
/**
* Converts configuration data to TOML format
* @param {Object} configData - The configuration data object
* @param {Function} translate - Translation function for internationalization
* @returns {string} - The TOML-formatted configuration
*/
export const configToToml = (configData, translate = (key) => key) => {
let tomlContent = `# Navidrome Configuration\n# Generated on ${new Date().toISOString()}\n\n`
// Handle both old array format (configData.config is array) and new nested format (configData.config is object)
let configs
if (Array.isArray(configData.config)) {
// Old format - already flattened
configs = configData.config
} else {
// New format - need to flatten
configs = flattenConfig(configData.config)
}
const { regularConfigs, devConfigs } = separateAndSortConfigs(configs)
// Process regular configs
const { sections: regularSections, rootKeys: regularRootKeys } =
buildTomlSections(regularConfigs)
// Add root-level keys first
if (regularRootKeys.length > 0) {
regularRootKeys.forEach(({ key, value }) => {
tomlContent += `${key} = ${formatTomlValue(value)}\n`
})
tomlContent += '\n'
}
// Add dev configs if any
if (devConfigs.length > 0) {
tomlContent += `# ${translate('about.config.devFlagsHeader')}\n`
tomlContent += `# ${translate('about.config.devFlagsComment')}\n\n`
const { sections: devSections, rootKeys: devRootKeys } =
buildTomlSections(devConfigs)
// Add dev root-level keys
devRootKeys.forEach(({ key, value }) => {
tomlContent += `${key} = ${formatTomlValue(value)}\n`
})
if (devRootKeys.length > 0) {
tomlContent += '\n'
}
// Add dev sections
Object.keys(devSections)
.sort()
.forEach((sectionName) => {
tomlContent += `[${sectionName}]\n`
devSections[sectionName].forEach(({ key, value }) => {
tomlContent += `${escapeTomlKey(key)} = ${formatTomlValue(value)}\n`
})
tomlContent += '\n'
})
}
// Add sections
Object.keys(regularSections)
.sort()
.forEach((sectionName) => {
tomlContent += `[${sectionName}]\n`
regularSections[sectionName].forEach(({ key, value }) => {
tomlContent += `${escapeTomlKey(key)} = ${formatTomlValue(value)}\n`
})
tomlContent += '\n'
})
return tomlContent
}

View File

@@ -0,0 +1,737 @@
import { describe, it, expect } from 'vitest'
import {
formatTomlValue,
buildTomlSections,
configToToml,
separateAndSortConfigs,
flattenConfig,
escapeTomlKey,
} from './aboutUtils'
describe('formatTomlValue', () => {
it('handles null and undefined values', () => {
expect(formatTomlValue(null)).toBe('""')
expect(formatTomlValue(undefined)).toBe('""')
})
it('handles boolean values', () => {
expect(formatTomlValue('true')).toBe('true')
expect(formatTomlValue('false')).toBe('false')
expect(formatTomlValue(true)).toBe('true')
expect(formatTomlValue(false)).toBe('false')
})
it('handles integer values', () => {
expect(formatTomlValue('123')).toBe('123')
expect(formatTomlValue('-456')).toBe('-456')
expect(formatTomlValue('0')).toBe('0')
expect(formatTomlValue(789)).toBe('789')
})
it('handles float values', () => {
expect(formatTomlValue('123.45')).toBe('123.45')
expect(formatTomlValue('-67.89')).toBe('-67.89')
expect(formatTomlValue('0.0')).toBe('0.0')
expect(formatTomlValue(12.34)).toBe('12.34')
})
it('handles duration values', () => {
expect(formatTomlValue('300ms')).toBe('"300ms"')
expect(formatTomlValue('5s')).toBe('"5s"')
expect(formatTomlValue('10m')).toBe('"10m"')
expect(formatTomlValue('2h')).toBe('"2h"')
expect(formatTomlValue('1.5s')).toBe('"1.5s"')
})
it('handles JSON arrays and objects', () => {
expect(formatTomlValue('["item1", "item2"]')).toBe('[ "item1", "item2" ]')
expect(formatTomlValue('{"key": "value"}')).toBe('"""{"key": "value"}"""')
})
it('formats different types of arrays correctly', () => {
// String array
expect(formatTomlValue('["genre", "tcon", "©gen"]')).toBe(
'[ "genre", "tcon", "©gen" ]',
)
// Mixed array with numbers and strings
expect(formatTomlValue('[42, "test", true]')).toBe('[ 42, "test", true ]')
// Empty array
expect(formatTomlValue('[]')).toBe('[ ]')
// Array with special characters in strings
expect(
formatTomlValue('["item with spaces", "item\\"with\\"quotes"]'),
).toBe('[ "item with spaces", "item\\"with\\"quotes" ]')
})
it('handles invalid JSON as regular strings', () => {
expect(formatTomlValue('[invalid json')).toBe('"[invalid json"')
expect(formatTomlValue('{broken')).toBe('"{broken"')
})
it('handles regular strings with quote escaping', () => {
expect(formatTomlValue('simple string')).toBe('"simple string"')
expect(formatTomlValue('string with "quotes"')).toBe(
'"string with \\"quotes\\""',
)
expect(formatTomlValue('/path/to/file')).toBe('"/path/to/file"')
})
it('handles strings with backslashes and quotes', () => {
expect(formatTomlValue('C:\\Program Files\\app')).toBe(
'"C:\\\\Program Files\\\\app"',
)
expect(formatTomlValue('path\\to"file')).toBe('"path\\\\to\\"file"')
expect(formatTomlValue('backslash\\ and "quote"')).toBe(
'"backslash\\\\ and \\"quote\\""',
)
expect(formatTomlValue('single\\backslash')).toBe('"single\\\\backslash"')
})
it('handles empty strings', () => {
expect(formatTomlValue('')).toBe('""')
})
})
describe('buildTomlSections', () => {
it('separates root keys from nested keys', () => {
const configs = [
{ key: 'RootKey1', value: 'value1' },
{ key: 'Section.NestedKey', value: 'value2' },
{ key: 'RootKey2', value: 'value3' },
{ key: 'Section.AnotherKey', value: 'value4' },
{ key: 'AnotherSection.Key', value: 'value5' },
]
const result = buildTomlSections(configs)
expect(result.rootKeys).toEqual([
{ key: 'RootKey1', value: 'value1' },
{ key: 'RootKey2', value: 'value3' },
])
expect(result.sections).toEqual({
Section: [
{ key: 'NestedKey', value: 'value2' },
{ key: 'AnotherKey', value: 'value4' },
],
AnotherSection: [{ key: 'Key', value: 'value5' }],
})
})
it('handles deeply nested keys', () => {
const configs = [{ key: 'Section.SubSection.DeepKey', value: 'deepValue' }]
const result = buildTomlSections(configs)
expect(result.rootKeys).toEqual([])
expect(result.sections).toEqual({
Section: [{ key: 'SubSection.DeepKey', value: 'deepValue' }],
})
})
it('handles empty input', () => {
const result = buildTomlSections([])
expect(result.rootKeys).toEqual([])
expect(result.sections).toEqual({})
})
})
describe('configToToml', () => {
const mockTranslate = (key) => {
const translations = {
'about.config.devFlagsHeader':
'Development Flags (subject to change/removal)',
'about.config.devFlagsComment':
'These are experimental settings and may be removed in future versions',
}
return translations[key] || key
}
it('generates TOML with header and timestamp', () => {
const configData = {
config: [{ key: 'TestKey', value: 'testValue' }],
}
const result = configToToml(configData, mockTranslate)
expect(result).toContain('# Navidrome Configuration')
expect(result).toContain('# Generated on')
expect(result).toContain('TestKey = "testValue"')
})
it('separates and sorts regular and dev configs', () => {
const configData = {
config: [
{ key: 'ZRegularKey', value: 'regularValue' },
{ key: 'DevTestFlag', value: 'true' },
{ key: 'ARegularKey', value: 'anotherValue' },
{ key: 'DevAnotherFlag', value: 'false' },
],
}
const result = configToToml(configData, mockTranslate)
// Check that regular configs come first and are sorted
const lines = result.split('\n')
const aRegularIndex = lines.findIndex((line) =>
line.includes('ARegularKey'),
)
const zRegularIndex = lines.findIndex((line) =>
line.includes('ZRegularKey'),
)
const devHeaderIndex = lines.findIndex((line) =>
line.includes('Development Flags'),
)
const devAnotherIndex = lines.findIndex((line) =>
line.includes('DevAnotherFlag'),
)
const devTestIndex = lines.findIndex((line) => line.includes('DevTestFlag'))
expect(aRegularIndex).toBeLessThan(zRegularIndex)
expect(zRegularIndex).toBeLessThan(devHeaderIndex)
expect(devHeaderIndex).toBeLessThan(devAnotherIndex)
expect(devAnotherIndex).toBeLessThan(devTestIndex)
})
it('skips ConfigFile entries', () => {
const configData = {
config: [
{ key: 'ConfigFile', value: '/path/to/config.toml' },
{ key: 'TestKey', value: 'testValue' },
],
}
const result = configToToml(configData, mockTranslate)
expect(result).not.toContain('ConfigFile =')
expect(result).toContain('TestKey = "testValue"')
})
it('handles sections correctly', () => {
const configData = {
config: [
{ key: 'RootKey', value: 'rootValue' },
{ key: 'Section.NestedKey', value: 'nestedValue' },
{ key: 'Section.AnotherKey', value: 'anotherValue' },
{ key: 'DevA', value: 'DevValue' },
],
}
const result = configToToml(configData, mockTranslate)
// Fields in a section are sorted alphabetically
const fields = [
'RootKey = "rootValue"',
'DevA = "DevValue"',
'[Section]',
'AnotherKey = "anotherValue"',
'NestedKey = "nestedValue"',
]
for (let idx = 0; idx < fields.length - 1; idx++) {
expect(result).toContain(fields[idx])
const idxA = result.indexOf(fields[idx])
const idxB = result.indexOf(fields[idx + 1])
expect(idxA).toBeLessThan(idxB)
}
expect(result).toContain(fields[fields.length - 1])
})
it('includes dev flags header when dev configs exist', () => {
const configData = {
config: [
{ key: 'RegularKey', value: 'regularValue' },
{ key: 'DevTestFlag', value: 'true' },
],
}
const result = configToToml(configData, mockTranslate)
expect(result).toContain('# Development Flags (subject to change/removal)')
expect(result).toContain(
'# These are experimental settings and may be removed in future versions',
)
expect(result).toContain('DevTestFlag = true')
})
it('does not include dev flags header when no dev configs exist', () => {
const configData = {
config: [{ key: 'RegularKey', value: 'regularValue' }],
}
const result = configToToml(configData, mockTranslate)
expect(result).not.toContain('Development Flags')
expect(result).toContain('RegularKey = "regularValue"')
})
it('handles empty config data', () => {
const configData = { config: [] }
const result = configToToml(configData, mockTranslate)
expect(result).toContain('# Navidrome Configuration')
expect(result).not.toContain('Development Flags')
})
it('handles missing config array', () => {
const configData = {}
const result = configToToml(configData, mockTranslate)
expect(result).toContain('# Navidrome Configuration')
expect(result).not.toContain('Development Flags')
})
it('works without translate function', () => {
const configData = {
config: [{ key: 'DevTestFlag', value: 'true' }],
}
const result = configToToml(configData)
expect(result).toContain('# about.config.devFlagsHeader')
expect(result).toContain('# about.config.devFlagsComment')
expect(result).toContain('DevTestFlag = true')
})
it('handles various data types correctly', () => {
const configData = {
config: [
{ key: 'StringValue', value: 'test string' },
{ key: 'BooleanValue', value: 'true' },
{ key: 'IntegerValue', value: '42' },
{ key: 'FloatValue', value: '3.14' },
{ key: 'DurationValue', value: '5s' },
{ key: 'ArrayValue', value: '["item1", "item2"]' },
],
}
const result = configToToml(configData, mockTranslate)
expect(result).toContain('StringValue = "test string"')
expect(result).toContain('BooleanValue = true')
expect(result).toContain('IntegerValue = 42')
expect(result).toContain('FloatValue = 3.14')
expect(result).toContain('DurationValue = "5s"')
expect(result).toContain('ArrayValue = [ "item1", "item2" ]')
})
it('handles nested config object format correctly', () => {
const configData = {
config: {
Address: '127.0.0.1',
Port: 4533,
EnableDownloads: true,
DevLogSourceLine: false,
LastFM: {
Enabled: true,
ApiKey: 'secret123',
Language: 'en',
},
Scanner: {
Schedule: 'daily',
Enabled: true,
},
},
}
const result = configToToml(configData, mockTranslate)
// Should contain regular configs
expect(result).toContain('Address = "127.0.0.1"')
expect(result).toContain('Port = 4533')
expect(result).toContain('EnableDownloads = true')
// Should contain dev configs with header
expect(result).toContain('# Development Flags (subject to change/removal)')
expect(result).toContain('DevLogSourceLine = false')
// Should contain sections
expect(result).toContain('[LastFM]')
expect(result).toContain('Enabled = true')
expect(result).toContain('ApiKey = "secret123"')
expect(result).toContain('Language = "en"')
expect(result).toContain('[Scanner]')
expect(result).toContain('Schedule = "daily"')
})
it('handles mixed nested and flat structure', () => {
const configData = {
config: {
MusicFolder: '/music',
DevAutoLoginUsername: 'testuser',
Jukebox: {
Enabled: false,
AdminOnly: true,
},
},
}
const result = configToToml(configData, mockTranslate)
expect(result).toContain('MusicFolder = "/music"')
expect(result).toContain('DevAutoLoginUsername = "testuser"')
expect(result).toContain('[Jukebox]')
expect(result).toContain('Enabled = false')
expect(result).toContain('AdminOnly = true')
})
it('properly escapes keys with special characters in sections', () => {
const configData = {
config: [
{ key: 'DevLogLevels.persistence/sql_base_repository', value: 'trace' },
{ key: 'DevLogLevels.core/scanner', value: 'debug' },
{ key: 'DevLogLevels.regular_key', value: 'info' },
{ key: 'Tags.genre.Aliases', value: '["tcon","genre","©gen"]' },
],
}
const result = configToToml(configData, mockTranslate)
// Keys with forward slashes should be quoted
expect(result).toContain('"persistence/sql_base_repository" = "trace"')
expect(result).toContain('"core/scanner" = "debug"')
// Regular keys should not be quoted
expect(result).toContain('regular_key = "info"')
// Arrays should be formatted correctly
expect(result).toContain('"genre.Aliases" = [ "tcon", "genre", "©gen" ]')
// Should contain proper sections
expect(result).toContain('[DevLogLevels]')
expect(result).toContain('[Tags]')
})
})
describe('flattenConfig', () => {
it('flattens simple nested objects correctly', () => {
const config = {
Address: '0.0.0.0',
Port: 4533,
EnableDownloads: true,
LastFM: {
Enabled: true,
ApiKey: 'secret123',
Language: 'en',
},
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'Address',
envVar: 'ND_ADDRESS',
value: '0.0.0.0',
})
expect(result).toContainEqual({
key: 'Port',
envVar: 'ND_PORT',
value: '4533',
})
expect(result).toContainEqual({
key: 'EnableDownloads',
envVar: 'ND_ENABLEDOWNLOADS',
value: 'true',
})
expect(result).toContainEqual({
key: 'LastFM.Enabled',
envVar: 'ND_LASTFM_ENABLED',
value: 'true',
})
expect(result).toContainEqual({
key: 'LastFM.ApiKey',
envVar: 'ND_LASTFM_APIKEY',
value: 'secret123',
})
expect(result).toContainEqual({
key: 'LastFM.Language',
envVar: 'ND_LASTFM_LANGUAGE',
value: 'en',
})
})
it('handles deeply nested objects', () => {
const config = {
Scanner: {
Schedule: 'daily',
Options: {
ExtractorType: 'taglib',
ArtworkPriority: 'cover.jpg',
},
},
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'Scanner.Schedule',
envVar: 'ND_SCANNER_SCHEDULE',
value: 'daily',
})
expect(result).toContainEqual({
key: 'Scanner.Options.ExtractorType',
envVar: 'ND_SCANNER_OPTIONS_EXTRACTORTYPE',
value: 'taglib',
})
expect(result).toContainEqual({
key: 'Scanner.Options.ArtworkPriority',
envVar: 'ND_SCANNER_OPTIONS_ARTWORKPRIORITY',
value: 'cover.jpg',
})
})
it('handles arrays correctly', () => {
const config = {
DeviceList: ['device1', 'device2'],
Settings: {
EnabledFormats: ['mp3', 'flac', 'ogg'],
},
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'DeviceList',
envVar: 'ND_DEVICELIST',
value: '["device1","device2"]',
})
expect(result).toContainEqual({
key: 'Settings.EnabledFormats',
envVar: 'ND_SETTINGS_ENABLEDFORMATS',
value: '["mp3","flac","ogg"]',
})
})
it('handles null and undefined values', () => {
const config = {
NullValue: null,
UndefinedValue: undefined,
EmptyString: '',
ZeroValue: 0,
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'NullValue',
envVar: 'ND_NULLVALUE',
value: 'null',
})
expect(result).toContainEqual({
key: 'UndefinedValue',
envVar: 'ND_UNDEFINEDVALUE',
value: 'undefined',
})
expect(result).toContainEqual({
key: 'EmptyString',
envVar: 'ND_EMPTYSTRING',
value: '',
})
expect(result).toContainEqual({
key: 'ZeroValue',
envVar: 'ND_ZEROVALUE',
value: '0',
})
})
it('handles empty object', () => {
const result = flattenConfig({})
expect(result).toEqual([])
})
it('handles null/undefined input', () => {
expect(flattenConfig(null)).toEqual([])
expect(flattenConfig(undefined)).toEqual([])
})
it('handles non-object input', () => {
expect(flattenConfig('string')).toEqual([])
expect(flattenConfig(123)).toEqual([])
expect(flattenConfig(true)).toEqual([])
})
})
describe('separateAndSortConfigs', () => {
it('separates regular and dev configs correctly with array input', () => {
const configs = [
{ key: 'RegularKey1', value: 'value1' },
{ key: 'DevTestFlag', value: 'true' },
{ key: 'AnotherRegular', value: 'value2' },
{ key: 'DevAnotherFlag', value: 'false' },
]
const result = separateAndSortConfigs(configs)
expect(result.regularConfigs).toEqual([
{ key: 'AnotherRegular', value: 'value2' },
{ key: 'RegularKey1', value: 'value1' },
])
expect(result.devConfigs).toEqual([
{ key: 'DevAnotherFlag', value: 'false' },
{ key: 'DevTestFlag', value: 'true' },
])
})
it('separates regular and dev configs correctly with nested object input', () => {
const config = {
Address: '127.0.0.1',
Port: 4533,
DevAutoLoginUsername: 'testuser',
DevLogSourceLine: true,
LastFM: {
Enabled: true,
ApiKey: 'secret123',
},
}
const result = separateAndSortConfigs(config)
expect(result.regularConfigs).toEqual([
{ key: 'Address', envVar: 'ND_ADDRESS', value: '127.0.0.1' },
{ key: 'LastFM.ApiKey', envVar: 'ND_LASTFM_APIKEY', value: 'secret123' },
{ key: 'LastFM.Enabled', envVar: 'ND_LASTFM_ENABLED', value: 'true' },
{ key: 'Port', envVar: 'ND_PORT', value: '4533' },
])
expect(result.devConfigs).toEqual([
{
key: 'DevAutoLoginUsername',
envVar: 'ND_DEVAUTOLOGINUSERNAME',
value: 'testuser',
},
{ key: 'DevLogSourceLine', envVar: 'ND_DEVLOGSOURCELINE', value: 'true' },
])
})
it('skips ConfigFile entries', () => {
const configs = [
{ key: 'ConfigFile', value: '/path/to/config.toml' },
{ key: 'RegularKey', value: 'value' },
{ key: 'DevFlag', value: 'true' },
]
const result = separateAndSortConfigs(configs)
expect(result.regularConfigs).toEqual([
{ key: 'RegularKey', value: 'value' },
])
expect(result.devConfigs).toEqual([{ key: 'DevFlag', value: 'true' }])
})
it('skips ConfigFile entries with nested object input', () => {
const config = {
ConfigFile: '/path/to/config.toml',
RegularKey: 'value',
DevFlag: true,
}
const result = separateAndSortConfigs(config)
expect(result.regularConfigs).toEqual([
{ key: 'RegularKey', envVar: 'ND_REGULARKEY', value: 'value' },
])
expect(result.devConfigs).toEqual([
{ key: 'DevFlag', envVar: 'ND_DEVFLAG', value: 'true' },
])
})
it('handles empty input', () => {
const result = separateAndSortConfigs([])
expect(result.regularConfigs).toEqual([])
expect(result.devConfigs).toEqual([])
})
it('handles null/undefined input', () => {
const result1 = separateAndSortConfigs(null)
const result2 = separateAndSortConfigs(undefined)
expect(result1.regularConfigs).toEqual([])
expect(result1.devConfigs).toEqual([])
expect(result2.regularConfigs).toEqual([])
expect(result2.devConfigs).toEqual([])
})
it('sorts configs alphabetically', () => {
const configs = [
{ key: 'ZRegular', value: 'z' },
{ key: 'ARegular', value: 'a' },
{ key: 'DevZ', value: 'z' },
{ key: 'DevA', value: 'a' },
]
const result = separateAndSortConfigs(configs)
expect(result.regularConfigs[0].key).toBe('ARegular')
expect(result.regularConfigs[1].key).toBe('ZRegular')
expect(result.devConfigs[0].key).toBe('DevA')
expect(result.devConfigs[1].key).toBe('DevZ')
})
})
describe('escapeTomlKey', () => {
it('does not escape valid bare keys', () => {
expect(escapeTomlKey('RegularKey')).toBe('RegularKey')
expect(escapeTomlKey('regular_key')).toBe('regular_key')
expect(escapeTomlKey('regular-key')).toBe('regular-key')
expect(escapeTomlKey('key123')).toBe('key123')
expect(escapeTomlKey('Key_with_underscores')).toBe('Key_with_underscores')
expect(escapeTomlKey('Key-with-hyphens')).toBe('Key-with-hyphens')
})
it('escapes keys with special characters', () => {
// Keys with forward slashes (like DevLogLevels keys)
expect(escapeTomlKey('persistence/sql_base_repository')).toBe(
'"persistence/sql_base_repository"',
)
expect(escapeTomlKey('core/scanner')).toBe('"core/scanner"')
// Keys with dots
expect(escapeTomlKey('Section.NestedKey')).toBe('"Section.NestedKey"')
// Keys with spaces
expect(escapeTomlKey('key with spaces')).toBe('"key with spaces"')
// Keys with other special characters
expect(escapeTomlKey('key@with@symbols')).toBe('"key@with@symbols"')
expect(escapeTomlKey('key+with+plus')).toBe('"key+with+plus"')
})
it('escapes quotes in keys', () => {
expect(escapeTomlKey('key"with"quotes')).toBe('"key\\"with\\"quotes"')
expect(escapeTomlKey('key with "quotes" inside')).toBe(
'"key with \\"quotes\\" inside"',
)
})
it('escapes backslashes in keys', () => {
expect(escapeTomlKey('key\\with\\backslashes')).toBe(
'"key\\\\with\\\\backslashes"',
)
expect(escapeTomlKey('path\\to\\file')).toBe('"path\\\\to\\\\file"')
})
it('handles empty and null keys', () => {
expect(escapeTomlKey('')).toBe('""')
expect(escapeTomlKey(null)).toBe('null')
expect(escapeTomlKey(undefined)).toBe('undefined')
})
})

View File

@@ -41,6 +41,7 @@
"addToQueue": "Play Later",
"playNow": "Play Now",
"addToPlaylist": "Add to Playlist",
"showInPlaylist": "Show in Playlist",
"shuffleAll": "Shuffle All",
"download": "Download",
"playNext": "Play Next",
@@ -197,11 +198,17 @@
"export": "Export",
"saveQueue": "Save Queue to Playlist",
"makePublic": "Make Public",
"makePrivate": "Make Private"
"makePrivate": "Make Private",
"searchOrCreate": "Search playlists or type to create new...",
"pressEnterToCreate": "Press Enter to create new playlist",
"removeFromSelection": "Remove from selection",
"removeSymbol": "×"
},
"message": {
"duplicate_song": "Add duplicated songs",
"song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?"
"song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?",
"noPlaylistsFound": "No playlists found",
"noPlaylists": "No playlists available"
}
},
"radio": {
@@ -498,6 +505,21 @@
"disabled": "Disabled",
"waiting": "Waiting"
}
},
"tabs": {
"about": "About",
"config": "Configuration"
},
"config": {
"configName": "Config Name",
"environmentVariable": "Environment Variable",
"currentValue": "Current Value",
"configurationFile": "Configuration File",
"exportToml": "Export Configuration (TOML)",
"exportSuccess": "Configuration exported to clipboard in TOML format",
"exportFailed": "Failed to copy configuration",
"devFlagsHeader": "Development Flags (subject to change/removal)",
"devFlagsComment": "These are experimental settings and may be removed in future versions"
}
},
"activity": {

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from 'react'
import React, { useCallback, useEffect, useMemo } from 'react'
import {
BulkActionsToolbar,
ListToolbar,
@@ -26,11 +26,13 @@ import {
useResourceRefresh,
DateField,
ArtistLinkField,
RatingField,
} from '../common'
import { AlbumLinkField } from '../song/AlbumLinkField'
import { playTracks } from '../actions'
import PlaylistSongBulkActions from './PlaylistSongBulkActions'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import config from '../config'
const useStyles = makeStyles(
(theme) => ({
@@ -66,11 +68,17 @@ const useStyles = makeStyles(
'& $contextMenu': {
visibility: 'visible',
},
'& $ratingField': {
visibility: 'visible',
},
},
},
contextMenu: {
visibility: (props) => (props.isDesktop ? 'hidden' : 'visible'),
},
ratingField: {
visibility: 'hidden',
},
}),
{ name: 'RaList' },
)
@@ -84,7 +92,8 @@ const ReorderableList = ({ readOnly, children, ...rest }) => {
const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
const listContext = useListContext()
const { data, ids, selectedIds, onUnselectItems, refetch } = listContext
const { data, ids, selectedIds, onUnselectItems, refetch, setPage } =
listContext
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
const classes = useStyles({ isDesktop })
const dispatch = useDispatch()
@@ -93,6 +102,11 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
const version = useVersion()
useResourceRefresh('song', 'playlist')
useEffect(() => {
setPage(1)
window.scrollTo({ top: 0, behavior: 'smooth' })
}, [playlistId, setPage])
const onAddToPlaylist = useCallback(
(pls) => {
if (pls.id === playlistId) {
@@ -155,8 +169,16 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
quality: isDesktop && <QualityInfo source="quality" sortable={false} />,
channels: isDesktop && <NumberField source="channels" />,
bpm: isDesktop && <NumberField source="bpm" />,
rating: config.enableStarRating && (
<RatingField
source="rating"
sortByOrder={'DESC'}
resource={'song'}
className={classes.ratingField}
/>
),
}
}, [isDesktop, classes.draggable])
}, [isDesktop, classes.draggable, classes.ratingField])
const columns = useSelectedFields({
resource: 'playlistTrack',
@@ -168,6 +190,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
'playCount',
'playDate',
'albumArtist',
'rating',
],
})
@@ -207,7 +230,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
{columns}
<SongContextMenu
onAddToPlaylist={onAddToPlaylist}
showLove={false}
showLove={true}
className={classes.contextMenu}
/>
</SongDatagrid>