mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
18 Commits
f4d0fa836e
...
3fb9e2f32b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fb9e2f32b | ||
|
|
131c0c565c | ||
|
|
53ff33866d | ||
|
|
508670ecfb | ||
|
|
c369224597 | ||
|
|
52d8cf4f76 | ||
|
|
15de641bea | ||
|
|
b4b618eb0c | ||
|
|
f8c430586d | ||
|
|
b0cc0a2f40 | ||
|
|
a875606c28 | ||
|
|
7cd4a482c2 | ||
|
|
5c4df0912e | ||
|
|
9a50079978 | ||
|
|
5925e8dbd8 | ||
|
|
3761503d7a | ||
|
|
66d4a75c8b | ||
|
|
6a3fef8e3b |
@ -90,6 +90,7 @@ var _ = Describe("CacheWarmer", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("deduplicates items in buffer", func() {
|
It("deduplicates items in buffer", func() {
|
||||||
|
fc.SetReady(false) // Make cache unavailable so items stay in buffer
|
||||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
@ -160,6 +161,13 @@ var staticData = sync.OnceValue(func() insights.Data {
|
|||||||
data.Build.Settings, data.Build.GoVersion = buildInfo()
|
data.Build.Settings, data.Build.GoVersion = buildInfo()
|
||||||
data.OS.Containerized = consts.InContainer
|
data.OS.Containerized = consts.InContainer
|
||||||
|
|
||||||
|
// Install info
|
||||||
|
packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
|
||||||
|
packageFileData, err := os.ReadFile(packageFilename)
|
||||||
|
if err == nil {
|
||||||
|
data.OS.Package = string(packageFileData)
|
||||||
|
}
|
||||||
|
|
||||||
// OS info
|
// OS info
|
||||||
data.OS.Type = runtime.GOOS
|
data.OS.Type = runtime.GOOS
|
||||||
data.OS.Arch = runtime.GOARCH
|
data.OS.Arch = runtime.GOARCH
|
||||||
|
|||||||
@ -16,6 +16,7 @@ type Data struct {
|
|||||||
Containerized bool `json:"containerized"`
|
Containerized bool `json:"containerized"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
NumCPU int `json:"numCPU"`
|
NumCPU int `json:"numCPU"`
|
||||||
|
Package string `json:"package,omitempty"`
|
||||||
} `json:"os"`
|
} `json:"os"`
|
||||||
Mem struct {
|
Mem struct {
|
||||||
Alloc uint64 `json:"alloc"`
|
Alloc uint64 `json:"alloc"`
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@ -9,7 +10,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -194,22 +195,31 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
|||||||
}
|
}
|
||||||
filteredLines = append(filteredLines, line)
|
filteredLines = append(filteredLines, line)
|
||||||
}
|
}
|
||||||
paths, err := s.normalizePaths(ctx, pls, folder, filteredLines)
|
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn(ctx, "Error normalizing paths in playlist", "playlist", pls.Name, err)
|
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
found, err := mediaFileRepository.FindByPaths(paths)
|
|
||||||
|
found, err := mediaFileRepository.FindByPaths(resolvedPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
|
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Build lookup map with library-qualified keys
|
||||||
existing := make(map[string]int, len(found))
|
existing := make(map[string]int, len(found))
|
||||||
for idx := range found {
|
for idx := range found {
|
||||||
existing[normalizePathForComparison(found[idx].Path)] = idx
|
// Key format: "libraryID:path" (normalized)
|
||||||
|
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, normalizePathForComparison(found[idx].Path))
|
||||||
|
existing[key] = idx
|
||||||
}
|
}
|
||||||
for _, path := range paths {
|
for _, path := range resolvedPaths {
|
||||||
idx, ok := existing[normalizePathForComparison(path)]
|
// Parse the library-qualified path
|
||||||
|
parts := strings.SplitN(path, ":", 2)
|
||||||
|
// Path is already qualified: "libraryID:path"
|
||||||
|
normalizedPath := parts[0] + ":" + normalizePathForComparison(parts[1])
|
||||||
|
|
||||||
|
idx, ok := existing[normalizedPath]
|
||||||
if ok {
|
if ok {
|
||||||
mfs = append(mfs, found[idx])
|
mfs = append(mfs, found[idx])
|
||||||
} else {
|
} else {
|
||||||
@ -233,62 +243,150 @@ func normalizePathForComparison(path string) string {
|
|||||||
return strings.ToLower(norm.NFC.String(path))
|
return strings.ToLower(norm.NFC.String(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO This won't work for multiple libraries
|
// pathResolution holds the result of resolving a playlist path to a library-relative path.
|
||||||
func (s *playlists) normalizePaths(ctx context.Context, pls *model.Playlist, folder *model.Folder, lines []string) ([]string, error) {
|
type pathResolution struct {
|
||||||
libRegex, err := s.compileLibraryPaths(ctx)
|
absolutePath string
|
||||||
if err != nil {
|
libraryPath string
|
||||||
return nil, err
|
libraryID int
|
||||||
}
|
valid bool
|
||||||
|
|
||||||
res := make([]string, 0, len(lines))
|
|
||||||
for idx, line := range lines {
|
|
||||||
var libPath string
|
|
||||||
var filePath string
|
|
||||||
|
|
||||||
if folder != nil && !filepath.IsAbs(line) {
|
|
||||||
libPath = folder.LibraryPath
|
|
||||||
filePath = filepath.Join(folder.AbsolutePath(), line)
|
|
||||||
} else {
|
|
||||||
cleanLine := filepath.Clean(line)
|
|
||||||
if libPath = libRegex.FindString(cleanLine); libPath != "" {
|
|
||||||
filePath = cleanLine
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if libPath != "" {
|
|
||||||
if rel, err := filepath.Rel(libPath, filePath); err == nil {
|
|
||||||
res = append(res, rel)
|
|
||||||
} else {
|
|
||||||
log.Debug(ctx, "Error getting relative path", "playlist", pls.Name, "path", line, "libPath", libPath,
|
|
||||||
"filePath", filePath, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slice.Map(res, filepath.ToSlash), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *playlists) compileLibraryPaths(ctx context.Context) (*regexp.Regexp, error) {
|
// ToQualifiedString converts the path resolution to a library-qualified string with forward slashes.
|
||||||
libs, err := s.ds.Library(ctx).GetAll()
|
// Format: "libraryID:relativePath" with forward slashes for path separators.
|
||||||
|
func (r pathResolution) ToQualifiedString() (string, error) {
|
||||||
|
if !r.valid {
|
||||||
|
return "", fmt.Errorf("invalid path resolution")
|
||||||
|
}
|
||||||
|
relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Convert path separators to forward slashes
|
||||||
|
return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching.
|
||||||
|
type libraryMatcher struct {
|
||||||
|
libraries model.Libraries
|
||||||
|
cleanedPaths []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLibraryForPath finds which library contains the given absolute path.
|
||||||
|
// Returns library ID and path, or 0 and empty string if not found.
|
||||||
|
func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) {
|
||||||
|
// Check sorted libraries (longest path first) to find the best match
|
||||||
|
for i, cleanLibPath := range lm.cleanedPaths {
|
||||||
|
// Check if absolutePath is under this library path
|
||||||
|
if strings.HasPrefix(absolutePath, cleanLibPath) {
|
||||||
|
// Ensure it's a proper path boundary (not just a prefix)
|
||||||
|
if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator {
|
||||||
|
return lm.libraries[i].ID, cleanLibPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first).
|
||||||
|
// This ensures correct matching when library paths are prefixes of each other.
|
||||||
|
// Example: /music-classical must be checked before /music
|
||||||
|
// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical
|
||||||
|
func newLibraryMatcher(libs model.Libraries) *libraryMatcher {
|
||||||
|
// Sort libraries by path length (descending) to ensure longest paths match first.
|
||||||
|
slices.SortFunc(libs, func(i, j model.Library) int {
|
||||||
|
return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pre-clean all library paths once for efficient matching
|
||||||
|
cleanedPaths := make([]string, len(libs))
|
||||||
|
for i, lib := range libs {
|
||||||
|
cleanedPaths[i] = filepath.Clean(lib.Path)
|
||||||
|
}
|
||||||
|
return &libraryMatcher{
|
||||||
|
libraries: libs,
|
||||||
|
cleanedPaths: cleanedPaths,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathResolver handles path resolution logic for playlist imports.
|
||||||
|
type pathResolver struct {
|
||||||
|
matcher *libraryMatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPathResolver creates a pathResolver with libraries loaded from the datastore.
|
||||||
|
func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) {
|
||||||
|
libs, err := ds.Library(ctx).GetAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
matcher := newLibraryMatcher(libs)
|
||||||
|
return &pathResolver{matcher: matcher}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePath determines the absolute path and library path for a playlist entry.
|
||||||
|
// For absolute paths, it uses them directly.
|
||||||
|
// For relative paths, it resolves them relative to the playlist's folder location.
|
||||||
|
// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3"
|
||||||
|
//
|
||||||
|
// resolves to /music/songs/abc.mp3
|
||||||
|
func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolution {
|
||||||
|
var absolutePath string
|
||||||
|
if folder != nil && !filepath.IsAbs(line) {
|
||||||
|
// Resolve relative path to absolute path based on playlist location
|
||||||
|
absolutePath = filepath.Clean(filepath.Join(folder.AbsolutePath(), line))
|
||||||
|
} else {
|
||||||
|
// Use absolute path directly after cleaning
|
||||||
|
absolutePath = filepath.Clean(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.findInLibraries(absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findInLibraries matches an absolute path against all known libraries and returns
|
||||||
|
// a pathResolution with the library information. Returns an invalid resolution if
|
||||||
|
// the path is not found in any library.
|
||||||
|
func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
|
||||||
|
libID, libPath := r.matcher.findLibraryForPath(absolutePath)
|
||||||
|
if libID == 0 {
|
||||||
|
return pathResolution{valid: false}
|
||||||
|
}
|
||||||
|
return pathResolution{
|
||||||
|
absolutePath: absolutePath,
|
||||||
|
libraryPath: libPath,
|
||||||
|
libraryID: libID,
|
||||||
|
valid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
|
||||||
|
// For relative paths, it resolves them to absolute paths first, then determines which
|
||||||
|
// library they belong to. This allows playlists to reference files across library boundaries.
|
||||||
|
func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
|
||||||
|
resolver, err := newPathResolver(ctx, s.ds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create regex patterns for each library path
|
results := make([]string, 0, len(lines))
|
||||||
patterns := make([]string, len(libs))
|
for idx, line := range lines {
|
||||||
for i, lib := range libs {
|
resolution := resolver.resolvePath(line, folder)
|
||||||
cleanPath := filepath.Clean(lib.Path)
|
|
||||||
escapedPath := regexp.QuoteMeta(cleanPath)
|
if !resolution.valid {
|
||||||
patterns[i] = fmt.Sprintf("^%s(?:/|$)", escapedPath)
|
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
qualifiedPath, err := resolution.ToQualifiedString()
|
||||||
|
if err != nil {
|
||||||
|
log.Debug(ctx, "Error getting library-qualified path", "path", line,
|
||||||
|
"libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, qualifiedPath)
|
||||||
}
|
}
|
||||||
// Combine all patterns into a single regex
|
|
||||||
combinedPattern := strings.Join(patterns, "|")
|
return results, nil
|
||||||
re, err := regexp.Compile(combinedPattern)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("compiling library paths `%s`: %w", combinedPattern, err)
|
|
||||||
}
|
|
||||||
return re, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||||
|
|||||||
432
core/playlists_internal_test.go
Normal file
432
core/playlists_internal_test.go
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("libraryMatcher", func() {
|
||||||
|
var ds *tests.MockDataStore
|
||||||
|
var mockLibRepo *tests.MockLibraryRepo
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
mockLibRepo = &tests.MockLibraryRepo{}
|
||||||
|
ds = &tests.MockDataStore{
|
||||||
|
MockedLibrary: mockLibRepo,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper function to create a libraryMatcher from the mock datastore
|
||||||
|
createMatcher := func(ds model.DataStore) *libraryMatcher {
|
||||||
|
libs, err := ds.Library(ctx).GetAll()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
return newLibraryMatcher(libs)
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe("Longest library path matching", func() {
|
||||||
|
It("matches the longest library path when multiple libraries share a prefix", func() {
|
||||||
|
// Setup libraries with prefix conflicts
|
||||||
|
mockLibRepo.SetData([]model.Library{
|
||||||
|
{ID: 1, Path: "/music"},
|
||||||
|
{ID: 2, Path: "/music-classical"},
|
||||||
|
{ID: 3, Path: "/music-classical/opera"},
|
||||||
|
})
|
||||||
|
|
||||||
|
matcher := createMatcher(ds)
|
||||||
|
|
||||||
|
// Test that longest path matches first and returns correct library ID
|
||||||
|
testCases := []struct {
|
||||||
|
path string
|
||||||
|
expectedLibID int
|
||||||
|
expectedLibPath string
|
||||||
|
}{
|
||||||
|
{"/music-classical/opera/track.mp3", 3, "/music-classical/opera"},
|
||||||
|
{"/music-classical/track.mp3", 2, "/music-classical"},
|
||||||
|
{"/music/track.mp3", 1, "/music"},
|
||||||
|
{"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
libID, libPath := matcher.findLibraryForPath(tc.path)
|
||||||
|
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID)
|
||||||
|
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles libraries with similar prefixes but different structures", func() {
|
||||||
|
mockLibRepo.SetData([]model.Library{
|
||||||
|
{ID: 1, Path: "/home/user/music"},
|
||||||
|
{ID: 2, Path: "/home/user/music-backup"},
|
||||||
|
})
|
||||||
|
|
||||||
|
matcher := createMatcher(ds)
|
||||||
|
|
||||||
|
// Test that music-backup library is matched correctly
|
||||||
|
libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3")
|
||||||
|
Expect(libID).To(Equal(2))
|
||||||
|
Expect(libPath).To(Equal("/home/user/music-backup"))
|
||||||
|
|
||||||
|
// Test that music library is still matched correctly
|
||||||
|
libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3")
|
||||||
|
Expect(libID).To(Equal(1))
|
||||||
|
Expect(libPath).To(Equal("/home/user/music"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("matches path that is exactly the library root", func() {
|
||||||
|
mockLibRepo.SetData([]model.Library{
|
||||||
|
{ID: 1, Path: "/music"},
|
||||||
|
{ID: 2, Path: "/music-classical"},
|
||||||
|
})
|
||||||
|
|
||||||
|
matcher := createMatcher(ds)
|
||||||
|
|
||||||
|
// Exact library path should match
|
||||||
|
libID, libPath := matcher.findLibraryForPath("/music-classical")
|
||||||
|
Expect(libID).To(Equal(2))
|
||||||
|
Expect(libPath).To(Equal("/music-classical"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles complex nested library structures", func() {
|
||||||
|
mockLibRepo.SetData([]model.Library{
|
||||||
|
{ID: 1, Path: "/media"},
|
||||||
|
{ID: 2, Path: "/media/audio"},
|
||||||
|
{ID: 3, Path: "/media/audio/classical"},
|
||||||
|
{ID: 4, Path: "/media/audio/classical/baroque"},
|
||||||
|
})
|
||||||
|
|
||||||
|
matcher := createMatcher(ds)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
path string
|
||||||
|
expectedLibID int
|
||||||
|
expectedLibPath string
|
||||||
|
}{
|
||||||
|
{"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"},
|
||||||
|
{"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"},
|
||||||
|
{"/media/audio/rock/track.mp3", 2, "/media/audio"},
|
||||||
|
{"/media/video/movie.mp4", 1, "/media"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
libID, libPath := matcher.findLibraryForPath(tc.path)
|
||||||
|
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
|
||||||
|
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Edge cases", func() {
|
||||||
|
It("handles empty library list", func() {
|
||||||
|
mockLibRepo.SetData([]model.Library{})
|
||||||
|
|
||||||
|
matcher := createMatcher(ds)
|
||||||
|
Expect(matcher).ToNot(BeNil())
|
||||||
|
|
||||||
|
// Should not match anything
|
||||||
|
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
|
||||||
|
Expect(libID).To(Equal(0))
|
||||||
|
Expect(libPath).To(BeEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles single library", func() {
|
||||||
|
mockLibRepo.SetData([]model.Library{
|
||||||
|
{ID: 1, Path: "/music"},
|
||||||
|
})
|
||||||
|
|
||||||
|
matcher := createMatcher(ds)
|
||||||
|
|
||||||
|
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
|
||||||
|
Expect(libID).To(Equal(1))
|
||||||
|
Expect(libPath).To(Equal("/music"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles libraries with special characters in paths", func() {
|
||||||
|
mockLibRepo.SetData([]model.Library{
|
||||||
|
{ID: 1, Path: "/music[test]"},
|
||||||
|
{ID: 2, Path: "/music(backup)"},
|
||||||
|
})
|
||||||
|
|
||||||
|
matcher := createMatcher(ds)
|
||||||
|
Expect(matcher).ToNot(BeNil())
|
||||||
|
|
||||||
|
// Special characters should match literally
|
||||||
|
libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3")
|
||||||
|
Expect(libID).To(Equal(1))
|
||||||
|
Expect(libPath).To(Equal("/music[test]"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Path matching order", func() {
|
||||||
|
It("ensures longest paths match first", func() {
|
||||||
|
mockLibRepo.SetData([]model.Library{
|
||||||
|
{ID: 1, Path: "/a"},
|
||||||
|
{ID: 2, Path: "/ab"},
|
||||||
|
{ID: 3, Path: "/abc"},
|
||||||
|
})
|
||||||
|
|
||||||
|
matcher := createMatcher(ds)
|
||||||
|
|
||||||
|
// Verify that longer paths match correctly (not cut off by shorter prefix)
|
||||||
|
testCases := []struct {
|
||||||
|
path string
|
||||||
|
expectedLibID int
|
||||||
|
}{
|
||||||
|
{"/abc/file.mp3", 3},
|
||||||
|
{"/ab/file.mp3", 2},
|
||||||
|
{"/a/file.mp3", 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
libID, _ := matcher.findLibraryForPath(tc.path)
|
||||||
|
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
var _ = Describe("pathResolver", func() {
|
||||||
|
var ds *tests.MockDataStore
|
||||||
|
var mockLibRepo *tests.MockLibraryRepo
|
||||||
|
var resolver *pathResolver
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
mockLibRepo = &tests.MockLibraryRepo{}
|
||||||
|
ds = &tests.MockDataStore{
|
||||||
|
MockedLibrary: mockLibRepo,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup test libraries
|
||||||
|
mockLibRepo.SetData([]model.Library{
|
||||||
|
{ID: 1, Path: "/music"},
|
||||||
|
{ID: 2, Path: "/music-classical"},
|
||||||
|
{ID: 3, Path: "/podcasts"},
|
||||||
|
})
|
||||||
|
|
||||||
|
var err error
|
||||||
|
resolver, err = newPathResolver(ctx, ds)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("resolvePath", func() {
|
||||||
|
It("resolves absolute paths", func() {
|
||||||
|
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
|
||||||
|
|
||||||
|
Expect(resolution.valid).To(BeTrue())
|
||||||
|
Expect(resolution.libraryID).To(Equal(1))
|
||||||
|
Expect(resolution.libraryPath).To(Equal("/music"))
|
||||||
|
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("resolves relative paths when folder is provided", func() {
|
||||||
|
folder := &model.Folder{
|
||||||
|
Path: "playlists",
|
||||||
|
LibraryPath: "/music",
|
||||||
|
LibraryID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
|
||||||
|
|
||||||
|
Expect(resolution.valid).To(BeTrue())
|
||||||
|
Expect(resolution.libraryID).To(Equal(1))
|
||||||
|
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns invalid resolution for paths outside any library", func() {
|
||||||
|
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
|
||||||
|
|
||||||
|
Expect(resolution.valid).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("resolvePath", func() {
|
||||||
|
Context("With absolute paths", func() {
|
||||||
|
It("resolves path within a library", func() {
|
||||||
|
resolution := resolver.resolvePath("/music/track.mp3", nil)
|
||||||
|
|
||||||
|
Expect(resolution.valid).To(BeTrue())
|
||||||
|
Expect(resolution.libraryID).To(Equal(1))
|
||||||
|
Expect(resolution.libraryPath).To(Equal("/music"))
|
||||||
|
Expect(resolution.absolutePath).To(Equal("/music/track.mp3"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("resolves path to the longest matching library", func() {
|
||||||
|
resolution := resolver.resolvePath("/music-classical/track.mp3", nil)
|
||||||
|
|
||||||
|
Expect(resolution.valid).To(BeTrue())
|
||||||
|
Expect(resolution.libraryID).To(Equal(2))
|
||||||
|
Expect(resolution.libraryPath).To(Equal("/music-classical"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns invalid resolution for path outside libraries", func() {
|
||||||
|
resolution := resolver.resolvePath("/videos/movie.mp4", nil)
|
||||||
|
|
||||||
|
Expect(resolution.valid).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("cleans the path before matching", func() {
|
||||||
|
resolution := resolver.resolvePath("/music//artist/../artist/track.mp3", nil)
|
||||||
|
|
||||||
|
Expect(resolution.valid).To(BeTrue())
|
||||||
|
Expect(resolution.absolutePath).To(Equal("/music/artist/track.mp3"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("With relative paths", func() {
|
||||||
|
It("resolves relative path within same library", func() {
|
||||||
|
folder := &model.Folder{
|
||||||
|
Path: "playlists",
|
||||||
|
LibraryPath: "/music",
|
||||||
|
LibraryID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
resolution := resolver.resolvePath("../songs/track.mp3", folder)
|
||||||
|
|
||||||
|
Expect(resolution.valid).To(BeTrue())
|
||||||
|
Expect(resolution.libraryID).To(Equal(1))
|
||||||
|
Expect(resolution.absolutePath).To(Equal("/music/songs/track.mp3"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("resolves relative path to different library", func() {
|
||||||
|
folder := &model.Folder{
|
||||||
|
Path: "playlists",
|
||||||
|
LibraryPath: "/music",
|
||||||
|
LibraryID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path goes up and into a different library
|
||||||
|
resolution := resolver.resolvePath("../../podcasts/episode.mp3", folder)
|
||||||
|
|
||||||
|
Expect(resolution.valid).To(BeTrue())
|
||||||
|
Expect(resolution.libraryID).To(Equal(3))
|
||||||
|
Expect(resolution.libraryPath).To(Equal("/podcasts"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("uses matcher to find correct library for resolved path", func() {
|
||||||
|
folder := &model.Folder{
|
||||||
|
Path: "playlists",
|
||||||
|
LibraryPath: "/music",
|
||||||
|
LibraryID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// This relative path resolves to music-classical library
|
||||||
|
resolution := resolver.resolvePath("../../music-classical/track.mp3", folder)
|
||||||
|
|
||||||
|
Expect(resolution.valid).To(BeTrue())
|
||||||
|
Expect(resolution.libraryID).To(Equal(2))
|
||||||
|
Expect(resolution.libraryPath).To(Equal("/music-classical"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns invalid for relative paths escaping all libraries", func() {
|
||||||
|
folder := &model.Folder{
|
||||||
|
Path: "playlists",
|
||||||
|
LibraryPath: "/music",
|
||||||
|
LibraryID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
resolution := resolver.resolvePath("../../../../etc/passwd", folder)
|
||||||
|
|
||||||
|
Expect(resolution.valid).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Cross-library resolution scenarios", func() {
|
||||||
|
It("handles playlist in library A referencing file in library B", func() {
|
||||||
|
// Playlist is in /music/playlists
|
||||||
|
folder := &model.Folder{
|
||||||
|
Path: "playlists",
|
||||||
|
LibraryPath: "/music",
|
||||||
|
LibraryID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative path that goes to /podcasts library
|
||||||
|
resolution := resolver.resolvePath("../../podcasts/show/episode.mp3", folder)
|
||||||
|
|
||||||
|
Expect(resolution.valid).To(BeTrue())
|
||||||
|
Expect(resolution.libraryID).To(Equal(3), "Should resolve to podcasts library")
|
||||||
|
Expect(resolution.libraryPath).To(Equal("/podcasts"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("prefers longer library paths when resolving", func() {
|
||||||
|
// Ensure /music-classical is matched instead of /music
|
||||||
|
resolution := resolver.resolvePath("/music-classical/baroque/track.mp3", nil)
|
||||||
|
|
||||||
|
Expect(resolution.valid).To(BeTrue())
|
||||||
|
Expect(resolution.libraryID).To(Equal(2), "Should match /music-classical, not /music")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
var _ = Describe("pathResolution", func() {
|
||||||
|
Describe("ToQualifiedString", func() {
|
||||||
|
It("converts valid resolution to qualified string with forward slashes", func() {
|
||||||
|
resolution := pathResolution{
|
||||||
|
absolutePath: "/music/artist/album/track.mp3",
|
||||||
|
libraryPath: "/music",
|
||||||
|
libraryID: 1,
|
||||||
|
valid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
qualifiedStr, err := resolution.ToQualifiedString()
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(qualifiedStr).To(Equal("1:artist/album/track.mp3"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles Windows-style paths by converting to forward slashes", func() {
|
||||||
|
resolution := pathResolution{
|
||||||
|
absolutePath: "/music/artist/album/track.mp3",
|
||||||
|
libraryPath: "/music",
|
||||||
|
libraryID: 2,
|
||||||
|
valid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
qualifiedStr, err := resolution.ToQualifiedString()
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
// Should always use forward slashes regardless of OS
|
||||||
|
Expect(qualifiedStr).To(ContainSubstring("2:"))
|
||||||
|
Expect(qualifiedStr).ToNot(ContainSubstring("\\"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for invalid resolution", func() {
|
||||||
|
resolution := pathResolution{valid: false}
|
||||||
|
|
||||||
|
_, err := resolution.ToQualifiedString()
|
||||||
|
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
var _ = Describe("normalizePathForComparison", func() {
|
||||||
|
It("normalizes Unicode characters to NFC form and converts to lowercase", func() {
|
||||||
|
// Test with NFD (decomposed) input - as would come from macOS filesystem
|
||||||
|
nfdPath := norm.NFD.String("Michèle") // Explicitly convert to NFD form
|
||||||
|
normalized := normalizePathForComparison(nfdPath)
|
||||||
|
Expect(normalized).To(Equal("michèle"))
|
||||||
|
|
||||||
|
// Test with NFC (composed) input - as would come from Apple Music M3U
|
||||||
|
nfcPath := "Michèle" // This might be in NFC form
|
||||||
|
normalizedNfc := normalizePathForComparison(nfcPath)
|
||||||
|
|
||||||
|
// Ensure the two paths are not equal in their original forms
|
||||||
|
Expect(nfdPath).ToNot(Equal(nfcPath))
|
||||||
|
|
||||||
|
// Both should normalize to the same result
|
||||||
|
Expect(normalized).To(Equal(normalizedNfc))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles paths with mixed case and Unicode characters", func() {
|
||||||
|
path := "Artist/Noël Coward/Album/Song.mp3"
|
||||||
|
normalized := normalizePathForComparison(path)
|
||||||
|
Expect(normalized).To(Equal("artist/noël coward/album/song.mp3"))
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package core
|
package core_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@ -9,18 +9,18 @@ import (
|
|||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/criteria"
|
"github.com/navidrome/navidrome/model/criteria"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
"golang.org/x/text/unicode/norm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("Playlists", func() {
|
var _ = Describe("Playlists", func() {
|
||||||
var ds *tests.MockDataStore
|
var ds *tests.MockDataStore
|
||||||
var ps Playlists
|
var ps core.Playlists
|
||||||
var mockPlsRepo mockedPlaylistRepo
|
var mockPlsRepo mockedPlaylistRepo
|
||||||
var mockLibRepo *tests.MockLibraryRepo
|
var mockLibRepo *tests.MockLibraryRepo
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@ -33,16 +33,16 @@ var _ = Describe("Playlists", func() {
|
|||||||
MockedLibrary: mockLibRepo,
|
MockedLibrary: mockLibRepo,
|
||||||
}
|
}
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||||
// Path should be libPath, but we want to match the root folder referenced in the m3u, which is `/`
|
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/"}})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("ImportFile", func() {
|
Describe("ImportFile", func() {
|
||||||
var folder *model.Folder
|
var folder *model.Folder
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ps = NewPlaylists(ds)
|
ps = core.NewPlaylists(ds)
|
||||||
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
||||||
libPath, _ := os.Getwd()
|
libPath, _ := os.Getwd()
|
||||||
|
// Set up library with the actual library path that matches the folder
|
||||||
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: libPath}})
|
||||||
folder = &model.Folder{
|
folder = &model.Folder{
|
||||||
ID: "1",
|
ID: "1",
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
@ -112,6 +112,224 @@ var _ = Describe("Playlists", func() {
|
|||||||
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
|
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("Cross-library relative paths", func() {
|
||||||
|
var tmpDir, plsDir, songsDir string
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
// Create temp directory structure
|
||||||
|
tmpDir = GinkgoT().TempDir()
|
||||||
|
plsDir = tmpDir + "/playlists"
|
||||||
|
songsDir = tmpDir + "/songs"
|
||||||
|
Expect(os.Mkdir(plsDir, 0755)).To(Succeed())
|
||||||
|
Expect(os.Mkdir(songsDir, 0755)).To(Succeed())
|
||||||
|
|
||||||
|
// Setup two different libraries with paths matching our temp structure
|
||||||
|
mockLibRepo.SetData([]model.Library{
|
||||||
|
{ID: 1, Path: songsDir},
|
||||||
|
{ID: 2, Path: plsDir},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a mock media file repository that returns files for both libraries
|
||||||
|
// Note: The paths are relative to their respective library roots
|
||||||
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
|
||||||
|
data: []string{
|
||||||
|
"abc.mp3", // This is songs/abc.mp3 relative to songsDir
|
||||||
|
"def.mp3", // This is playlists/def.mp3 relative to plsDir
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ps = core.NewPlaylists(ds)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles relative paths that reference files in other libraries", func() {
|
||||||
|
// Create a temporary playlist file with relative path
|
||||||
|
plsContent := "#PLAYLIST:Cross Library Test\n../songs/abc.mp3\ndef.mp3"
|
||||||
|
plsFile := plsDir + "/test.m3u"
|
||||||
|
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
// Playlist is in the Playlists library folder
|
||||||
|
// Important: Path should be relative to LibraryPath, and Name is the folder name
|
||||||
|
plsFolder := &model.Folder{
|
||||||
|
ID: "2",
|
||||||
|
LibraryID: 2,
|
||||||
|
LibraryPath: plsDir,
|
||||||
|
Path: "",
|
||||||
|
Name: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(pls.Tracks).To(HaveLen(2))
|
||||||
|
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
|
||||||
|
Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library
|
||||||
|
})
|
||||||
|
|
||||||
|
It("ignores paths that point outside all libraries", func() {
|
||||||
|
// Create a temporary playlist file with path outside libraries
|
||||||
|
plsContent := "#PLAYLIST:Outside Test\n../../outside.mp3\nabc.mp3"
|
||||||
|
plsFile := plsDir + "/test.m3u"
|
||||||
|
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
plsFolder := &model.Folder{
|
||||||
|
ID: "2",
|
||||||
|
LibraryID: 2,
|
||||||
|
LibraryPath: plsDir,
|
||||||
|
Path: "",
|
||||||
|
Name: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
// Should only find abc.mp3, not outside.mp3
|
||||||
|
Expect(pls.Tracks).To(HaveLen(1))
|
||||||
|
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles relative paths with multiple '../' components", func() {
|
||||||
|
// Create a nested structure: tmpDir/playlists/subfolder/test.m3u
|
||||||
|
subFolder := plsDir + "/subfolder"
|
||||||
|
Expect(os.Mkdir(subFolder, 0755)).To(Succeed())
|
||||||
|
|
||||||
|
// Create the media file in the subfolder directory
|
||||||
|
// The mock will return it as "def.mp3" relative to plsDir
|
||||||
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
|
||||||
|
data: []string{
|
||||||
|
"abc.mp3", // From songsDir library
|
||||||
|
"def.mp3", // From plsDir library root
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// From subfolder, ../../songs/abc.mp3 should resolve to songs library
|
||||||
|
// ../def.mp3 should resolve to plsDir/def.mp3
|
||||||
|
plsContent := "#PLAYLIST:Nested Test\n../../songs/abc.mp3\n../def.mp3"
|
||||||
|
plsFile := subFolder + "/test.m3u"
|
||||||
|
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
// The folder: AbsolutePath = LibraryPath + Path + Name
|
||||||
|
// So for /playlists/subfolder: LibraryPath=/playlists, Path="", Name="subfolder"
|
||||||
|
plsFolder := &model.Folder{
|
||||||
|
ID: "2",
|
||||||
|
LibraryID: 2,
|
||||||
|
LibraryPath: plsDir,
|
||||||
|
Path: "", // Empty because subfolder is directly under library root
|
||||||
|
Name: "subfolder", // The folder name
|
||||||
|
}
|
||||||
|
|
||||||
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(pls.Tracks).To(HaveLen(2))
|
||||||
|
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
|
||||||
|
Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library root
|
||||||
|
})
|
||||||
|
|
||||||
|
It("correctly resolves libraries when one path is a prefix of another", func() {
|
||||||
|
// This tests the bug where /music would match before /music-classical
|
||||||
|
// Create temp directory structure with prefix conflict
|
||||||
|
tmpDir := GinkgoT().TempDir()
|
||||||
|
musicDir := tmpDir + "/music"
|
||||||
|
musicClassicalDir := tmpDir + "/music-classical"
|
||||||
|
Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
|
||||||
|
Expect(os.Mkdir(musicClassicalDir, 0755)).To(Succeed())
|
||||||
|
|
||||||
|
// Setup two libraries where one is a prefix of the other
|
||||||
|
mockLibRepo.SetData([]model.Library{
|
||||||
|
{ID: 1, Path: musicDir}, // /tmp/xxx/music
|
||||||
|
{ID: 2, Path: musicClassicalDir}, // /tmp/xxx/music-classical
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock will return tracks from both libraries
|
||||||
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
|
||||||
|
data: []string{
|
||||||
|
"rock.mp3", // From music library
|
||||||
|
"bach.mp3", // From music-classical library
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create playlist in music library that references music-classical
|
||||||
|
plsContent := "#PLAYLIST:Cross Prefix Test\nrock.mp3\n../music-classical/bach.mp3"
|
||||||
|
plsFile := musicDir + "/test.m3u"
|
||||||
|
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
plsFolder := &model.Folder{
|
||||||
|
ID: "1",
|
||||||
|
LibraryID: 1,
|
||||||
|
LibraryPath: musicDir,
|
||||||
|
Path: "",
|
||||||
|
Name: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(pls.Tracks).To(HaveLen(2))
|
||||||
|
Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library
|
||||||
|
Expect(pls.Tracks[1].Path).To(Equal("bach.mp3")) // From music-classical library (not music!)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("correctly handles identical relative paths from different libraries", func() {
|
||||||
|
// This tests the bug where two libraries have files at the same relative path
|
||||||
|
// and only one appears in the playlist
|
||||||
|
tmpDir := GinkgoT().TempDir()
|
||||||
|
musicDir := tmpDir + "/music"
|
||||||
|
classicalDir := tmpDir + "/classical"
|
||||||
|
Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
|
||||||
|
Expect(os.Mkdir(classicalDir, 0755)).To(Succeed())
|
||||||
|
Expect(os.MkdirAll(musicDir+"/album", 0755)).To(Succeed())
|
||||||
|
Expect(os.MkdirAll(classicalDir+"/album", 0755)).To(Succeed())
|
||||||
|
// Create placeholder files so paths resolve correctly
|
||||||
|
Expect(os.WriteFile(musicDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
|
||||||
|
Expect(os.WriteFile(classicalDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
|
||||||
|
|
||||||
|
// Both libraries have a file at "album/track.mp3"
|
||||||
|
mockLibRepo.SetData([]model.Library{
|
||||||
|
{ID: 1, Path: musicDir},
|
||||||
|
{ID: 2, Path: classicalDir},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock returns files with same relative path but different IDs and library IDs
|
||||||
|
// Keys use the library-qualified format: "libraryID:path"
|
||||||
|
ds.MockedMediaFile = &mockedMediaFileRepo{
|
||||||
|
data: map[string]model.MediaFile{
|
||||||
|
"1:album/track.mp3": {ID: "music-track", Path: "album/track.mp3", LibraryID: 1, Title: "Rock Song"},
|
||||||
|
"2:album/track.mp3": {ID: "classical-track", Path: "album/track.mp3", LibraryID: 2, Title: "Classical Piece"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Recreate playlists service to pick up new mock
|
||||||
|
ps = core.NewPlaylists(ds)
|
||||||
|
|
||||||
|
// Create playlist in music library that references both tracks
|
||||||
|
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
|
||||||
|
plsFile := musicDir + "/test.m3u"
|
||||||
|
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
plsFolder := &model.Folder{
|
||||||
|
ID: "1",
|
||||||
|
LibraryID: 1,
|
||||||
|
LibraryPath: musicDir,
|
||||||
|
Path: "",
|
||||||
|
Name: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Should have BOTH tracks, not just one
|
||||||
|
Expect(pls.Tracks).To(HaveLen(2), "Playlist should contain both tracks with same relative path")
|
||||||
|
|
||||||
|
// Verify we got tracks from DIFFERENT libraries (the key fix!)
|
||||||
|
// Collect the library IDs
|
||||||
|
libIDs := make(map[int]bool)
|
||||||
|
for _, track := range pls.Tracks {
|
||||||
|
libIDs[track.LibraryID] = true
|
||||||
|
}
|
||||||
|
Expect(libIDs).To(HaveLen(2), "Tracks should come from two different libraries")
|
||||||
|
Expect(libIDs[1]).To(BeTrue(), "Should have track from library 1")
|
||||||
|
Expect(libIDs[2]).To(BeTrue(), "Should have track from library 2")
|
||||||
|
|
||||||
|
// Both tracks should have the same relative path
|
||||||
|
Expect(pls.Tracks[0].Path).To(Equal("album/track.mp3"))
|
||||||
|
Expect(pls.Tracks[1].Path).To(Equal("album/track.mp3"))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("ImportM3U", func() {
|
Describe("ImportM3U", func() {
|
||||||
@ -119,7 +337,7 @@ var _ = Describe("Playlists", func() {
|
|||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
repo = &mockedMediaFileFromListRepo{}
|
repo = &mockedMediaFileFromListRepo{}
|
||||||
ds.MockedMediaFile = repo
|
ds.MockedMediaFile = repo
|
||||||
ps = NewPlaylists(ds)
|
ps = core.NewPlaylists(ds)
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||||
})
|
})
|
||||||
@ -206,53 +424,6 @@ var _ = Describe("Playlists", func() {
|
|||||||
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
|
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("handles Unicode normalization when comparing paths", func() {
|
|
||||||
// Test case for Apple Music playlists that use NFC encoding vs macOS filesystem NFD
|
|
||||||
// The character "è" can be represented as NFC (single codepoint) or NFD (e + combining accent)
|
|
||||||
|
|
||||||
const pathWithAccents = "artist/Michèle Desrosiers/album/Noël.m4a"
|
|
||||||
|
|
||||||
// Simulate a database entry with NFD encoding (as stored by macOS filesystem)
|
|
||||||
nfdPath := norm.NFD.String(pathWithAccents)
|
|
||||||
repo.data = []string{nfdPath}
|
|
||||||
|
|
||||||
// Simulate an Apple Music M3U playlist entry with NFC encoding
|
|
||||||
nfcPath := norm.NFC.String("/music/" + pathWithAccents)
|
|
||||||
m3u := strings.Join([]string{
|
|
||||||
nfcPath,
|
|
||||||
}, "\n")
|
|
||||||
f := strings.NewReader(m3u)
|
|
||||||
|
|
||||||
pls, err := ps.ImportM3U(ctx, f)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(pls.Tracks).To(HaveLen(1), "Should find the track despite Unicode normalization differences")
|
|
||||||
Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("normalizePathForComparison", func() {
|
|
||||||
It("normalizes Unicode characters to NFC form and converts to lowercase", func() {
|
|
||||||
// Test with NFD (decomposed) input - as would come from macOS filesystem
|
|
||||||
nfdPath := norm.NFD.String("Michèle") // Explicitly convert to NFD form
|
|
||||||
normalized := normalizePathForComparison(nfdPath)
|
|
||||||
Expect(normalized).To(Equal("michèle"))
|
|
||||||
|
|
||||||
// Test with NFC (composed) input - as would come from Apple Music M3U
|
|
||||||
nfcPath := "Michèle" // This might be in NFC form
|
|
||||||
normalizedNfc := normalizePathForComparison(nfcPath)
|
|
||||||
|
|
||||||
// Ensure the two paths are not equal in their original forms
|
|
||||||
Expect(nfdPath).ToNot(Equal(nfcPath))
|
|
||||||
|
|
||||||
// Both should normalize to the same result
|
|
||||||
Expect(normalized).To(Equal(normalizedNfc))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles paths with mixed case and Unicode characters", func() {
|
|
||||||
path := "Artist/Noël Coward/Album/Song.mp3"
|
|
||||||
normalized := normalizePathForComparison(path)
|
|
||||||
Expect(normalized).To(Equal("artist/noël coward/album/song.mp3"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("InPlaylistsPath", func() {
|
Describe("InPlaylistsPath", func() {
|
||||||
@ -269,27 +440,27 @@ var _ = Describe("Playlists", func() {
|
|||||||
|
|
||||||
It("returns true if PlaylistsPath is empty", func() {
|
It("returns true if PlaylistsPath is empty", func() {
|
||||||
conf.Server.PlaylistsPath = ""
|
conf.Server.PlaylistsPath = ""
|
||||||
Expect(InPlaylistsPath(folder)).To(BeTrue())
|
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns true if PlaylistsPath is any (**/**)", func() {
|
It("returns true if PlaylistsPath is any (**/**)", func() {
|
||||||
conf.Server.PlaylistsPath = "**/**"
|
conf.Server.PlaylistsPath = "**/**"
|
||||||
Expect(InPlaylistsPath(folder)).To(BeTrue())
|
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns true if folder is in PlaylistsPath", func() {
|
It("returns true if folder is in PlaylistsPath", func() {
|
||||||
conf.Server.PlaylistsPath = "other/**:playlists/**"
|
conf.Server.PlaylistsPath = "other/**:playlists/**"
|
||||||
Expect(InPlaylistsPath(folder)).To(BeTrue())
|
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns false if folder is not in PlaylistsPath", func() {
|
It("returns false if folder is not in PlaylistsPath", func() {
|
||||||
conf.Server.PlaylistsPath = "other"
|
conf.Server.PlaylistsPath = "other"
|
||||||
Expect(InPlaylistsPath(folder)).To(BeFalse())
|
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
|
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
|
||||||
conf.Server.PlaylistsPath = "."
|
conf.Server.PlaylistsPath = "."
|
||||||
Expect(InPlaylistsPath(folder)).To(BeFalse())
|
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||||
|
|
||||||
folder2 := model.Folder{
|
folder2 := model.Folder{
|
||||||
LibraryPath: "/music",
|
LibraryPath: "/music",
|
||||||
@ -297,22 +468,47 @@ var _ = Describe("Playlists", func() {
|
|||||||
Name: ".",
|
Name: ".",
|
||||||
}
|
}
|
||||||
|
|
||||||
Expect(InPlaylistsPath(folder2)).To(BeTrue())
|
Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
|
// mockedMediaFileRepo's FindByPaths method returns MediaFiles for the given paths.
|
||||||
|
// If data map is provided, looks up files by key; otherwise creates them from paths.
|
||||||
type mockedMediaFileRepo struct {
|
type mockedMediaFileRepo struct {
|
||||||
model.MediaFileRepository
|
model.MediaFileRepository
|
||||||
|
data map[string]model.MediaFile
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
|
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||||
var mfs model.MediaFiles
|
var mfs model.MediaFiles
|
||||||
|
|
||||||
|
// If data map provided, look up files
|
||||||
|
if r.data != nil {
|
||||||
|
for _, path := range paths {
|
||||||
|
if mf, ok := r.data[path]; ok {
|
||||||
|
mfs = append(mfs, mf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mfs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, create MediaFiles from paths
|
||||||
for idx, path := range paths {
|
for idx, path := range paths {
|
||||||
|
// Strip library qualifier if present (format: "libraryID:path")
|
||||||
|
actualPath := path
|
||||||
|
libraryID := 1
|
||||||
|
if parts := strings.SplitN(path, ":", 2); len(parts) == 2 {
|
||||||
|
if id, err := strconv.Atoi(parts[0]); err == nil {
|
||||||
|
libraryID = id
|
||||||
|
actualPath = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mfs = append(mfs, model.MediaFile{
|
mfs = append(mfs, model.MediaFile{
|
||||||
ID: strconv.Itoa(idx),
|
ID: strconv.Itoa(idx),
|
||||||
Path: path,
|
Path: actualPath,
|
||||||
|
LibraryID: libraryID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return mfs, nil
|
return mfs, nil
|
||||||
@ -324,13 +520,31 @@ type mockedMediaFileFromListRepo struct {
|
|||||||
data []string
|
data []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
|
func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||||
var mfs model.MediaFiles
|
var mfs model.MediaFiles
|
||||||
for idx, path := range r.data {
|
|
||||||
mfs = append(mfs, model.MediaFile{
|
for idx, dataPath := range r.data {
|
||||||
ID: strconv.Itoa(idx),
|
for _, requestPath := range paths {
|
||||||
Path: path,
|
// Strip library qualifier if present (format: "libraryID:path")
|
||||||
})
|
actualPath := requestPath
|
||||||
|
libraryID := 1
|
||||||
|
if parts := strings.SplitN(requestPath, ":", 2); len(parts) == 2 {
|
||||||
|
if id, err := strconv.Atoi(parts[0]); err == nil {
|
||||||
|
libraryID = id
|
||||||
|
actualPath = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive comparison (like SQL's "collate nocase")
|
||||||
|
if strings.EqualFold(actualPath, dataPath) {
|
||||||
|
mfs = append(mfs, model.MediaFile{
|
||||||
|
ID: strconv.Itoa(idx),
|
||||||
|
Path: dataPath,
|
||||||
|
LibraryID: libraryID,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return mfs, nil
|
return mfs, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -192,12 +194,43 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindByPaths finds media files by their paths.
|
||||||
|
// The paths can be library-qualified (format: "libraryID:path") or unqualified ("path").
|
||||||
|
// Library-qualified paths search within the specified library, while unqualified paths
|
||||||
|
// search across all libraries for backward compatibility.
|
||||||
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
|
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||||
sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
|
query := Or{}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
parts := strings.SplitN(path, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
// Library-qualified path: "libraryID:path"
|
||||||
|
libraryID, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
// Invalid format, skip
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
relativePath := parts[1]
|
||||||
|
query = append(query, And{
|
||||||
|
Eq{"path collate nocase": relativePath},
|
||||||
|
Eq{"library_id": libraryID},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Unqualified path: search across all libraries
|
||||||
|
query = append(query, Eq{"path collate nocase": path})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(query) == 0 {
|
||||||
|
return model.MediaFiles{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sel := r.newSelect().Columns("*").Where(query)
|
||||||
var res dbMediaFiles
|
var res dbMediaFiles
|
||||||
if err := r.queryAll(sel, &res); err != nil {
|
if err := r.queryAll(sel, &res); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.toModels(), nil
|
return res.toModels(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -83,6 +83,15 @@ nfpms:
|
|||||||
owner: navidrome
|
owner: navidrome
|
||||||
group: navidrome
|
group: navidrome
|
||||||
|
|
||||||
|
- src: release/linux/.package.rpm # contents: "rpm"
|
||||||
|
dst: /var/lib/navidrome/.package
|
||||||
|
type: "config|noreplace"
|
||||||
|
packager: rpm
|
||||||
|
- src: release/linux/.package.deb # contents: "deb"
|
||||||
|
dst: /var/lib/navidrome/.package
|
||||||
|
type: "config|noreplace"
|
||||||
|
packager: deb
|
||||||
|
|
||||||
scripts:
|
scripts:
|
||||||
preinstall: "release/linux/preinstall.sh"
|
preinstall: "release/linux/preinstall.sh"
|
||||||
postinstall: "release/linux/postinstall.sh"
|
postinstall: "release/linux/postinstall.sh"
|
||||||
|
|||||||
1
release/linux/.package.deb
Normal file
1
release/linux/.package.deb
Normal file
@ -0,0 +1 @@
|
|||||||
|
deb
|
||||||
1
release/linux/.package.rpm
Normal file
1
release/linux/.package.rpm
Normal file
@ -0,0 +1 @@
|
|||||||
|
rpm
|
||||||
@ -49,6 +49,9 @@ cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.exe "$MSI_OUT
|
|||||||
cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR"
|
cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR"
|
||||||
cp "$BINARY" "$MSI_OUTPUT_DIR"
|
cp "$BINARY" "$MSI_OUTPUT_DIR"
|
||||||
|
|
||||||
|
# package type indicator file
|
||||||
|
echo "msi" > "$MSI_OUTPUT_DIR/.package"
|
||||||
|
|
||||||
# workaround for wixl WixVariable not working to override bmp locations
|
# workaround for wixl WixVariable not working to override bmp locations
|
||||||
cp "$WORKSPACE"/release/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp
|
cp "$WORKSPACE"/release/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp
|
||||||
cp "$WORKSPACE"/release/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp
|
cp "$WORKSPACE"/release/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp
|
||||||
|
|||||||
@ -69,6 +69,12 @@
|
|||||||
|
|
||||||
</Directory>
|
</Directory>
|
||||||
</Directory>
|
</Directory>
|
||||||
|
|
||||||
|
<Directory Id="ND_DATAFOLDER" name="[ND_DATAFOLDER]">
|
||||||
|
<Component Id='PackageFile' Guid='9eec0697-803c-4629-858f-20dc376c960b' Win64="$(var.Win64)">
|
||||||
|
<File Id='package' Name='.package' DiskId='1' Source='.package' KeyPath='no' />
|
||||||
|
</Component>
|
||||||
|
</Directory>
|
||||||
</Directory>
|
</Directory>
|
||||||
|
|
||||||
<InstallUISequence>
|
<InstallUISequence>
|
||||||
@ -81,6 +87,7 @@
|
|||||||
<ComponentRef Id='Configuration'/>
|
<ComponentRef Id='Configuration'/>
|
||||||
<ComponentRef Id='MainExecutable' />
|
<ComponentRef Id='MainExecutable' />
|
||||||
<ComponentRef Id='FFMpegExecutable' />
|
<ComponentRef Id='FFMpegExecutable' />
|
||||||
|
<ComponentRef Id='PackageFile' />
|
||||||
</Feature>
|
</Feature>
|
||||||
</Product>
|
</Product>
|
||||||
</Wix>
|
</Wix>
|
||||||
|
|||||||
@ -1,628 +0,0 @@
|
|||||||
{
|
|
||||||
"languageName": "Tiếng Việt",
|
|
||||||
"resources": {
|
|
||||||
"song": {
|
|
||||||
"name": "Tên bài hát",
|
|
||||||
"fields": {
|
|
||||||
"albumArtist": "Nghệ sĩ trong album",
|
|
||||||
"duration": "Thời lượng",
|
|
||||||
"trackNumber": "#",
|
|
||||||
"playCount": "Số lượt phát",
|
|
||||||
"title": "Tên",
|
|
||||||
"artist": "Nghệ sĩ",
|
|
||||||
"album": "Album",
|
|
||||||
"path": "Đường dẫn file",
|
|
||||||
"genre": "Thể loại",
|
|
||||||
"compilation": "Tuyển tập",
|
|
||||||
"year": "Năm",
|
|
||||||
"size": "Kích thước tệp",
|
|
||||||
"updatedAt": "Cập nhật vào",
|
|
||||||
"bitRate": "Số bit",
|
|
||||||
"discSubtitle": "Tiêu đề phụ của đĩa",
|
|
||||||
"starred": "Yêu thích",
|
|
||||||
"comment": "Bình luận",
|
|
||||||
"rating": "Đánh giá",
|
|
||||||
"quality": "Chất lượng",
|
|
||||||
"bpm": "BPM",
|
|
||||||
"playDate": "Phát lần cuối",
|
|
||||||
"channels": "Kênh",
|
|
||||||
"createdAt": "Ngày thêm bài hát",
|
|
||||||
"grouping": "Nhóm",
|
|
||||||
"mood": "Tâm trạng",
|
|
||||||
"participants": "Người tham gia bổ sung",
|
|
||||||
"tags": "Tag bổ sung",
|
|
||||||
"mappedTags": "Thẻ đã liên kết",
|
|
||||||
"rawTags": "Thẻ gốc",
|
|
||||||
"bitDepth": "",
|
|
||||||
"sampleRate": "",
|
|
||||||
"missing": "",
|
|
||||||
"libraryName": ""
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"addToQueue": "Thêm bài hát vào hàng chờ",
|
|
||||||
"playNow": "Phát ",
|
|
||||||
"addToPlaylist": "Thêm vào danh sách",
|
|
||||||
"shuffleAll": "Ngẫu nhiên Tất cả",
|
|
||||||
"download": "Tải bài hát xuống",
|
|
||||||
"playNext": "Phát tiếp theo",
|
|
||||||
"info": "Lấy thông tin bài hát",
|
|
||||||
"showInPlaylist": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"album": {
|
|
||||||
"name": "Tên album",
|
|
||||||
"fields": {
|
|
||||||
"albumArtist": "Nghệ sĩ trong album",
|
|
||||||
"artist": "Nghệ sĩ",
|
|
||||||
"duration": "Thời lượng",
|
|
||||||
"songCount": "Số bài hát",
|
|
||||||
"playCount": "Số lượt phát",
|
|
||||||
"name": "Tên",
|
|
||||||
"genre": "Thể loại",
|
|
||||||
"compilation": "Tuyển tập",
|
|
||||||
"year": "Năm",
|
|
||||||
"updatedAt": "Cập nhật vào",
|
|
||||||
"comment": "Bình luận",
|
|
||||||
"rating": "Đánh giá",
|
|
||||||
"createdAt": "Ngày thêm album",
|
|
||||||
"size": "Kích cỡ",
|
|
||||||
"originalDate": "Bản gốc",
|
|
||||||
"releaseDate": "Ngày phát hành",
|
|
||||||
"releases": "Bản phát hành |||| Các bản phát hành",
|
|
||||||
"released": "Đã phát hành",
|
|
||||||
"recordLabel": "Hãng đĩa",
|
|
||||||
"catalogNum": "Số Catalog",
|
|
||||||
"releaseType": "Loai",
|
|
||||||
"grouping": "Nhóm",
|
|
||||||
"media": "",
|
|
||||||
"mood": "",
|
|
||||||
"date": "",
|
|
||||||
"missing": "",
|
|
||||||
"libraryName": ""
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"playAll": "Phát",
|
|
||||||
"playNext": "Tiếp theo",
|
|
||||||
"addToQueue": "Thêm album vào hàng chờ",
|
|
||||||
"shuffle": "phát ngẫu nhiên",
|
|
||||||
"addToPlaylist": "Thêm vào danh sách phát",
|
|
||||||
"download": "Tải Album xuống",
|
|
||||||
"info": "Lấy thông tin album",
|
|
||||||
"share": "Chia sẻ"
|
|
||||||
},
|
|
||||||
"lists": {
|
|
||||||
"all": "Tất cả",
|
|
||||||
"random": "Ngẫu nhiên",
|
|
||||||
"recentlyAdded": "Thêm vào gần đây",
|
|
||||||
"recentlyPlayed": "Đã phát gần đây",
|
|
||||||
"mostPlayed": "Phát nhiều nhất",
|
|
||||||
"starred": "Album Yêu thích",
|
|
||||||
"topRated": "Được đánh giá cao nhất"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"artist": {
|
|
||||||
"name": "Nghệ sĩ",
|
|
||||||
"fields": {
|
|
||||||
"name": "Tên nghệ sĩ",
|
|
||||||
"albumCount": "Số Album",
|
|
||||||
"songCount": "Số bài hát",
|
|
||||||
"playCount": "Số lượt phát",
|
|
||||||
"rating": "Đánh giá",
|
|
||||||
"genre": "Thể loại",
|
|
||||||
"size": "Kích cỡ",
|
|
||||||
"role": "",
|
|
||||||
"missing": ""
|
|
||||||
},
|
|
||||||
"roles": {
|
|
||||||
"albumartist": "",
|
|
||||||
"artist": "",
|
|
||||||
"composer": "",
|
|
||||||
"conductor": "",
|
|
||||||
"lyricist": "",
|
|
||||||
"arranger": "",
|
|
||||||
"producer": "",
|
|
||||||
"director": "",
|
|
||||||
"engineer": "",
|
|
||||||
"mixer": "",
|
|
||||||
"remixer": "",
|
|
||||||
"djmixer": "",
|
|
||||||
"performer": "",
|
|
||||||
"maincredit": ""
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"shuffle": "",
|
|
||||||
"radio": "",
|
|
||||||
"topSongs": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"name": "Người dùng",
|
|
||||||
"fields": {
|
|
||||||
"userName": "Tên người dùng",
|
|
||||||
"isAdmin": "Quản trị viên",
|
|
||||||
"lastLoginAt": "Lần đăng nhập cuối",
|
|
||||||
"updatedAt": "Cập nhật lúc",
|
|
||||||
"name": "Tên người dùng",
|
|
||||||
"password": "Mật khẩu",
|
|
||||||
"createdAt": "Tạo vào",
|
|
||||||
"changePassword": "Đổi mật khẩu ?",
|
|
||||||
"currentPassword": "Mật khẩu hiện tại",
|
|
||||||
"newPassword": "Mật khẩu mới",
|
|
||||||
"token": "Token",
|
|
||||||
"lastAccessAt": "Lần truy cập cuối",
|
|
||||||
"libraries": ""
|
|
||||||
},
|
|
||||||
"helperTexts": {
|
|
||||||
"name": "Sự thay đổi về tên bạn sẽ có hiệu lực vào lần đăng nhập tiếp theo",
|
|
||||||
"libraries": ""
|
|
||||||
},
|
|
||||||
"notifications": {
|
|
||||||
"created": "Tạo bởi user",
|
|
||||||
"updated": "Cập nhật bởi user",
|
|
||||||
"deleted": "Xóa người dùng"
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"listenBrainzToken": "Nhập token của MusicBrainz",
|
|
||||||
"clickHereForToken": "",
|
|
||||||
"selectAllLibraries": "",
|
|
||||||
"adminAutoLibraries": ""
|
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"librariesRequired": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"player": {
|
|
||||||
"name": "Trình phát |||| Các trình phát",
|
|
||||||
"fields": {
|
|
||||||
"name": "Tên trình phát",
|
|
||||||
"transcodingId": "Mã chuyển mã",
|
|
||||||
"maxBitRate": "Bit Rate cao nhất",
|
|
||||||
"client": "",
|
|
||||||
"userName": "Tên người dùng",
|
|
||||||
"lastSeen": "Lần cuối nhìn thấy",
|
|
||||||
"reportRealPath": "Hiện đường dẫn thực",
|
|
||||||
"scrobbleEnabled": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"transcoding": {
|
|
||||||
"name": "Chuyển đổi định dạng",
|
|
||||||
"fields": {
|
|
||||||
"name": "Tên cấu hình chuyển mã",
|
|
||||||
"targetFormat": "Định dạng cuối",
|
|
||||||
"defaultBitRate": "Số Bit mặc định",
|
|
||||||
"command": "Câu lệnh"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"playlist": {
|
|
||||||
"name": "Danh sách phát |||| Các danh sách phát",
|
|
||||||
"fields": {
|
|
||||||
"name": "Tên",
|
|
||||||
"duration": "Thời lượng",
|
|
||||||
"ownerName": "Chủ sở hữu",
|
|
||||||
"public": "Công khai",
|
|
||||||
"updatedAt": "Cập nhật vào",
|
|
||||||
"createdAt": "Tạo vào lúc",
|
|
||||||
"songCount": "Số bài hát",
|
|
||||||
"comment": "Bình luận",
|
|
||||||
"sync": "Tự động thêm vào",
|
|
||||||
"path": "Nhập từ"
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"selectPlaylist": "Chọn 1 danh sách phát",
|
|
||||||
"addNewPlaylist": "Tạo \"%{name}\"",
|
|
||||||
"export": "Xuất danh sách phát",
|
|
||||||
"makePublic": "",
|
|
||||||
"makePrivate": "",
|
|
||||||
"saveQueue": "",
|
|
||||||
"searchOrCreate": "",
|
|
||||||
"pressEnterToCreate": "",
|
|
||||||
"removeFromSelection": ""
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"duplicate_song": "Thêm các bài hát trùng lặp",
|
|
||||||
"song_exist": "Có một số bài hát trùng đang được thêm vào danh sách phát. Bạn muốn thêm các bài trùng hay bỏ qua chúng?",
|
|
||||||
"noPlaylistsFound": "",
|
|
||||||
"noPlaylists": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"radio": {
|
|
||||||
"name": "Radio |||| Radios",
|
|
||||||
"fields": {
|
|
||||||
"name": "Tên",
|
|
||||||
"streamUrl": "Stream URL",
|
|
||||||
"homePageUrl": "URL trang chủ",
|
|
||||||
"updatedAt": "Cập nhật vào",
|
|
||||||
"createdAt": "Tạo vào lúc"
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"playNow": "Phát ngay"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"share": {
|
|
||||||
"name": "Chia sẻ |||| Chia sẻ",
|
|
||||||
"fields": {
|
|
||||||
"username": "Chia sẻ bởi",
|
|
||||||
"url": "URL",
|
|
||||||
"description": "Phần mô tả",
|
|
||||||
"contents": "Nội dung",
|
|
||||||
"expiresAt": "Hết hạn",
|
|
||||||
"lastVisitedAt": "Lần mở cuối ",
|
|
||||||
"visitCount": "Lượt ",
|
|
||||||
"format": "Định dạng",
|
|
||||||
"maxBitRate": "Số Bit cao nhất",
|
|
||||||
"updatedAt": "Cập nhật vào",
|
|
||||||
"createdAt": "Tạo vào",
|
|
||||||
"downloadable": "Cho phép tải xuống?"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"missing": {
|
|
||||||
"name": "",
|
|
||||||
"fields": {
|
|
||||||
"path": "",
|
|
||||||
"size": "",
|
|
||||||
"updatedAt": "",
|
|
||||||
"libraryName": ""
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"remove": "",
|
|
||||||
"remove_all": ""
|
|
||||||
},
|
|
||||||
"notifications": {
|
|
||||||
"removed": ""
|
|
||||||
},
|
|
||||||
"empty": ""
|
|
||||||
},
|
|
||||||
"library": {
|
|
||||||
"name": "",
|
|
||||||
"fields": {
|
|
||||||
"name": "",
|
|
||||||
"path": "",
|
|
||||||
"remotePath": "",
|
|
||||||
"lastScanAt": "",
|
|
||||||
"songCount": "",
|
|
||||||
"albumCount": "",
|
|
||||||
"artistCount": "",
|
|
||||||
"totalSongs": "",
|
|
||||||
"totalAlbums": "",
|
|
||||||
"totalArtists": "",
|
|
||||||
"totalFolders": "",
|
|
||||||
"totalFiles": "",
|
|
||||||
"totalMissingFiles": "",
|
|
||||||
"totalSize": "",
|
|
||||||
"totalDuration": "",
|
|
||||||
"defaultNewUsers": "",
|
|
||||||
"createdAt": "",
|
|
||||||
"updatedAt": ""
|
|
||||||
},
|
|
||||||
"sections": {
|
|
||||||
"basic": "",
|
|
||||||
"statistics": ""
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"scan": "",
|
|
||||||
"manageUsers": "",
|
|
||||||
"viewDetails": ""
|
|
||||||
},
|
|
||||||
"notifications": {
|
|
||||||
"created": "",
|
|
||||||
"updated": "",
|
|
||||||
"deleted": "Xóa thư viện thành công",
|
|
||||||
"scanStarted": "Bắt đầu quét thư viện",
|
|
||||||
"scanCompleted": "Quét thư viện hoàn tất"
|
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"nameRequired": "",
|
|
||||||
"pathRequired": "",
|
|
||||||
"pathNotDirectory": "",
|
|
||||||
"pathNotFound": "",
|
|
||||||
"pathNotAccessible": "",
|
|
||||||
"pathInvalid": ""
|
|
||||||
},
|
|
||||||
"messages": {
|
|
||||||
"deleteConfirm": "",
|
|
||||||
"scanInProgress": "Đang quét...",
|
|
||||||
"noLibrariesAssigned": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ra": {
|
|
||||||
"auth": {
|
|
||||||
"welcome1": "Cảm ơn bạn vì đã sử dụng Navidrome",
|
|
||||||
"welcome2": "Để bắt đầu, hãy tạo một tài khoản quản trị viên.",
|
|
||||||
"confirmPassword": "Xác nhận mật khẩu",
|
|
||||||
"buttonCreateAdmin": "Tạo quản trị viên",
|
|
||||||
"auth_check_error": "Hãy đăng nhập để tiếp tục",
|
|
||||||
"user_menu": "Profile",
|
|
||||||
"username": "Tên người dùng",
|
|
||||||
"password": "Mật khẩu",
|
|
||||||
"sign_in": "Đăng nhập",
|
|
||||||
"sign_in_error": "Xác thực thất bại, hãy thử lại",
|
|
||||||
"logout": "Đăng xuất",
|
|
||||||
"insightsCollectionNote": "Navidrome thu thập dữ liệu sử dụng ẩn danh để giúp cải thiện dự án. Nhấp [here] để tìm hiểu thêm và tắt tính năng này nếu bạn muốn."
|
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"invalidChars": "Vui lòng chỉ sử dụng chữ cái và số",
|
|
||||||
"passwordDoesNotMatch": "Mật khẩu không đúng",
|
|
||||||
"required": "Yêu cầu",
|
|
||||||
"minLength": "Ít nhất là %{min} ký tự",
|
|
||||||
"maxLength": "Phải nhiều hơn hoặc bằng hoặc bằng %{max}.",
|
|
||||||
"minValue": "Ít nhất là %{min}",
|
|
||||||
"maxValue": "Phải nhỏ hơn hoặc bằng %{max}",
|
|
||||||
"number": "Phải là một số",
|
|
||||||
"email": "Phải là một email ",
|
|
||||||
"oneOf": "Phải là một trong các lựa chọn sau: %{options}",
|
|
||||||
"regex": "Phải khớp với định dạng cụ thể (regex): %{pattern}",
|
|
||||||
"unique": "Phải đặc biệt",
|
|
||||||
"url": "Phải là một URL hợp lệ"
|
|
||||||
},
|
|
||||||
"action": {
|
|
||||||
"add_filter": "Thêm bộ lọc",
|
|
||||||
"add": "Thêm",
|
|
||||||
"back": "Quay lại",
|
|
||||||
"bulk_actions": "Đã chọn 1 mục |||| Đã chọn %{smart_count} mục",
|
|
||||||
"cancel": "Hủy",
|
|
||||||
"clear_input_value": "Xóa thiết đặt",
|
|
||||||
"clone": "Nhân bản",
|
|
||||||
"confirm": "Xác nhận",
|
|
||||||
"create": "Tạo",
|
|
||||||
"delete": "Xóa",
|
|
||||||
"edit": "Sửa",
|
|
||||||
"export": "Xuất",
|
|
||||||
"list": "Danh sách",
|
|
||||||
"refresh": "Làm mới",
|
|
||||||
"remove_filter": "Bỏ bộ lọc này",
|
|
||||||
"remove": "Gỡ bỏ",
|
|
||||||
"save": "Lưu lại",
|
|
||||||
"search": "Tìm kiếm",
|
|
||||||
"show": "Hiển thị",
|
|
||||||
"sort": "Lọc",
|
|
||||||
"undo": "Hoàn tác",
|
|
||||||
"expand": "Mở rộng",
|
|
||||||
"close": "Đóng",
|
|
||||||
"open_menu": "Mở menu",
|
|
||||||
"close_menu": "Đóng menu",
|
|
||||||
"unselect": "Bỏ chọn",
|
|
||||||
"skip": "Bỏ qua",
|
|
||||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
|
||||||
"share": "Chia sẻ",
|
|
||||||
"download": "Tải xuống"
|
|
||||||
},
|
|
||||||
"boolean": {
|
|
||||||
"true": "Có",
|
|
||||||
"false": "Không"
|
|
||||||
},
|
|
||||||
"page": {
|
|
||||||
"create": "Tạo %{name}",
|
|
||||||
"dashboard": "Trang chủ",
|
|
||||||
"edit": "%{name} #%{id}",
|
|
||||||
"error": "Có gì đó không ổn",
|
|
||||||
"list": "%{name}",
|
|
||||||
"loading": "Đang tải",
|
|
||||||
"not_found": "Không tìm thấy",
|
|
||||||
"show": "%{name} #%{id}",
|
|
||||||
"empty": "Chưa có %{name}",
|
|
||||||
"invite": "Bạn muốn thêm vào không ?"
|
|
||||||
},
|
|
||||||
"input": {
|
|
||||||
"file": {
|
|
||||||
"upload_several": "Thả một vài tệp để tải lên hoặc nhấp để chọn",
|
|
||||||
"upload_single": "Thả một file để tải lên hoặc nhấp để chọn nó"
|
|
||||||
},
|
|
||||||
"image": {
|
|
||||||
"upload_several": "Thả một vài ảnh để tải lên hoặc nhấp để chọn",
|
|
||||||
"upload_single": "Thả một ảnh để tải lên hoặc nhấp để chọn nó"
|
|
||||||
},
|
|
||||||
"references": {
|
|
||||||
"all_missing": "Không thể tìm thấy dữ liệu",
|
|
||||||
"many_missing": "Ít nhất một mục được liên kết không còn tồn tại.",
|
|
||||||
"single_missing": "Tham chiếu liên kết không còn khả dụng nữa."
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"toggle_visible": "Ẩn mật khẩu",
|
|
||||||
"toggle_hidden": "Hiện mật khẩu"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"about": "Giới thiệu",
|
|
||||||
"are_you_sure": "Bạn chắc chứ ?",
|
|
||||||
"bulk_delete_content": "Bạn có chắc chắn muốn xóa %{name} này không? |||| Bạn có chắc chắn muốn xóa %{smart_count} mục này không??",
|
|
||||||
"bulk_delete_title": "Xóa %{name} đã chọn |||| Xóa %{smart_count} mục %{name}",
|
|
||||||
"delete_content": "Xác nhận xóa ?",
|
|
||||||
"delete_title": "Xóa %{name} #%{id}",
|
|
||||||
"details": "Chi tiết",
|
|
||||||
"error": "Có lỗi xảy ra với client và yêu cầu của bạn không thành công.",
|
|
||||||
"invalid_form": "Biểu mẫu không hợp lệ. Vui lòng kiểm tra lại các lỗi",
|
|
||||||
"loading": "Trang đang được tải, hãy kiên nhận",
|
|
||||||
"no": "Không",
|
|
||||||
"not_found": "Có thể bạn đã nhập sai URL hoặc truy cập vào một liên kết không hợp lệ.",
|
|
||||||
"yes": "Có",
|
|
||||||
"unsaved_changes": "Một số thiết đặt chưa được lưu. Bạn muốn bỏ qua chúng không ?"
|
|
||||||
},
|
|
||||||
"navigation": {
|
|
||||||
"no_results": "Không tìm thấy kết quả",
|
|
||||||
"no_more_results": "Số trang %{page} nằm ngoài giới hạn. Hãy thử quay lại trang trước",
|
|
||||||
"page_out_of_boundaries": "Trang %{page} không hợp lệ",
|
|
||||||
"page_out_from_end": "Bạn đang ở trang cuối rồi",
|
|
||||||
"page_out_from_begin": "Không thể quay về trước trang 1",
|
|
||||||
"page_range_info": "%{offsetBegin}–%{offsetEnd} trong tổng số %{total}",
|
|
||||||
"page_rows_per_page": "Số mục mỗi trang :",
|
|
||||||
"next": "Tiếp theo",
|
|
||||||
"prev": "Trước",
|
|
||||||
"skip_nav": "Bỏ qua đến nội dung"
|
|
||||||
},
|
|
||||||
"notification": {
|
|
||||||
"updated": "Mục đã được cập nhật |||| %{smart_count} mục đã cập nhật",
|
|
||||||
"created": "Đã tạo mục mới",
|
|
||||||
"deleted": "Đã xóa muc |||| %{smart_count} mục đã xóa",
|
|
||||||
"bad_item": "Mục không đúng",
|
|
||||||
"item_doesnt_exist": "Mục không tồn tại",
|
|
||||||
"http_error": "Lỗi kết nối đến máy chủ",
|
|
||||||
"data_provider_error": "Lỗi dataProvider. Kiểm tra Console để biết thêm chi tiết",
|
|
||||||
"i18n_error": "Không thể tải bản dịch cho ngôn ngữ đã chọn",
|
|
||||||
"canceled": "Hành động đã bị hủy",
|
|
||||||
"logged_out": "Phiên của bạn đã kết thúc, vui lòng kết nối lại.",
|
|
||||||
"new_version": "Có phiên bản mới! Hãy làm mới trang"
|
|
||||||
},
|
|
||||||
"toggleFieldsMenu": {
|
|
||||||
"columnsToDisplay": "Các cột hiển thị",
|
|
||||||
"layout": "Bố cục",
|
|
||||||
"grid": "Lưới",
|
|
||||||
"table": "Bảng"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"note": "Lưu ý",
|
|
||||||
"transcodingDisabled": "Việc thay đổi cấu hình chuyển mã (transcoding configuration) thông qua giao diện web đã bị vô hiệu hóa vì lý do bảo mật. Nếu bạn muốn chỉnh sửa hoặc thêm tùy chọn chuyển mã, hãy khởi động lại máy chủ kèm theo tùy chọn cấu hình %{config}",
|
|
||||||
"transcodingEnabled": "Navidrome hiện đang chạy với tùy chọn cấu hình %{config}, cho phép thực thi lệnh hệ thống từ phần cài đặt chuyển mã (transcoding) trong giao diện web. Chúng tôi khuyến nghị bạn nên tắt tùy chọn này vì lý do bảo mật, và chỉ bật lại khi cần cấu hình các tùy chọn chuyển mã.",
|
|
||||||
"songsAddedToPlaylist": "Đã thêm 1 bài hát vào danh sách phát |||| Đã thêm %{smart_count} bài hát vào danh sách phát",
|
|
||||||
"noPlaylistsAvailable": "Không có danh sách phát",
|
|
||||||
"delete_user_title": "Xóa người dùng '%{name}'",
|
|
||||||
"delete_user_content": "Bạn có muốn xóa người dùng này và tất cả các dữ liệu của họ không ( bao gồm danh sách phát và các thiết đặt )?",
|
|
||||||
"notifications_blocked": "Bạn đã tắt thông báo trong cài đặt trình duyệt",
|
|
||||||
"notifications_not_available": "Trình duyệt này không hỗ trợ thông báo trên desktop hoặc bạn đang truy cập Navidrome qua http",
|
|
||||||
"lastfmLinkSuccess": "",
|
|
||||||
"lastfmLinkFailure": "",
|
|
||||||
"lastfmUnlinkSuccess": "",
|
|
||||||
"lastfmUnlinkFailure": "",
|
|
||||||
"openIn": {
|
|
||||||
"lastfm": "Mở trong Last.fm",
|
|
||||||
"musicbrainz": "Mở trong MusicBrainz"
|
|
||||||
},
|
|
||||||
"lastfmLink": "Đọc thêm...",
|
|
||||||
"listenBrainzLinkSuccess": "",
|
|
||||||
"listenBrainzLinkFailure": "Không thể liên kết với ListenBrainz : %{error}",
|
|
||||||
"listenBrainzUnlinkSuccess": "Đã bỏ liên kết với ListenBrainz và ",
|
|
||||||
"listenBrainzUnlinkFailure": "Không thể liên kết với MusicBrainz",
|
|
||||||
"downloadOriginalFormat": "Tải xuống ở định dạng gốc",
|
|
||||||
"shareOriginalFormat": "Chia sẻ ở định dạng gốc",
|
|
||||||
"shareDialogTitle": "Chia sẻ %{resource} '%{name}'",
|
|
||||||
"shareBatchDialogTitle": "Chia sẻ 1 %{resource} |||| Chia sẻ %{smart_count} %{resource}",
|
|
||||||
"shareSuccess": "URL đã sao chép vào bảng nhớ tạm : %{url}",
|
|
||||||
"shareFailure": "Lỗi khi sao chép URL %{url} vào bảng nhớ tạm",
|
|
||||||
"downloadDialogTitle": "Tải xuống %{resource} '%{name}' (%{size})",
|
|
||||||
"shareCopyToClipboard": "Sao chép vào bảng nhớ tạm : Ctrl+C, Enter",
|
|
||||||
"remove_missing_title": "",
|
|
||||||
"remove_missing_content": "",
|
|
||||||
"remove_all_missing_title": "",
|
|
||||||
"remove_all_missing_content": "",
|
|
||||||
"noSimilarSongsFound": "",
|
|
||||||
"noTopSongsFound": ""
|
|
||||||
},
|
|
||||||
"menu": {
|
|
||||||
"library": "Thư viện",
|
|
||||||
"settings": "Cài đặt",
|
|
||||||
"version": "Phiên bản",
|
|
||||||
"theme": "Theme",
|
|
||||||
"personal": {
|
|
||||||
"name": "Cá nhân hóa",
|
|
||||||
"options": {
|
|
||||||
"theme": "Theme",
|
|
||||||
"language": "Ngôn ngữ",
|
|
||||||
"defaultView": "",
|
|
||||||
"desktop_notifications": "Thông báo trên desktop",
|
|
||||||
"lastfmScrobbling": "",
|
|
||||||
"listenBrainzScrobbling": "",
|
|
||||||
"replaygain": "Chế độ ReplayGain",
|
|
||||||
"preAmp": "ReplayGain PreAmp (dB)",
|
|
||||||
"gain": {
|
|
||||||
"none": "Tắt",
|
|
||||||
"album": "Dùng Album Gain",
|
|
||||||
"track": "Dùng Track Gain"
|
|
||||||
},
|
|
||||||
"lastfmNotConfigured": "Khóa API của Last.fm chưa được cấu hình"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"albumList": "Albums",
|
|
||||||
"about": "Về",
|
|
||||||
"playlists": "Danh sách phát",
|
|
||||||
"sharedPlaylists": "Danh sách phát được chia sẻ",
|
|
||||||
"librarySelector": {
|
|
||||||
"allLibraries": "Tất cả thư viện (%{count})",
|
|
||||||
"multipleLibraries": "",
|
|
||||||
"selectLibraries": "",
|
|
||||||
"none": "Không có"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"player": {
|
|
||||||
"playListsText": "Danh sách chờ",
|
|
||||||
"openText": "Mở",
|
|
||||||
"closeText": "Thoát",
|
|
||||||
"notContentText": "Không có bài hát",
|
|
||||||
"clickToPlayText": "Nhấp để phát",
|
|
||||||
"clickToPauseText": "Nhấp để tạm dừng",
|
|
||||||
"nextTrackText": "Track tiếp theo",
|
|
||||||
"previousTrackText": "Track trước đó",
|
|
||||||
"reloadText": "Làm mới",
|
|
||||||
"volumeText": "Âm lượng",
|
|
||||||
"toggleLyricText": "Bật lời bài hát",
|
|
||||||
"toggleMiniModeText": "Thu nhỏ",
|
|
||||||
"destroyText": "Xóa",
|
|
||||||
"downloadText": "Tải xuống",
|
|
||||||
"removeAudioListsText": "Xóa danh sách ",
|
|
||||||
"clickToDeleteText": "Nhấp để xóa %{name}",
|
|
||||||
"emptyLyricText": "Không có lời",
|
|
||||||
"playModeText": {
|
|
||||||
"order": "Theo thứ tự",
|
|
||||||
"orderLoop": "Lặp lại",
|
|
||||||
"singleLoop": "Lặp lại một lần",
|
|
||||||
"shufflePlay": "Phát ngẫu nhiên"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"about": {
|
|
||||||
"links": {
|
|
||||||
"homepage": "Trang chủ",
|
|
||||||
"source": "Mã nguồn",
|
|
||||||
"featureRequests": "Yêu cầu tính năng",
|
|
||||||
"lastInsightsCollection": "Lần thu thập dữ liệu gần nhất",
|
|
||||||
"insights": {
|
|
||||||
"disabled": "Đã tắt",
|
|
||||||
"waiting": "Đang chờ"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tabs": {
|
|
||||||
"about": "",
|
|
||||||
"config": ""
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"configName": "",
|
|
||||||
"environmentVariable": "",
|
|
||||||
"currentValue": "",
|
|
||||||
"configurationFile": "",
|
|
||||||
"exportToml": "",
|
|
||||||
"exportSuccess": "",
|
|
||||||
"exportFailed": "",
|
|
||||||
"devFlagsHeader": "",
|
|
||||||
"devFlagsComment": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"activity": {
|
|
||||||
"title": "Hoạt động",
|
|
||||||
"totalScanned": "Tổng Folder đã quét",
|
|
||||||
"quickScan": "Quét nhanh",
|
|
||||||
"fullScan": "Quét toàn bộ",
|
|
||||||
"serverUptime": "Server Uptime",
|
|
||||||
"serverDown": "Ngoại tuyến",
|
|
||||||
"scanType": "",
|
|
||||||
"status": "",
|
|
||||||
"elapsedTime": ""
|
|
||||||
},
|
|
||||||
"help": {
|
|
||||||
"title": "Phím tắt của Navidrome",
|
|
||||||
"hotkeys": {
|
|
||||||
"show_help": "Hiện giúp đỡ",
|
|
||||||
"toggle_menu": "Bật thanh phát bên",
|
|
||||||
"toggle_play": "Phát / tạm dừng",
|
|
||||||
"prev_song": "Bài hát trước đó",
|
|
||||||
"next_song": "Bài hát sau đó",
|
|
||||||
"vol_up": "Tăng âm lượng",
|
|
||||||
"vol_down": "Giảm âm lượng",
|
|
||||||
"toggle_love": "Thêm track này vào yêu thích",
|
|
||||||
"current_song": "Đi đến bài hát hiện tại"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nowPlaying": {
|
|
||||||
"title": "",
|
|
||||||
"empty": "",
|
|
||||||
"minutesAgo": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -148,7 +148,9 @@ func (api *Router) routes() http.Handler {
|
|||||||
h(r, "createBookmark", api.CreateBookmark)
|
h(r, "createBookmark", api.CreateBookmark)
|
||||||
h(r, "deleteBookmark", api.DeleteBookmark)
|
h(r, "deleteBookmark", api.DeleteBookmark)
|
||||||
h(r, "getPlayQueue", api.GetPlayQueue)
|
h(r, "getPlayQueue", api.GetPlayQueue)
|
||||||
|
h(r, "getPlayQueueByIndex", api.GetPlayQueueByIndex)
|
||||||
h(r, "savePlayQueue", api.SavePlayQueue)
|
h(r, "savePlayQueue", api.SavePlayQueue)
|
||||||
|
h(r, "savePlayQueueByIndex", api.SavePlayQueueByIndex)
|
||||||
})
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(getPlayer(api.players))
|
r.Use(getPlayer(api.players))
|
||||||
|
|||||||
@ -91,7 +91,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
|||||||
Current: currentID,
|
Current: currentID,
|
||||||
Position: pq.Position,
|
Position: pq.Position,
|
||||||
Username: user.UserName,
|
Username: user.UserName,
|
||||||
Changed: &pq.UpdatedAt,
|
Changed: pq.UpdatedAt,
|
||||||
ChangedBy: pq.ChangedBy,
|
ChangedBy: pq.ChangedBy,
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
@ -135,3 +135,74 @@ func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
|||||||
}
|
}
|
||||||
return newResponse(), nil
|
return newResponse(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
|
||||||
|
user, _ := request.UserFrom(r.Context())
|
||||||
|
|
||||||
|
repo := api.ds.PlayQueue(r.Context())
|
||||||
|
pq, err := repo.RetrieveWithMediaFiles(user.ID)
|
||||||
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if pq == nil || len(pq.Items) == 0 {
|
||||||
|
return newResponse(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
response := newResponse()
|
||||||
|
|
||||||
|
var index *int
|
||||||
|
if len(pq.Items) > 0 {
|
||||||
|
index = &pq.Current
|
||||||
|
}
|
||||||
|
|
||||||
|
response.PlayQueueByIndex = &responses.PlayQueueByIndex{
|
||||||
|
Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
|
||||||
|
CurrentIndex: index,
|
||||||
|
Position: pq.Position,
|
||||||
|
Username: user.UserName,
|
||||||
|
Changed: pq.UpdatedAt,
|
||||||
|
ChangedBy: pq.ChangedBy,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
|
||||||
|
p := req.Params(r)
|
||||||
|
ids, _ := p.Strings("id")
|
||||||
|
|
||||||
|
position := p.Int64Or("position", 0)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
var currentIndex int
|
||||||
|
|
||||||
|
if len(ids) > 0 {
|
||||||
|
currentIndex, err = p.Int("currentIndex")
|
||||||
|
if err != nil || currentIndex < 0 || currentIndex >= len(ids) {
|
||||||
|
return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items := slice.Map(ids, func(id string) model.MediaFile {
|
||||||
|
return model.MediaFile{ID: id}
|
||||||
|
})
|
||||||
|
|
||||||
|
user, _ := request.UserFrom(r.Context())
|
||||||
|
client, _ := request.ClientFrom(r.Context())
|
||||||
|
|
||||||
|
pq := &model.PlayQueue{
|
||||||
|
UserID: user.ID,
|
||||||
|
Current: currentIndex,
|
||||||
|
Position: position,
|
||||||
|
ChangedBy: client,
|
||||||
|
Items: items,
|
||||||
|
CreatedAt: time.Time{},
|
||||||
|
UpdatedAt: time.Time{},
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := api.ds.PlayQueue(r.Context())
|
||||||
|
err = repo.Store(pq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return newResponse(), nil
|
||||||
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
|
|||||||
{Name: "transcodeOffset", Versions: []int32{1}},
|
{Name: "transcodeOffset", Versions: []int32{1}},
|
||||||
{Name: "formPost", Versions: []int32{1}},
|
{Name: "formPost", Versions: []int32{1}},
|
||||||
{Name: "songLyrics", Versions: []int32{1}},
|
{Name: "songLyrics", Versions: []int32{1}},
|
||||||
|
{Name: "indexBasedQueue", Versions: []int32{1}},
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,10 +35,11 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
|||||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||||
HaveLen(3),
|
HaveLen(4),
|
||||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
||||||
|
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
"openSubsonic": true,
|
"openSubsonic": true,
|
||||||
"playQueue": {
|
"playQueue": {
|
||||||
"username": "",
|
"username": "",
|
||||||
|
"changed": "0001-01-01T00:00:00Z",
|
||||||
"changedBy": ""
|
"changedBy": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||||
<playQueue username="" changedBy=""></playQueue>
|
<playQueue username="" changed="0001-01-01T00:00:00Z" changedBy=""></playQueue>
|
||||||
</subsonic-response>
|
</subsonic-response>
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"version": "1.16.1",
|
||||||
|
"type": "navidrome",
|
||||||
|
"serverVersion": "v0.55.0",
|
||||||
|
"openSubsonic": true,
|
||||||
|
"playQueueByIndex": {
|
||||||
|
"entry": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"isDir": false,
|
||||||
|
"title": "title",
|
||||||
|
"isVideo": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"currentIndex": 0,
|
||||||
|
"position": 243,
|
||||||
|
"username": "user1",
|
||||||
|
"changed": "0001-01-01T00:00:00Z",
|
||||||
|
"changedBy": "a_client"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||||
|
<playQueueByIndex currentIndex="0" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client">
|
||||||
|
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
|
||||||
|
</playQueueByIndex>
|
||||||
|
</subsonic-response>
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"version": "1.16.1",
|
||||||
|
"type": "navidrome",
|
||||||
|
"serverVersion": "v0.55.0",
|
||||||
|
"openSubsonic": true,
|
||||||
|
"playQueueByIndex": {
|
||||||
|
"username": "",
|
||||||
|
"changed": "0001-01-01T00:00:00Z",
|
||||||
|
"changedBy": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||||
|
<playQueueByIndex username="" changed="0001-01-01T00:00:00Z" changedBy=""></playQueueByIndex>
|
||||||
|
</subsonic-response>
|
||||||
@ -60,6 +60,7 @@ type Subsonic struct {
|
|||||||
// OpenSubsonic extensions
|
// OpenSubsonic extensions
|
||||||
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
||||||
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
||||||
|
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -439,12 +440,21 @@ type TopSongs struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PlayQueue struct {
|
type PlayQueue struct {
|
||||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||||
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
|
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
|
||||||
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||||
Username string `xml:"username,attr" json:"username"`
|
Username string `xml:"username,attr" json:"username"`
|
||||||
Changed *time.Time `xml:"changed,attr,omitempty" json:"changed,omitempty"`
|
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||||
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayQueueByIndex struct {
|
||||||
|
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||||
|
CurrentIndex *int `xml:"currentIndex,attr,omitempty" json:"currentIndex,omitempty"`
|
||||||
|
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||||
|
Username string `xml:"username,attr" json:"username"`
|
||||||
|
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||||
|
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Bookmark struct {
|
type Bookmark struct {
|
||||||
|
|||||||
@ -768,7 +768,7 @@ var _ = Describe("Responses", func() {
|
|||||||
response.PlayQueue.Username = "user1"
|
response.PlayQueue.Username = "user1"
|
||||||
response.PlayQueue.Current = "111"
|
response.PlayQueue.Current = "111"
|
||||||
response.PlayQueue.Position = 243
|
response.PlayQueue.Position = 243
|
||||||
response.PlayQueue.Changed = &time.Time{}
|
response.PlayQueue.Changed = time.Time{}
|
||||||
response.PlayQueue.ChangedBy = "a_client"
|
response.PlayQueue.ChangedBy = "a_client"
|
||||||
child := make([]Child, 1)
|
child := make([]Child, 1)
|
||||||
child[0] = Child{Id: "1", Title: "title", IsDir: false}
|
child[0] = Child{Id: "1", Title: "title", IsDir: false}
|
||||||
@ -783,6 +783,40 @@ var _ = Describe("Responses", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("PlayQueueByIndex", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
response.PlayQueueByIndex = &PlayQueueByIndex{}
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("without data", func() {
|
||||||
|
It("should match .XML", func() {
|
||||||
|
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||||
|
})
|
||||||
|
It("should match .JSON", func() {
|
||||||
|
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("with data", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
response.PlayQueueByIndex.Username = "user1"
|
||||||
|
response.PlayQueueByIndex.CurrentIndex = gg.P(0)
|
||||||
|
response.PlayQueueByIndex.Position = 243
|
||||||
|
response.PlayQueueByIndex.Changed = time.Time{}
|
||||||
|
response.PlayQueueByIndex.ChangedBy = "a_client"
|
||||||
|
child := make([]Child, 1)
|
||||||
|
child[0] = Child{Id: "1", Title: "title", IsDir: false}
|
||||||
|
response.PlayQueueByIndex.Entry = child
|
||||||
|
})
|
||||||
|
It("should match .XML", func() {
|
||||||
|
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||||
|
})
|
||||||
|
It("should match .JSON", func() {
|
||||||
|
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("Shares", func() {
|
Describe("Shares", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
response.Shares = &Shares{}
|
response.Shares = &Shares{}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user