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"
|
"os"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core"
|
"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/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
@@ -70,7 +68,7 @@ func runScanner(ctx context.Context) {
|
|||||||
ds := persistence.New(sqlDB)
|
ds := persistence.New(sqlDB)
|
||||||
pls := core.NewPlaylists(ds)
|
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 {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to scan", err)
|
log.Fatal(ctx, "Failed to scan", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type configOptions struct {
|
|||||||
CoverArtPriority string
|
CoverArtPriority string
|
||||||
CoverJpegQuality int
|
CoverJpegQuality int
|
||||||
ArtistArtPriority string
|
ArtistArtPriority string
|
||||||
|
LyricsPriority string
|
||||||
EnableGravatar bool
|
EnableGravatar bool
|
||||||
EnableFavourites bool
|
EnableFavourites bool
|
||||||
EnableStarRating bool
|
EnableStarRating bool
|
||||||
@@ -86,25 +87,23 @@ type configOptions struct {
|
|||||||
PasswordEncryptionKey string
|
PasswordEncryptionKey string
|
||||||
ReverseProxyUserHeader string
|
ReverseProxyUserHeader string
|
||||||
ReverseProxyWhitelist string
|
ReverseProxyWhitelist string
|
||||||
HTTPSecurityHeaders secureOptions
|
HTTPSecurityHeaders secureOptions `json:",omitzero"`
|
||||||
Prometheus prometheusOptions
|
Prometheus prometheusOptions `json:",omitzero"`
|
||||||
Scanner scannerOptions
|
Scanner scannerOptions `json:",omitzero"`
|
||||||
Jukebox jukeboxOptions
|
Jukebox jukeboxOptions `json:",omitzero"`
|
||||||
Backup backupOptions
|
Backup backupOptions `json:",omitzero"`
|
||||||
PID pidOptions
|
PID pidOptions `json:",omitzero"`
|
||||||
Inspect inspectOptions
|
Inspect inspectOptions `json:",omitzero"`
|
||||||
Subsonic subsonicOptions
|
Subsonic subsonicOptions `json:",omitzero"`
|
||||||
LyricsPriority string
|
LastFM lastfmOptions `json:",omitzero"`
|
||||||
|
Spotify spotifyOptions `json:",omitzero"`
|
||||||
Agents string
|
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
||||||
LastFM lastfmOptions
|
Tags map[string]TagConf `json:",omitempty"`
|
||||||
Spotify spotifyOptions
|
Agents string
|
||||||
ListenBrainz listenBrainzOptions
|
|
||||||
Tags map[string]TagConf
|
|
||||||
|
|
||||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||||
|
DevLogLevels map[string]string `json:",omitempty"`
|
||||||
DevLogSourceLine bool
|
DevLogSourceLine bool
|
||||||
DevLogLevels map[string]string
|
|
||||||
DevEnableProfiler bool
|
DevEnableProfiler bool
|
||||||
DevAutoCreateAdminPassword string
|
DevAutoCreateAdminPassword string
|
||||||
DevAutoLoginUsername string
|
DevAutoLoginUsername string
|
||||||
@@ -112,6 +111,7 @@ type configOptions struct {
|
|||||||
DevActivityPanelUpdateRate time.Duration
|
DevActivityPanelUpdateRate time.Duration
|
||||||
DevSidebarPlaylists bool
|
DevSidebarPlaylists bool
|
||||||
DevShowArtistPage bool
|
DevShowArtistPage bool
|
||||||
|
DevUIShowConfig bool
|
||||||
DevOffsetOptimize int
|
DevOffsetOptimize int
|
||||||
DevArtworkMaxRequests int
|
DevArtworkMaxRequests int
|
||||||
DevArtworkThrottleBacklogLimit int
|
DevArtworkThrottleBacklogLimit int
|
||||||
@@ -145,12 +145,12 @@ type subsonicOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TagConf struct {
|
type TagConf struct {
|
||||||
Ignore bool `yaml:"ignore"`
|
Ignore bool `yaml:"ignore" json:",omitempty"`
|
||||||
Aliases []string `yaml:"aliases"`
|
Aliases []string `yaml:"aliases" json:",omitempty"`
|
||||||
Type string `yaml:"type"`
|
Type string `yaml:"type" json:",omitempty"`
|
||||||
MaxLength int `yaml:"maxLength"`
|
MaxLength int `yaml:"maxLength" json:",omitempty"`
|
||||||
Split []string `yaml:"split"`
|
Split []string `yaml:"split" json:",omitempty"`
|
||||||
Album bool `yaml:"album"`
|
Album bool `yaml:"album" json:",omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type lastfmOptions struct {
|
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("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("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("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("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||||
viper.SetDefault("coverjpegquality", 75)
|
viper.SetDefault("coverjpegquality", 75)
|
||||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||||
@@ -553,6 +553,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
|
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
|
||||||
viper.SetDefault("devsidebarplaylists", true)
|
viper.SetDefault("devsidebarplaylists", true)
|
||||||
viper.SetDefault("devshowartistpage", true)
|
viper.SetDefault("devshowartistpage", true)
|
||||||
|
viper.SetDefault("devuishowconfig", true)
|
||||||
viper.SetDefault("devoffsetoptimize", 50000)
|
viper.SetDefault("devoffsetoptimize", 50000)
|
||||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
|
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
|
||||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ import (
|
|||||||
"github.com/navidrome/navidrome/utils/str"
|
"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 {
|
type artistReader struct {
|
||||||
cacheKey
|
cacheKey
|
||||||
a *artwork
|
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 {
|
func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
|
||||||
return func() (io.ReadCloser, string, error) {
|
return func() (io.ReadCloser, string, error) {
|
||||||
fsys := os.DirFS(artistFolder)
|
current := artistFolder
|
||||||
matches, err := fs.Glob(fsys, pattern)
|
for i := 0; i < maxArtistFolderTraversalDepth; i++ {
|
||||||
if err != nil {
|
if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
|
||||||
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", artistFolder)
|
return reader, path, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
f, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
parent := filepath.Dir(current)
|
||||||
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
|
if parent == current {
|
||||||
return nil, "", err
|
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) {
|
func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) {
|
||||||
if len(albums) == 0 {
|
if len(albums) == 0 {
|
||||||
return "", time.Time{}, nil
|
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)
|
folderPath := str.LongestCommonPrefix(paths)
|
||||||
if !strings.HasSuffix(folderPath, string(filepath.Separator)) {
|
if !strings.HasSuffix(folderPath, string(filepath.Separator)) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package artwork
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"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 {
|
type fakeFolderRepo struct {
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/kballard/go-shellquote"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func start(ctx context.Context, args []string) (Executor, error) {
|
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)
|
log.Debug("Executing mpv command", "cmd", args)
|
||||||
j := Executor{args: args}
|
j := Executor{args: args}
|
||||||
j.PipeReader, j.out = io.Pipe()
|
j.PipeReader, j.out = io.Pipe()
|
||||||
@@ -71,28 +75,32 @@ func (j *Executor) wait() {
|
|||||||
|
|
||||||
// Path will always be an absolute path
|
// Path will always be an absolute path
|
||||||
func createMPVCommand(deviceName string, filename string, socketName string) []string {
|
func createMPVCommand(deviceName string, filename string, socketName string) []string {
|
||||||
split := strings.Split(fixCmd(conf.Server.MPVCmdTemplate), " ")
|
// Parse the template structure using shell parsing to handle quoted arguments
|
||||||
for i, s := range split {
|
templateArgs, err := shellquote.Split(conf.Server.MPVCmdTemplate)
|
||||||
s = strings.ReplaceAll(s, "%d", deviceName)
|
if err != nil {
|
||||||
s = strings.ReplaceAll(s, "%f", filename)
|
log.Error("Failed to parse MPV command template", "template", conf.Server.MPVCmdTemplate, err)
|
||||||
s = strings.ReplaceAll(s, "%s", socketName)
|
return nil
|
||||||
split[i] = s
|
|
||||||
}
|
}
|
||||||
return split
|
|
||||||
}
|
|
||||||
|
|
||||||
func fixCmd(cmd string) string {
|
// Replace placeholders in each parsed argument to preserve spaces in substituted values
|
||||||
split := strings.Split(cmd, " ")
|
for i, arg := range templateArgs {
|
||||||
var result []string
|
arg = strings.ReplaceAll(arg, "%d", deviceName)
|
||||||
cmdPath, _ := mpvCommand()
|
arg = strings.ReplaceAll(arg, "%f", filename)
|
||||||
for _, s := range split {
|
arg = strings.ReplaceAll(arg, "%s", socketName)
|
||||||
if s == "mpv" || s == "mpv.exe" {
|
templateArgs[i] = arg
|
||||||
result = append(result, cmdPath)
|
}
|
||||||
} else {
|
|
||||||
result = append(result, s)
|
// 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.
|
// 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")
|
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)
|
exe, err := start(ctx, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Error starting mpv process", err)
|
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/hashicorp/go-multierror v1.1.1
|
||||||
github.com/jellydator/ttlcache/v3 v3.3.0
|
github.com/jellydator/ttlcache/v3 v3.3.0
|
||||||
github.com/kardianos/service v1.2.2
|
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/kr/pretty v0.3.1
|
||||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||||
@@ -85,7 +86,6 @@ require (
|
|||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap 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/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ type PlaylistRepository interface {
|
|||||||
FindByPath(path string) (*Playlist, error)
|
FindByPath(path string) (*Playlist, error)
|
||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
|
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
|
||||||
|
GetPlaylists(mediaFileId string) (Playlists, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaylistTrack struct {
|
type PlaylistTrack struct {
|
||||||
|
|||||||
@@ -203,6 +203,25 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli
|
|||||||
return playlists, err
|
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 {
|
func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder {
|
||||||
query := r.newSelect(options...).Join("user on user.id = owner_id").
|
query := r.newSelect(options...).Join("user on user.id = owner_id").
|
||||||
Columns(r.tableName+".*", "user.user_name as owner_name")
|
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() {
|
Context("Smart Playlists", func() {
|
||||||
var rules *criteria.Criteria
|
var rules *criteria.Criteria
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
|||||||
@@ -99,10 +99,10 @@ func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
|
|||||||
"playlist_tracks.*",
|
"playlist_tracks.*",
|
||||||
).
|
).
|
||||||
Join("media_file f on f.id = media_file_id").
|
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
|
var trk dbPlaylistTrack
|
||||||
err := r.queryOne(sel, &trk)
|
err := r.queryOne(sel, &trk)
|
||||||
return trk.PlaylistTrack.MediaFile, err
|
return trk.PlaylistTrack, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) {
|
func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
"recentlyPlayed": "Recientes",
|
"recentlyPlayed": "Recientes",
|
||||||
"mostPlayed": "Más reproducidos",
|
"mostPlayed": "Más reproducidos",
|
||||||
"starred": "Favoritos",
|
"starred": "Favoritos",
|
||||||
"topRated": "Los mejores calificados"
|
"topRated": "Mejor calificados"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"artist": {
|
"artist": {
|
||||||
@@ -523,4 +523,4 @@
|
|||||||
"current_song": "Canción actual"
|
"current_song": "Canción actual"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,11 +197,17 @@
|
|||||||
"export": "Exportar",
|
"export": "Exportar",
|
||||||
"makePublic": "Pública",
|
"makePublic": "Pública",
|
||||||
"makePrivate": "Pessoal",
|
"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": {
|
"message": {
|
||||||
"duplicate_song": "Adicionar músicas duplicadas",
|
"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": {
|
"radio": {
|
||||||
@@ -496,6 +502,21 @@
|
|||||||
"disabled": "Desligado",
|
"disabled": "Desligado",
|
||||||
"waiting": "Aguardando"
|
"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": {
|
"activity": {
|
||||||
@@ -523,4 +544,4 @@
|
|||||||
"current_song": "Vai para música atual"
|
"current_song": "Vai para música atual"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,13 +63,12 @@ func (s *controller) getScanner() scanner {
|
|||||||
if conf.Server.DevExternalScanner {
|
if conf.Server.DevExternalScanner {
|
||||||
return &scannerExternal{}
|
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.
|
// CallScan starts an in-process scan of the music library.
|
||||||
// This is meant to be called from the command line (see cmd/scan.go).
|
// 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,
|
func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool) (<-chan *ProgressInfo, error) {
|
||||||
metrics metrics.Metrics, fullScan bool) (<-chan *ProgressInfo, error) {
|
|
||||||
release, err := lockScan(ctx)
|
release, err := lockScan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -80,7 +79,7 @@ func CallScan(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, p
|
|||||||
progress := make(chan *ProgressInfo, 100)
|
progress := make(chan *ProgressInfo, 100)
|
||||||
go func() {
|
go func() {
|
||||||
defer close(progress)
|
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)
|
scanner.scanAll(ctx, fullScan, progress)
|
||||||
}()
|
}()
|
||||||
return progress, nil
|
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
|
// Send the final scan status event, with totals
|
||||||
if count, folderCount, err := s.getCounters(ctx); err != nil {
|
if count, folderCount, err := s.getCounters(ctx); err != nil {
|
||||||
|
s.metrics.WriteAfterScanMetrics(ctx, false)
|
||||||
return scanWarnings, err
|
return scanWarnings, err
|
||||||
} else {
|
} else {
|
||||||
scanType, elapsed, lastErr := s.getScanInfo(ctx)
|
scanType, elapsed, lastErr := s.getScanInfo(ctx)
|
||||||
|
s.metrics.WriteAfterScanMetrics(ctx, true)
|
||||||
s.sendMessage(ctx, &events.ScanStatus{
|
s.sendMessage(ctx, &events.ScanStatus{
|
||||||
Scanning: false,
|
Scanning: false,
|
||||||
Count: count,
|
Count: count,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
@@ -19,10 +18,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type scannerImpl struct {
|
type scannerImpl struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
cw artwork.CacheWarmer
|
cw artwork.CacheWarmer
|
||||||
pls core.Playlists
|
pls core.Playlists
|
||||||
metrics metrics.Metrics
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanState holds the state of an in-progress scan, to be passed to the various phases
|
// 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)
|
log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err)
|
||||||
_ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, err.Error())
|
_ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, err.Error())
|
||||||
state.sendError(err)
|
state.sendError(err)
|
||||||
s.metrics.WriteAfterScanMetrics(ctx, false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +118,6 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
|
|||||||
state.sendProgress(&ProgressInfo{ChangesDetected: true})
|
state.sendProgress(&ProgressInfo{ChangesDetected: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
s.metrics.WriteAfterScanMetrics(ctx, err == nil)
|
|
||||||
log.Info(ctx, "Scanner: Finished scanning all libraries", "duration", time.Since(startTime))
|
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
|
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)
|
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.addPlaylistRoute(r)
|
||||||
n.addPlaylistTrackRoute(r)
|
n.addPlaylistTrackRoute(r)
|
||||||
|
n.addSongPlaylistsRoute(r)
|
||||||
n.addMissingFilesRoute(r)
|
n.addMissingFilesRoute(r)
|
||||||
n.addInspectRoute(r)
|
n.addInspectRoute(r)
|
||||||
|
n.addConfigRoute(r)
|
||||||
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
|
n.addKeepAliveRoute(r)
|
||||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
n.addInsightsRoute(r)
|
||||||
_, _ = 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}`))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
@@ -144,6 +133,9 @@ func (n *Router) addPlaylistTrackRoute(r chi.Router) {
|
|||||||
})
|
})
|
||||||
r.Route("/{id}", func(r chi.Router) {
|
r.Route("/{id}", func(r chi.Router) {
|
||||||
r.Use(server.URLParamsMiddleware)
|
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) {
|
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
reorderItem(n.ds)(w, r)
|
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) {
|
func (n *Router) addMissingFilesRoute(r chi.Router) {
|
||||||
r.Route("/missing", func(r chi.Router) {
|
r.Route("/missing", func(r chi.Router) {
|
||||||
n.RX(r, "/", newMissingRepository(n.ds), false)
|
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 {
|
func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
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,
|
"devSidebarPlaylists": conf.Server.DevSidebarPlaylists,
|
||||||
"lastFMEnabled": conf.Server.LastFM.Enabled,
|
"lastFMEnabled": conf.Server.LastFM.Enabled,
|
||||||
"devShowArtistPage": conf.Server.DevShowArtistPage,
|
"devShowArtistPage": conf.Server.DevShowArtistPage,
|
||||||
|
"devUIShowConfig": conf.Server.DevUIShowConfig,
|
||||||
"listenBrainzEnabled": conf.Server.ListenBrainz.Enabled,
|
"listenBrainzEnabled": conf.Server.ListenBrainz.Enabled,
|
||||||
"enableExternalServices": conf.Server.EnableExternalServices,
|
"enableExternalServices": conf.Server.EnableExternalServices,
|
||||||
"enableReplayGain": conf.Server.EnableReplayGain,
|
"enableReplayGain": conf.Server.EnableReplayGain,
|
||||||
|
|||||||
@@ -304,6 +304,17 @@ var _ = Describe("serveIndex", func() {
|
|||||||
Expect(config).To(HaveKeyWithValue("devShowArtistPage", true))
|
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() {
|
It("sets the listenBrainzEnabled", func() {
|
||||||
conf.Server.ListenBrainz.Enabled = true
|
conf.Server.ListenBrainz.Enabled = true
|
||||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ func (s *Server) initRoutes() {
|
|||||||
clientUniqueIDMiddleware,
|
clientUniqueIDMiddleware,
|
||||||
compressMiddleware(),
|
compressMiddleware(),
|
||||||
loggerInjector,
|
loggerInjector,
|
||||||
jwtVerifier,
|
JWTVerifier,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount the Native API /events endpoint with all default middlewares, adding the authentication middlewares
|
// Mount the Native API /events endpoint with all default middlewares, adding the authentication middlewares
|
||||||
|
|||||||
@@ -4,26 +4,40 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
"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
|
// 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) {
|
func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) {
|
||||||
loggedUser, ok := request.UserFrom(r.Context())
|
loggedUser, ok := request.UserFrom(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, newError(responses.ErrorGeneric, "Internal error")
|
return nil, newError(responses.ErrorGeneric, "Internal error")
|
||||||
}
|
}
|
||||||
|
|
||||||
response := newResponse()
|
response := newResponse()
|
||||||
response.User = &responses.User{}
|
user := buildUserResponse(loggedUser)
|
||||||
response.User.Username = loggedUser.UserName
|
response.User = &user
|
||||||
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
|
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,17 +46,8 @@ func (api *Router) GetUsers(r *http.Request) (*responses.Subsonic, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, newError(responses.ErrorGeneric, "Internal error")
|
return nil, newError(responses.ErrorGeneric, "Internal error")
|
||||||
}
|
}
|
||||||
user := responses.User{}
|
|
||||||
user.Username = loggedUser.Name
|
user := buildUserResponse(loggedUser)
|
||||||
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
|
|
||||||
}
|
|
||||||
response := newResponse()
|
response := newResponse()
|
||||||
response.Users = &responses.Users{User: []responses.User{user}}
|
response.Users = &responses.Users{User: []responses.User{user}}
|
||||||
return response, nil
|
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)
|
return block(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *MockDataStore) Resource(context.Context, any) model.ResourceRepository {
|
func (db *MockDataStore) Resource(ctx context.Context, m any) model.ResourceRepository {
|
||||||
return struct{ 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 {
|
func (db *MockDataStore) GC(context.Context) error {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/deluan/rest"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/id"
|
"github.com/navidrome/navidrome/model/id"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
@@ -76,9 +77,14 @@ func (m *MockMediaFileRepo) GetAll(...model.QueryOptions) (model.MediaFiles, err
|
|||||||
return nil, errors.New("error")
|
return nil, errors.New("error")
|
||||||
}
|
}
|
||||||
values := slices.Collect(maps.Values(m.Data))
|
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
|
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 {
|
func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error {
|
||||||
@@ -196,4 +202,30 @@ func (m *MockMediaFileRepo) DeleteAllMissing() (int64, error) {
|
|||||||
return count, nil
|
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.MediaFileRepository = (*MockMediaFileRepo)(nil)
|
||||||
|
var _ model.ResourceRepository = (*MockMediaFileRepo)(nil)
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ const Admin = (props) => {
|
|||||||
<Resource name="playlistTrack" />,
|
<Resource name="playlistTrack" />,
|
||||||
<Resource name="keepalive" />,
|
<Resource name="keepalive" />,
|
||||||
<Resource name="insights" />,
|
<Resource name="insights" />,
|
||||||
|
permissions === 'admin' && config.devUIShowConfig ? (
|
||||||
|
<Resource name="config" />
|
||||||
|
) : null,
|
||||||
<Player />,
|
<Player />,
|
||||||
]}
|
]}
|
||||||
</RAAdmin>
|
</RAAdmin>
|
||||||
|
|||||||
@@ -38,16 +38,14 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
|
|||||||
const subtitle = song.tags?.['subtitle']
|
const subtitle = song.tags?.['subtitle']
|
||||||
const title = song.title + (subtitle ? ` (${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 (
|
return (
|
||||||
<Link
|
<Link to={linkTo} className={className} ref={dragSongRef}>
|
||||||
to={
|
|
||||||
audioInfo.isRadio
|
|
||||||
? `/radio/${audioInfo.trackId}/show`
|
|
||||||
: `/album/${song.albumId}/show`
|
|
||||||
}
|
|
||||||
className={className}
|
|
||||||
ref={dragSongRef}
|
|
||||||
>
|
|
||||||
<span>
|
<span>
|
||||||
<span className={clsx(classes.songTitle, 'songTitle')}>{title}</span>
|
<span className={clsx(classes.songTitle, 'songTitle')}>{title}</span>
|
||||||
{isDesktop && (
|
{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 PlayerToolbar = ({ id, isRadio }) => {
|
||||||
const dispatch = useDispatch()
|
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 [toggleLove, toggling] = useToggleLove('song', data)
|
||||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
|
|||||||
@@ -38,15 +38,16 @@ export const RatingField = ({
|
|||||||
|
|
||||||
const handleRating = useCallback(
|
const handleRating = useCallback(
|
||||||
(e, val) => {
|
(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 (
|
return (
|
||||||
<span onClick={(e) => stopPropagation(e)}>
|
<span onClick={(e) => stopPropagation(e)}>
|
||||||
<Rating
|
<Rating
|
||||||
name={record.id}
|
name={record.mediaFileId || record.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
className,
|
className,
|
||||||
classes.rating,
|
classes.rating,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { useDispatch } from 'react-redux'
|
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 { IconButton, Menu, MenuItem } from '@material-ui/core'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import MoreVertIcon from '@material-ui/icons/MoreVert'
|
import MoreVertIcon from '@material-ui/icons/MoreVert'
|
||||||
@@ -20,7 +25,7 @@ import {
|
|||||||
import { LoveButton } from './LoveButton'
|
import { LoveButton } from './LoveButton'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
import { formatBytes } from '../utils'
|
import { formatBytes } from '../utils'
|
||||||
import { httpClient } from '../dataProvider'
|
import { useRedirect } from 'react-admin'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
noWrap: {
|
noWrap: {
|
||||||
@@ -57,8 +62,13 @@ export const SongContextMenu = ({
|
|||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
const notify = useNotify()
|
const notify = useNotify()
|
||||||
|
const dataProvider = useDataProvider()
|
||||||
const [anchorEl, setAnchorEl] = useState(null)
|
const [anchorEl, setAnchorEl] = useState(null)
|
||||||
|
const [playlistAnchorEl, setPlaylistAnchorEl] = useState(null)
|
||||||
|
const [playlists, setPlaylists] = useState([])
|
||||||
|
const [playlistsLoaded, setPlaylistsLoaded] = useState(false)
|
||||||
const { permissions } = usePermissions()
|
const { permissions } = usePermissions()
|
||||||
|
const redirect = useRedirect()
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
playNow: {
|
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: {
|
share: {
|
||||||
enabled: config.enableSharing,
|
enabled: config.enableSharing,
|
||||||
label: translate('ra.action.share'),
|
label: translate('ra.action.share'),
|
||||||
@@ -113,8 +132,8 @@ export const SongContextMenu = ({
|
|||||||
if (permissions === 'admin' && !record.missing) {
|
if (permissions === 'admin' && !record.missing) {
|
||||||
try {
|
try {
|
||||||
let id = record.mediaFileId ?? record.id
|
let id = record.mediaFileId ?? record.id
|
||||||
const data = await httpClient(`/api/inspect?id=${id}`)
|
const data = await dataProvider.inspect(id)
|
||||||
fullRecord = { ...record, rawTags: data.json.rawTags }
|
fullRecord = { ...record, rawTags: data.data.rawTags }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify(
|
notify(
|
||||||
translate('ra.notification.http_error') + ': ' + error.message,
|
translate('ra.notification.http_error') + ': ' + error.message,
|
||||||
@@ -134,6 +153,21 @@ export const SongContextMenu = ({
|
|||||||
|
|
||||||
const handleClick = (e) => {
|
const handleClick = (e) => {
|
||||||
setAnchorEl(e.currentTarget)
|
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()
|
e.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,12 +178,39 @@ export const SongContextMenu = ({
|
|||||||
|
|
||||||
const handleItemClick = (e) => {
|
const handleItemClick = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setAnchorEl(null)
|
|
||||||
const key = e.target.getAttribute('value')
|
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()
|
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)
|
const open = Boolean(anchorEl)
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
@@ -170,17 +231,41 @@ export const SongContextMenu = ({
|
|||||||
id={'menu' + record.id}
|
id={'menu' + record.id}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={handleClose}
|
onClose={handleMainMenuClose}
|
||||||
>
|
>
|
||||||
{Object.keys(options).map(
|
{Object.keys(options).map(
|
||||||
(key) =>
|
(key) =>
|
||||||
options[key].enabled && (
|
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}
|
{options[key].label}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</Menu>
|
</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>
|
</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>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
hidden={tab == 1}
|
hidden={tab === 1}
|
||||||
id="mapped-tags-body"
|
id="mapped-tags-body"
|
||||||
aria-labelledby={record.rawTags ? 'mapped-tags-tab' : undefined}
|
aria-labelledby={record.rawTags ? 'mapped-tags-tab' : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,18 +17,42 @@ export const useRating = (resource, record) => {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refreshRating = useCallback(() => {
|
const refreshRating = useCallback(() => {
|
||||||
dataProvider
|
// For playlist tracks, refresh both resources to keep data in sync
|
||||||
.getOne(resource, { id: record.id })
|
if (record.mediaFileId) {
|
||||||
.then(() => {
|
// This is a playlist track - refresh both the playlist track and the song
|
||||||
if (mountedRef.current) {
|
const promises = [
|
||||||
setLoading(false)
|
dataProvider.getOne('song', { id: record.mediaFileId }),
|
||||||
}
|
dataProvider.getOne('playlistTrack', {
|
||||||
})
|
id: record.id,
|
||||||
.catch((e) => {
|
filter: { playlist_id: record.playlistId },
|
||||||
// eslint-disable-next-line no-console
|
}),
|
||||||
console.log('Error encountered: ' + e)
|
]
|
||||||
})
|
|
||||||
}, [dataProvider, record, resource])
|
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) => {
|
const rate = (val, id) => {
|
||||||
setLoading(true)
|
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 dataProvider = useDataProvider()
|
||||||
|
|
||||||
const refreshRecord = useCallback(() => {
|
const refreshRecord = useCallback(() => {
|
||||||
dataProvider.getOne(resource, { id: record.id }).then(() => {
|
const promises = []
|
||||||
if (mountedRef.current) {
|
|
||||||
setLoading(false)
|
// Always refresh the original resource
|
||||||
}
|
const params = { id: record.id }
|
||||||
})
|
if (record.playlistId) {
|
||||||
}, [dataProvider, record.id, resource])
|
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 toggleLove = () => {
|
||||||
const toggle = record.starred ? subsonic.unstar : subsonic.star
|
const toggle = record.starred ? subsonic.unstar : subsonic.star
|
||||||
|
const id = record.mediaFileId || record.id
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
toggle(record.id)
|
toggle(id)
|
||||||
.then(refreshRecord)
|
.then(refreshRecord)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
// eslint-disable-next-line no-console
|
// 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,
|
enableExternalServices: true,
|
||||||
enableCoverAnimation: true,
|
enableCoverAnimation: true,
|
||||||
devShowArtistPage: true,
|
devShowArtistPage: true,
|
||||||
|
devUIShowConfig: true,
|
||||||
enableReplayGain: true,
|
enableReplayGain: true,
|
||||||
defaultDownsamplingFormat: 'opus',
|
defaultDownsamplingFormat: 'opus',
|
||||||
publicBaseUrl: '/share',
|
publicBaseUrl: '/share',
|
||||||
|
|||||||
@@ -90,6 +90,16 @@ const wrapperDataProvider = {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}).then(({ json }) => ({ data: json }))
|
}).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
|
export default wrapperDataProvider
|
||||||
|
|||||||
@@ -10,14 +10,63 @@ import TableRow from '@material-ui/core/TableRow'
|
|||||||
import TableCell from '@material-ui/core/TableCell'
|
import TableCell from '@material-ui/core/TableCell'
|
||||||
import Paper from '@material-ui/core/Paper'
|
import Paper from '@material-ui/core/Paper'
|
||||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
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 { 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 config from '../config'
|
||||||
import { DialogTitle } from './DialogTitle'
|
import { DialogTitle } from './DialogTitle'
|
||||||
import { DialogContent } from './DialogContent'
|
import { DialogContent } from './DialogContent'
|
||||||
import { INSIGHTS_DOC_URL } from '../consts.js'
|
import { INSIGHTS_DOC_URL } from '../consts.js'
|
||||||
import subsonic from '../subsonic/index.js'
|
import subsonic from '../subsonic/index.js'
|
||||||
import { Typography } from '@material-ui/core'
|
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 = {
|
const links = {
|
||||||
homepage: 'navidrome.org',
|
homepage: 'navidrome.org',
|
||||||
@@ -54,7 +103,6 @@ const LinkToVersion = ({ version }) => {
|
|||||||
|
|
||||||
const ShowVersion = ({ uiVersion, serverVersion }) => {
|
const ShowVersion = ({ uiVersion, serverVersion }) => {
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
|
|
||||||
const showRefresh = uiVersion !== serverVersion
|
const showRefresh = uiVersion !== serverVersion
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,12 +121,16 @@ const ShowVersion = ({ uiVersion, serverVersion }) => {
|
|||||||
UI {translate('menu.version')}:
|
UI {translate('menu.version')}:
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="left">
|
<TableCell align="left">
|
||||||
<LinkToVersion version={uiVersion} />
|
<div>
|
||||||
<Link onClick={() => window.location.reload()}>
|
<LinkToVersion version={uiVersion} />
|
||||||
<Typography variant={'caption'}>
|
</div>
|
||||||
{' ' + translate('ra.notification.new_version')}
|
<div>
|
||||||
</Typography>
|
<Link onClick={() => window.location.reload()}>
|
||||||
</Link>
|
<Typography variant={'caption'}>
|
||||||
|
{translate('ra.notification.new_version')}
|
||||||
|
</Typography>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@@ -86,11 +138,286 @@ const ShowVersion = ({ uiVersion, serverVersion }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AboutDialog = ({ open, onClose }) => {
|
const AboutTabContent = ({
|
||||||
|
uiVersion,
|
||||||
|
serverVersion,
|
||||||
|
insightsData,
|
||||||
|
loading,
|
||||||
|
permissions,
|
||||||
|
}) => {
|
||||||
const translate = useTranslate()
|
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 { permissions } = usePermissions()
|
||||||
const { data, loading } = useGetOne('insights', 'insights_status')
|
const { data: insightsData, loading } = useGetOne(
|
||||||
|
'insights',
|
||||||
|
'insights_status',
|
||||||
|
)
|
||||||
const [serverVersion, setServerVersion] = useState('')
|
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
|
const uiVersion = config.version
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -108,85 +435,30 @@ const AboutDialog = ({ open, onClose }) => {
|
|||||||
})
|
})
|
||||||
}, [setServerVersion])
|
}, [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 (
|
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}>
|
<DialogTitle id="about-dialog-title" onClose={onClose}>
|
||||||
Navidrome Music Server
|
Navidrome Music Server
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<TableContainer component={Paper}>
|
<TabContent
|
||||||
<Table aria-label={translate('menu.about')} size="small">
|
tab={tab}
|
||||||
<TableBody>
|
setTab={setTab}
|
||||||
<ShowVersion
|
showConfigTab={showConfigTab}
|
||||||
uiVersion={uiVersion}
|
uiVersion={uiVersion}
|
||||||
serverVersion={serverVersion}
|
serverVersion={serverVersion}
|
||||||
/>
|
insightsData={insightsData}
|
||||||
{Object.keys(links).map((key) => {
|
loading={loading}
|
||||||
return (
|
permissions={permissions}
|
||||||
<TableRow key={key}>
|
configData={configData}
|
||||||
<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>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
makeStyles,
|
||||||
} from '@material-ui/core'
|
} from '@material-ui/core'
|
||||||
import {
|
import {
|
||||||
closeAddToPlaylist,
|
closeAddToPlaylist,
|
||||||
@@ -23,7 +24,21 @@ import DuplicateSongDialog from './DuplicateSongDialog'
|
|||||||
import { httpClient } from '../dataProvider'
|
import { httpClient } from '../dataProvider'
|
||||||
import { REST_URL } from '../consts'
|
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 = () => {
|
export const AddToPlaylistDialog = () => {
|
||||||
|
const classes = useStyles()
|
||||||
const { open, selectedIds, onSuccess, duplicateSong, duplicateIds } =
|
const { open, selectedIds, onSuccess, duplicateSong, duplicateIds } =
|
||||||
useSelector((state) => state.addToPlaylistDialog)
|
useSelector((state) => state.addToPlaylistDialog)
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
@@ -145,11 +160,14 @@ export const AddToPlaylistDialog = () => {
|
|||||||
aria-labelledby="form-dialog-new-playlist"
|
aria-labelledby="form-dialog-new-playlist"
|
||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
maxWidth={'sm'}
|
maxWidth={'sm'}
|
||||||
|
classes={{
|
||||||
|
paper: classes.dialogPaper,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle id="form-dialog-new-playlist">
|
<DialogTitle id="form-dialog-new-playlist">
|
||||||
{translate('resources.playlist.actions.selectPlaylist')}
|
{translate('resources.playlist.actions.selectPlaylist')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent className={classes.dialogContent}>
|
||||||
<SelectPlaylistInput onChange={handleChange} />
|
<SelectPlaylistInput onChange={handleChange} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|||||||
@@ -88,12 +88,18 @@ describe('AddToPlaylistDialog', () => {
|
|||||||
|
|
||||||
createTestUtils(mockDataProvider)
|
createTestUtils(mockDataProvider)
|
||||||
|
|
||||||
|
// Filter to see sample playlists
|
||||||
let textBox = screen.getByRole('textbox')
|
let textBox = screen.getByRole('textbox')
|
||||||
fireEvent.change(textBox, { target: { value: 'sample' } })
|
fireEvent.change(textBox, { target: { value: 'sample' } })
|
||||||
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
|
|
||||||
fireEvent.keyDown(textBox, { key: 'Enter' })
|
// Click on first playlist
|
||||||
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
|
const firstPlaylist = screen.getByText('sample playlist 1')
|
||||||
fireEvent.keyDown(textBox, { key: 'Enter' })
|
fireEvent.click(firstPlaylist)
|
||||||
|
|
||||||
|
// Click on second playlist
|
||||||
|
const secondPlaylist = screen.getByText('sample playlist 2')
|
||||||
|
fireEvent.click(secondPlaylist)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('playlist-add')).not.toBeDisabled()
|
expect(screen.getByTestId('playlist-add')).not.toBeDisabled()
|
||||||
})
|
})
|
||||||
@@ -133,12 +139,11 @@ describe('AddToPlaylistDialog', () => {
|
|||||||
|
|
||||||
createTestUtils(mockDataProvider)
|
createTestUtils(mockDataProvider)
|
||||||
|
|
||||||
|
// Type a new playlist name and press Enter to create it
|
||||||
let textBox = screen.getByRole('textbox')
|
let textBox = screen.getByRole('textbox')
|
||||||
fireEvent.change(textBox, { target: { value: 'sample' } })
|
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' })
|
fireEvent.keyDown(textBox, { key: 'Enter' })
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('playlist-add')).not.toBeDisabled()
|
expect(screen.getByTestId('playlist-add')).not.toBeDisabled()
|
||||||
})
|
})
|
||||||
@@ -171,14 +176,15 @@ describe('AddToPlaylistDialog', () => {
|
|||||||
|
|
||||||
createTestUtils(mockDataProvider)
|
createTestUtils(mockDataProvider)
|
||||||
|
|
||||||
|
// Create first playlist
|
||||||
let textBox = screen.getByRole('textbox')
|
let textBox = screen.getByRole('textbox')
|
||||||
fireEvent.change(textBox, { target: { value: 'sample' } })
|
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' })
|
fireEvent.keyDown(textBox, { key: 'Enter' })
|
||||||
|
|
||||||
|
// Create second playlist
|
||||||
fireEvent.change(textBox, { target: { value: 'new playlist' } })
|
fireEvent.change(textBox, { target: { value: 'new playlist' } })
|
||||||
fireEvent.keyDown(textBox, { key: 'Enter' })
|
fireEvent.keyDown(textBox, { key: 'Enter' })
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('playlist-add')).not.toBeDisabled()
|
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 TextField from '@material-ui/core/TextField'
|
||||||
import Checkbox from '@material-ui/core/Checkbox'
|
import Checkbox from '@material-ui/core/Checkbox'
|
||||||
import CheckBoxIcon from '@material-ui/icons/CheckBox'
|
import CheckBoxIcon from '@material-ui/icons/CheckBox'
|
||||||
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
|
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
|
||||||
import Autocomplete, {
|
import {
|
||||||
createFilterOptions,
|
List,
|
||||||
} from '@material-ui/lab/Autocomplete'
|
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 { useGetList, useTranslate } from 'react-admin'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { isWritable } from '../common'
|
import { isWritable } from '../common'
|
||||||
import { makeStyles } from '@material-ui/core'
|
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({
|
const PlaylistSearchField = ({
|
||||||
root: { width: '100%' },
|
searchText,
|
||||||
checkbox: { marginRight: 8 },
|
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 }) => {
|
export const SelectPlaylistInput = ({ onChange }) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const translate = useTranslate()
|
const [searchText, setSearchText] = useState('')
|
||||||
|
const [selectedPlaylists, setSelectedPlaylists] = useState([])
|
||||||
|
|
||||||
const { ids, data } = useGetList(
|
const { ids, data } = useGetList(
|
||||||
'playlist',
|
'playlist',
|
||||||
{ page: 1, perPage: -1 },
|
{ page: 1, perPage: -1 },
|
||||||
@@ -32,92 +271,131 @@ export const SelectPlaylistInput = ({ onChange }) => {
|
|||||||
ids &&
|
ids &&
|
||||||
ids.map((id) => data[id]).filter((option) => isWritable(option.ownerId))
|
ids.map((id) => data[id]).filter((option) => isWritable(option.ownerId))
|
||||||
|
|
||||||
const handleOnChange = (event, newValue) => {
|
// Filter playlists based on search text
|
||||||
let newState = []
|
const filteredOptions =
|
||||||
if (newValue && newValue.length) {
|
options?.filter((option) =>
|
||||||
newValue.forEach((playlistObject) => {
|
option.name.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
if (playlistObject.inputValue) {
|
) || []
|
||||||
newState.push({
|
|
||||||
name: playlistObject.inputValue,
|
const handlePlaylistToggle = (playlist) => {
|
||||||
})
|
const isSelected = selectedPlaylists.some((p) => p.id === playlist.id)
|
||||||
} else if (typeof playlistObject === 'string') {
|
let newSelection
|
||||||
newState.push({
|
|
||||||
name: playlistObject,
|
if (isSelected) {
|
||||||
})
|
newSelection = selectedPlaylists.filter((p) => p.id !== playlist.id)
|
||||||
} else {
|
} else {
|
||||||
newState.push(playlistObject)
|
newSelection = [...selectedPlaylists, playlist]
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
onChange(newState)
|
|
||||||
|
setSelectedPlaylists(newSelection)
|
||||||
|
onChange(newSelection)
|
||||||
}
|
}
|
||||||
|
|
||||||
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />
|
const handleRemoveSelected = (playlistToRemove) => {
|
||||||
const checkedIcon = <CheckBoxIcon fontSize="small" />
|
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 (
|
return (
|
||||||
<Autocomplete
|
<div className={classes.root}>
|
||||||
multiple
|
<PlaylistSearchField
|
||||||
disableCloseOnSelect
|
searchText={searchText}
|
||||||
onChange={handleOnChange}
|
onSearchChange={setSearchText}
|
||||||
filterOptions={(options, params) => {
|
onCreateNew={handleCreateNew}
|
||||||
const filtered = filter(options, params)
|
onKeyDown={handleKeyDown}
|
||||||
|
canCreateNew={canCreateNew}
|
||||||
|
/>
|
||||||
|
|
||||||
// Suggest the creation of a new value
|
<PlaylistList
|
||||||
if (params.inputValue !== '') {
|
filteredOptions={filteredOptions}
|
||||||
filtered.push({
|
selectedPlaylists={selectedPlaylists}
|
||||||
inputValue: params.inputValue,
|
onPlaylistToggle={handlePlaylistToggle}
|
||||||
name: translate('resources.playlist.actions.addNewPlaylist', {
|
searchText={searchText}
|
||||||
name: params.inputValue,
|
canCreateNew={canCreateNew}
|
||||||
}),
|
onCreateNew={handleCreateNew}
|
||||||
})
|
/>
|
||||||
}
|
|
||||||
|
|
||||||
return filtered
|
<SelectedPlaylistsDisplay
|
||||||
}}
|
selectedPlaylists={selectedPlaylists}
|
||||||
clearOnBlur
|
onRemoveSelected={handleRemoveSelected}
|
||||||
handleHomeEndKeys
|
/>
|
||||||
openOnFocus
|
</div>
|
||||||
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')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectPlaylistInput.propTypes = {
|
SelectPlaylistInput.propTypes = {
|
||||||
onChange: PropTypes.func.isRequired,
|
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 { SelectPlaylistInput } from './SelectPlaylistInput'
|
||||||
import { describe, beforeAll, afterEach, it, expect, vi } from 'vitest'
|
import { describe, beforeAll, afterEach, it, expect, vi } from 'vitest'
|
||||||
|
|
||||||
describe('SelectPlaylistInput', () => {
|
const mockPlaylists = [
|
||||||
beforeAll(() => localStorage.setItem('userId', 'admin'))
|
{ id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
|
||||||
afterEach(cleanup)
|
{ id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' },
|
||||||
const onChangeHandler = vi.fn()
|
{ 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 mockIndexedData = {
|
||||||
const mockData = [
|
'playlist-1': { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' },
|
||||||
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
|
'playlist-2': { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' },
|
||||||
{ id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
|
'playlist-3': {
|
||||||
]
|
id: 'playlist-3',
|
||||||
const mockIndexedData = {
|
name: 'Electronic Beats',
|
||||||
'sample-id1': {
|
ownerId: 'admin',
|
||||||
id: 'sample-id1',
|
},
|
||||||
name: 'sample playlist 1',
|
'playlist-4': { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' },
|
||||||
ownerId: 'admin',
|
}
|
||||||
},
|
|
||||||
'sample-id2': {
|
|
||||||
id: 'sample-id2',
|
|
||||||
name: 'sample playlist 2',
|
|
||||||
ownerId: 'admin',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockDataProvider = {
|
const createTestComponent = (
|
||||||
getList: vi
|
mockDataProvider = null,
|
||||||
.fn()
|
onChangeMock = vi.fn(),
|
||||||
.mockResolvedValue({ data: mockData, total: mockData.length }),
|
playlists = mockPlaylists,
|
||||||
}
|
indexedData = mockIndexedData,
|
||||||
|
) => {
|
||||||
|
const dataProvider = mockDataProvider || {
|
||||||
|
getList: vi.fn().mockResolvedValue({
|
||||||
|
data: playlists,
|
||||||
|
total: playlists.length,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
render(
|
return render(
|
||||||
<DataProviderContext.Provider value={mockDataProvider}>
|
<DataProviderContext.Provider value={dataProvider}>
|
||||||
<TestContext
|
<TestContext
|
||||||
initialState={{
|
initialState={{
|
||||||
addToPlaylistDialog: { open: true, duplicateSong: false },
|
admin: {
|
||||||
admin: {
|
ui: { optimistic: false },
|
||||||
ui: { optimistic: false },
|
resources: {
|
||||||
resources: {
|
playlist: {
|
||||||
playlist: {
|
data: indexedData,
|
||||||
data: mockIndexedData,
|
list: {
|
||||||
list: {
|
cachedRequests: {
|
||||||
cachedRequests: {
|
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}':
|
||||||
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}':
|
{
|
||||||
{
|
ids: Object.keys(indexedData),
|
||||||
ids: ['sample-id1', 'sample-id2'],
|
total: Object.keys(indexedData).length,
|
||||||
total: 2,
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
},
|
||||||
>
|
}}
|
||||||
<SelectPlaylistInput onChange={onChangeHandler} />
|
>
|
||||||
</TestContext>
|
<SelectPlaylistInput onChange={onChangeMock} />
|
||||||
</DataProviderContext.Provider>,
|
</TestContext>
|
||||||
)
|
</DataProviderContext.Provider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
await waitFor(() => {
|
describe('SelectPlaylistInput', () => {
|
||||||
expect(mockDataProvider.getList).toHaveBeenCalledWith('playlist', {
|
beforeAll(() => localStorage.setItem('userId', 'admin'))
|
||||||
filter: { smart: false },
|
afterEach(cleanup)
|
||||||
pagination: { page: 1, perPage: -1 },
|
|
||||||
sort: { field: 'name', order: 'ASC' },
|
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')
|
it('should handle case-insensitive search', async () => {
|
||||||
fireEvent.change(textBox, { target: { value: 'sample' } })
|
const onChangeMock = vi.fn()
|
||||||
fireEvent.keyDown(textBox, { key: 'ArrowDown' })
|
createTestComponent(null, onChangeMock)
|
||||||
fireEvent.keyDown(textBox, { key: 'Enter' })
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onChangeHandler).toHaveBeenCalledWith([
|
expect(screen.getByText('Jazz Collection')).toBeInTheDocument()
|
||||||
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
|
})
|
||||||
])
|
|
||||||
|
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' })
|
it('should show selected playlists as chips', async () => {
|
||||||
fireEvent.keyDown(textBox, { key: 'Enter' })
|
const onChangeMock = vi.fn()
|
||||||
await waitFor(() => {
|
createTestComponent(null, onChangeMock)
|
||||||
expect(onChangeHandler).toHaveBeenCalledWith([
|
|
||||||
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
|
await waitFor(() => {
|
||||||
{ id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
|
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, {
|
it('should remove selected playlists via chip remove button', async () => {
|
||||||
target: { value: 'new playlist' },
|
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(() => {
|
describe('Create New Playlist', () => {
|
||||||
expect(onChangeHandler).toHaveBeenCalledWith([
|
it('should create new playlist by pressing Enter', async () => {
|
||||||
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
|
const onChangeMock = vi.fn()
|
||||||
{ id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
|
createTestComponent(null, onChangeMock)
|
||||||
{ name: 'new playlist' },
|
|
||||||
])
|
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, {
|
it('should create new playlist by clicking add button', async () => {
|
||||||
target: { value: 'another new playlist' },
|
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(() => {
|
it('should not show create option for existing playlist names', async () => {
|
||||||
expect(onChangeHandler).toHaveBeenCalledWith([
|
const onChangeMock = vi.fn()
|
||||||
{ id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
|
createTestComponent(null, onChangeMock)
|
||||||
{ id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
|
|
||||||
{ name: 'new playlist' },
|
await waitFor(() => {
|
||||||
{ name: 'another new playlist' },
|
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",
|
"addToQueue": "Play Later",
|
||||||
"playNow": "Play Now",
|
"playNow": "Play Now",
|
||||||
"addToPlaylist": "Add to Playlist",
|
"addToPlaylist": "Add to Playlist",
|
||||||
|
"showInPlaylist": "Show in Playlist",
|
||||||
"shuffleAll": "Shuffle All",
|
"shuffleAll": "Shuffle All",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"playNext": "Play Next",
|
"playNext": "Play Next",
|
||||||
@@ -197,11 +198,17 @@
|
|||||||
"export": "Export",
|
"export": "Export",
|
||||||
"saveQueue": "Save Queue to Playlist",
|
"saveQueue": "Save Queue to Playlist",
|
||||||
"makePublic": "Make Public",
|
"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": {
|
"message": {
|
||||||
"duplicate_song": "Add duplicated songs",
|
"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": {
|
"radio": {
|
||||||
@@ -498,6 +505,21 @@
|
|||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"waiting": "Waiting"
|
"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": {
|
"activity": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo } from 'react'
|
import React, { useCallback, useEffect, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
BulkActionsToolbar,
|
BulkActionsToolbar,
|
||||||
ListToolbar,
|
ListToolbar,
|
||||||
@@ -26,11 +26,13 @@ import {
|
|||||||
useResourceRefresh,
|
useResourceRefresh,
|
||||||
DateField,
|
DateField,
|
||||||
ArtistLinkField,
|
ArtistLinkField,
|
||||||
|
RatingField,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
import { AlbumLinkField } from '../song/AlbumLinkField'
|
import { AlbumLinkField } from '../song/AlbumLinkField'
|
||||||
import { playTracks } from '../actions'
|
import { playTracks } from '../actions'
|
||||||
import PlaylistSongBulkActions from './PlaylistSongBulkActions'
|
import PlaylistSongBulkActions from './PlaylistSongBulkActions'
|
||||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||||
|
import config from '../config'
|
||||||
|
|
||||||
const useStyles = makeStyles(
|
const useStyles = makeStyles(
|
||||||
(theme) => ({
|
(theme) => ({
|
||||||
@@ -66,11 +68,17 @@ const useStyles = makeStyles(
|
|||||||
'& $contextMenu': {
|
'& $contextMenu': {
|
||||||
visibility: 'visible',
|
visibility: 'visible',
|
||||||
},
|
},
|
||||||
|
'& $ratingField': {
|
||||||
|
visibility: 'visible',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
contextMenu: {
|
contextMenu: {
|
||||||
visibility: (props) => (props.isDesktop ? 'hidden' : 'visible'),
|
visibility: (props) => (props.isDesktop ? 'hidden' : 'visible'),
|
||||||
},
|
},
|
||||||
|
ratingField: {
|
||||||
|
visibility: 'hidden',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{ name: 'RaList' },
|
{ name: 'RaList' },
|
||||||
)
|
)
|
||||||
@@ -84,7 +92,8 @@ const ReorderableList = ({ readOnly, children, ...rest }) => {
|
|||||||
|
|
||||||
const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
|
const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
|
||||||
const listContext = useListContext()
|
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 isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||||
const classes = useStyles({ isDesktop })
|
const classes = useStyles({ isDesktop })
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
@@ -93,6 +102,11 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
|
|||||||
const version = useVersion()
|
const version = useVersion()
|
||||||
useResourceRefresh('song', 'playlist')
|
useResourceRefresh('song', 'playlist')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1)
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}, [playlistId, setPage])
|
||||||
|
|
||||||
const onAddToPlaylist = useCallback(
|
const onAddToPlaylist = useCallback(
|
||||||
(pls) => {
|
(pls) => {
|
||||||
if (pls.id === playlistId) {
|
if (pls.id === playlistId) {
|
||||||
@@ -155,8 +169,16 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
|
|||||||
quality: isDesktop && <QualityInfo source="quality" sortable={false} />,
|
quality: isDesktop && <QualityInfo source="quality" sortable={false} />,
|
||||||
channels: isDesktop && <NumberField source="channels" />,
|
channels: isDesktop && <NumberField source="channels" />,
|
||||||
bpm: isDesktop && <NumberField source="bpm" />,
|
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({
|
const columns = useSelectedFields({
|
||||||
resource: 'playlistTrack',
|
resource: 'playlistTrack',
|
||||||
@@ -168,6 +190,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
|
|||||||
'playCount',
|
'playCount',
|
||||||
'playDate',
|
'playDate',
|
||||||
'albumArtist',
|
'albumArtist',
|
||||||
|
'rating',
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -207,7 +230,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
|
|||||||
{columns}
|
{columns}
|
||||||
<SongContextMenu
|
<SongContextMenu
|
||||||
onAddToPlaylist={onAddToPlaylist}
|
onAddToPlaylist={onAddToPlaylist}
|
||||||
showLove={false}
|
showLove={true}
|
||||||
className={classes.contextMenu}
|
className={classes.contextMenu}
|
||||||
/>
|
/>
|
||||||
</SongDatagrid>
|
</SongDatagrid>
|
||||||
|
|||||||
Reference in New Issue
Block a user