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
Agents string
LastFM lastfmOptions
Spotify spotifyOptions
ListenBrainz listenBrainzOptions
Tags map[string]TagConf
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
// 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)
matches, err := fs.Glob(fsys, pattern)
if err != nil {
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", artistFolder)
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
current := artistFolder
for i := 0; i < maxArtistFolderTraversalDepth; i++ {
if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
return reader, path, nil
}
f, err := os.Open(filePath)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
return nil, "", err
parent := filepath.Dir(current)
if parent == current {
break
}
return f, filePath, nil
current = parent
}
return nil, "", nil
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", folder, err)
return nil, "", err
}
for _, m := range matches {
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)
continue
}
return f, filePath, 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
// 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
}
return split
}
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"
@@ -19,10 +18,9 @@ import (
)
type scannerImpl struct {
ds model.DataStore
cw artwork.CacheWarmer
pls core.Playlists
metrics metrics.Metrics
ds model.DataStore
cw artwork.CacheWarmer
pls core.Playlists
}
// 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,8 +217,33 @@ func (db *MockDataStore) WithTxImmediate(block func(tx model.DataStore) error, l
return block(db)
}
func (db *MockDataStore) Resource(context.Context, any) model.ResourceRepository {
return struct{ 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 {

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})` : '')
const linkTo = audioInfo.isRadio
? `/radio/${audioInfo.trackId}/show`
: song.playlistId
? `/playlist/${song.playlistId}/show`
: `/album/${song.albumId}/show`
return (
<Link
to={
audioInfo.isRadio
? `/radio/${audioInfo.trackId}/show`
: `/album/${song.albumId}/show`
}
className={className}
ref={dragSongRef}
>
<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)
}
})
.catch((e) => {
// eslint-disable-next-line no-console
console.log('Error encountered: ' + e)
})
}, [dataProvider, record, resource])
// 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)
})
.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(() => {
if (mountedRef.current) {
setLoading(false)
}
})
}, [dataProvider, record.id, resource])
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.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">
<LinkToVersion version={uiVersion} />
<Link onClick={() => window.location.reload()}>
<Typography variant={'caption'}>
{' ' + translate('ra.notification.new_version')}
</Typography>
</Link>
<div>
<LinkToVersion version={uiVersion} />
</div>
<div>
<Link onClick={() => window.location.reload()}>
<Typography variant={'caption'}>
{translate('ra.notification.new_version')}
</Typography>
</Link>
</div>
</TableCell>
</TableRow>
)}
@@ -86,11 +138,286 @@ const ShowVersion = ({ uiVersion, serverVersion }) => {
)
}
const AboutDialog = ({ open, onClose }) => {
const AboutTabContent = ({
uiVersion,
serverVersion,
insightsData,
loading,
permissions,
}) => {
const translate = useTranslate()
const lastRun = !loading && insightsData?.lastRun
let insightsStatus = 'N/A'
if (lastRun === 'disabled') {
insightsStatus = translate('about.links.insights.disabled')
} else if (lastRun && lastRun?.startsWith('1969-12-31')) {
insightsStatus = translate('about.links.insights.waiting')
} else if (lastRun) {
insightsStatus = lastRun
}
return (
<Table aria-label={translate('menu.about')} size="small">
<TableBody>
<ShowVersion uiVersion={uiVersion} serverVersion={serverVersion} />
{Object.keys(links).map((key) => {
return (
<TableRow key={key}>
<TableCell align="right" component="th" scope="row">
{translate(`about.links.${key}`, {
_: humanize(underscore(key)),
})}
:
</TableCell>
<TableCell align="left">
<Link
href={`https://${links[key]}`}
target="_blank"
rel="noopener noreferrer"
>
{links[key]}
</Link>
</TableCell>
</TableRow>
)
})}
{permissions === 'admin' ? (
<TableRow>
<TableCell align="right" component="th" scope="row">
{translate(`about.links.lastInsightsCollection`)}:
</TableCell>
<TableCell align="left">
<Link href={INSIGHTS_DOC_URL}>{insightsStatus}</Link>
</TableCell>
</TableRow>
) : null}
<TableRow>
<TableCell align="right" component="th" scope="row">
<Link
href={'https://github.com/sponsors/deluan'}
target="_blank"
rel="noopener noreferrer"
>
<IconButton size={'small'}>
<FavoriteBorderIcon fontSize={'small'} />
</IconButton>
</Link>
</TableCell>
<TableCell align="left">
<Link
href={'https://ko-fi.com/deluan'}
target="_blank"
rel="noopener noreferrer"
>
ko-fi.com/deluan
</Link>
</TableCell>
</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, loading } = useGetOne('insights', 'insights_status')
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(() => {
@@ -108,85 +435,30 @@ const AboutDialog = ({ open, onClose }) => {
})
}, [setServerVersion])
const lastRun = !loading && data?.lastRun
let insightsStatus = 'N/A'
if (lastRun === 'disabled') {
insightsStatus = translate('about.links.insights.disabled')
} else if (lastRun && lastRun?.startsWith('1969-12-31')) {
insightsStatus = translate('about.links.insights.waiting')
} else if (lastRun) {
insightsStatus = lastRun
}
return (
<Dialog onClose={onClose} aria-labelledby="about-dialog-title" open={open}>
<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>
<TableContainer component={Paper}>
<Table aria-label={translate('menu.about')} size="small">
<TableBody>
<ShowVersion
uiVersion={uiVersion}
serverVersion={serverVersion}
/>
{Object.keys(links).map((key) => {
return (
<TableRow key={key}>
<TableCell align="right" component="th" scope="row">
{translate(`about.links.${key}`, {
_: humanize(underscore(key)),
})}
:
</TableCell>
<TableCell align="left">
<Link
href={`https://${links[key]}`}
target="_blank"
rel="noopener noreferrer"
>
{links[key]}
</Link>
</TableCell>
</TableRow>
)
})}
{permissions === 'admin' ? (
<TableRow>
<TableCell align="right" component="th" scope="row">
{translate(`about.links.lastInsightsCollection`)}:
</TableCell>
<TableCell align="left">
<Link href={INSIGHTS_DOC_URL}>{insightsStatus}</Link>
</TableCell>
</TableRow>
) : null}
<TableRow>
<TableCell align="right" component="th" scope="row">
<Link
href={'https://github.com/sponsors/deluan'}
target="_blank"
rel="noopener noreferrer"
>
<IconButton size={'small'}>
<FavoriteBorderIcon fontSize={'small'} />
</IconButton>
</Link>
</TableCell>
<TableCell align="left">
<Link
href={'https://ko-fi.com/deluan'}
target="_blank"
rel="noopener noreferrer"
>
ko-fi.com/deluan
</Link>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
<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,
})
} else {
newState.push(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 {
newSelection = [...selectedPlaylists, playlist]
}
onChange(newState)
setSelectedPlaylists(newSelection)
onChange(newSelection)
}
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />
const checkedIcon = <CheckBoxIcon fontSize="small" />
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,115 +11,483 @@ 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()
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
]
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 mockIndexedData = {
'sample-id1': {
id: 'sample-id1',
name: 'sample playlist 1',
ownerId: 'admin',
},
'sample-id2': {
id: 'sample-id2',
name: 'sample playlist 2',
ownerId: 'admin',
},
}
const mockIndexedData = {
'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}>
<TestContext
initialState={{
addToPlaylistDialog: { open: true, duplicateSong: false },
admin: {
ui: { optimistic: false },
resources: {
playlist: {
data: mockIndexedData,
list: {
cachedRequests: {
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}':
{
ids: ['sample-id1', 'sample-id2'],
total: 2,
},
},
return render(
<DataProviderContext.Provider value={dataProvider}>
<TestContext
initialState={{
admin: {
ui: { optimistic: false },
resources: {
playlist: {
data: indexedData,
list: {
cachedRequests: {
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}':
{
ids: Object.keys(indexedData),
total: Object.keys(indexedData).length,
},
},
},
},
},
}}
>
<SelectPlaylistInput onChange={onChangeHandler} />
</TestContext>
</DataProviderContext.Provider>,
)
},
}}
>
<SelectPlaylistInput onChange={onChangeMock} />
</TestContext>
</DataProviderContext.Provider>,
)
}
await waitFor(() => {
expect(mockDataProvider.getList).toHaveBeenCalledWith('playlist', {
filter: { smart: false },
pagination: { page: 1, perPage: -1 },
sort: { field: 'name', order: 'ASC' },
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(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' })
await waitFor(() => {
expect(onChangeHandler).toHaveBeenCalledWith([
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
])
it('should handle case-insensitive search', async () => {
const onChangeMock = vi.fn()
createTestComponent(null, onChangeMock)
await waitFor(() => {
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' },
])
})
// Select second playlist
const jazzPlaylist = screen.getByText('Jazz Collection')
fireEvent.click(jazzPlaylist)
await waitFor(() => {
expect(onChangeMock).toHaveBeenCalledWith([
{ id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
{ id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' },
])
})
// Deselect first playlist
fireEvent.click(rockPlaylist)
await waitFor(() => {
expect(onChangeMock).toHaveBeenCalledWith([
{ id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' },
])
})
})
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
fireEvent.keyDown(textBox, { 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' },
])
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
})
})
fireEvent.change(textBox, {
target: { value: 'new playlist' },
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)
})
})
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
fireEvent.keyDown(textBox, { 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' },
])
})
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('')
})
fireEvent.change(textBox, {
target: { value: 'another new playlist' },
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' },
])
})
})
fireEvent.keyDown(textBox, { 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' },
])
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' },
])
})
// Create new playlist
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'New Mix' } })
fireEvent.keyDown(searchInput, { key: 'Enter' })
await waitFor(() => {
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>