mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
Merge branch 'master' into kwg43w-codex/implement-starred/loved-playlists-functionality
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
17
core/playback/mpv/mpv_suite_test.go
Normal file
17
core/playback/mpv/mpv_suite_test.go
Normal 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")
|
||||
}
|
||||
390
core/playback/mpv/mpv_test.go
Normal file
390
core/playback/mpv/mpv_test.go
Normal 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
|
||||
}
|
||||
@@ -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
2
go.mod
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
"recentlyPlayed": "Recientes",
|
||||
"mostPlayed": "Más reproducidos",
|
||||
"starred": "Favoritos",
|
||||
"topRated": "Los mejores calificados"
|
||||
"topRated": "Mejor calificados"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
@@ -523,4 +523,4 @@
|
||||
"current_song": "Canción actual"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
@@ -523,4 +544,4 @@
|
||||
"current_song": "Vai para música atual"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
138
server/nativeapi/config.go
Normal 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)
|
||||
}
|
||||
}
|
||||
147
server/nativeapi/config_test.go
Normal file
147
server/nativeapi/config_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
@@ -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}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
464
server/nativeapi/native_api_song_test.go
Normal file
464
server/nativeapi/native_api_song_test.go
Normal 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(®ularUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create JWT token for regular user
|
||||
token, err := auth.CreateToken(®ularUser)
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
96
server/subsonic/users_test.go
Normal file
96
server/subsonic/users_test.go
Normal 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),
|
||||
)
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
57
ui/src/audioplayer/AudioTitle.test.jsx
Normal file
57
ui/src/audioplayer/AudioTitle.test.jsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
82
ui/src/common/SongContextMenu.test.jsx
Normal file
82
ui/src/common/SongContextMenu.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
|
||||
165
ui/src/common/useRating.test.js
Normal file
165
ui/src/common/useRating.test.js
Normal 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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
136
ui/src/common/useToggleLove.test.js
Normal file
136
ui/src/common/useToggleLove.test.js
Normal 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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,7 @@ const defaultConfig = {
|
||||
enableExternalServices: true,
|
||||
enableCoverAnimation: true,
|
||||
devShowArtistPage: true,
|
||||
devUIShowConfig: true,
|
||||
enableReplayGain: true,
|
||||
defaultDownsamplingFormat: 'opus',
|
||||
publicBaseUrl: '/share',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
278
ui/src/dialogs/aboutUtils.js
Normal file
278
ui/src/dialogs/aboutUtils.js
Normal 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
|
||||
}
|
||||
737
ui/src/dialogs/aboutUtils.test.js
Normal file
737
ui/src/dialogs/aboutUtils.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user