Compare commits

...

18 Commits

Author SHA1 Message Date
Deluan Quintão
3fb9e2f32b
Merge 52d8cf4f760fd0f87e5b239a99ea1ea72fec9e90 into 131c0c565cfd2f5c11939e05621cd4a671ec7ecb 2025-11-09 13:15:21 -05:00
Rob Emery
131c0c565c
feat(insights): detecting packaging method (#3841)
* Adding environmental variable so that navidrome can detect
if its running as an MSI install for insights

* Renaming to be ND_PACKAGE_TYPE so we can reuse this for the
.deb/.rpm stats as well

* Packaged implies a bool, this is a description so it should
be packaging or just package imo

* wixl currently doesn't support <Environment> so I'm swapping out
to a file next-door to the configuration file, we should be
able to reuse this for deb/rpm as well

* Using a file we should be able to add support for linux like this
also

* MSI should copy the package into place for us, it's not a KeyPath
as older versions won't have it, so it's presence doesn't indicate
the installed status of the package

* OK this doesn't exist, need to find another way to do it

* package to .package and moving to the datadir

* fix(scanner): better log message when AutoImportPlaylists is disabled

Fix #3861

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): support ID3v2 embedded images in WAV files

Fix #3867

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): show bitDepth in song info dialog

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(server): don't break if the ND_CONFIGFILE does not exist

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(docker): automatically loads a navidrome.toml file from /data, if available

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(server): custom ArtistJoiner config (#3873)

* feat(server): custom ArtistJoiner config

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(ui): organize ArtistLinkField, add tests

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): use display artist

* feat(ui): use display artist

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>

* chore: remove some BFR-related TODOs that are not valid anymore

Signed-off-by: Deluan <deluan@navidrome.org>

* chore: remove more outdated TODOs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): elapsed time for folder processing is wrong in the logs

Signed-off-by: Deluan <deluan@navidrome.org>

* Should be able to reuse this mechanism with deb and rpm, I think
it would be nice to know which specific one it is without guessing
based on /etc/debian_version or something; but it doesn't look like
that is exposed by goreleaser into an env or anything :/

* Need to reference the installed file and I think Id's don't require []

* Need to add into the root directory for this to work

* That was not deliberately removed

* feat: add RPM and DEB package configuration files for Navidrome

Signed-off-by: Deluan <deluan@navidrome.org>

* Don't need this as goreleaser will sort it out

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2025-11-09 12:57:55 -05:00
Kendall Garner
53ff33866d
feat(subsonic): implement indexBasedQueue extension (#4244)
* redo this whole PR, but clearner now that better errata is in

* update play queue types
2025-11-09 12:52:05 -05:00
Deluan
508670ecfb Revert "feat(ui): add Vietnamese localization for the application"
This reverts commit 9621a40f29a507b1e450da31a32134cdc7a9cf2a.
2025-11-09 12:41:25 -05:00
Deluan
c369224597 test: fix flaky CacheWarmer deduplication test
Fixed race condition in the 'deduplicates items in buffer' test where the
background worker goroutine could process and clear the buffer before the
test could verify its contents. Added fc.SetReady(false) to keep the cache
unavailable during the test, ensuring buffered items remain in memory for
verification. This matches the pattern already used in the 'adds multiple
items to buffer' test.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-09 12:19:28 -05:00
Deluan
52d8cf4f76 docs: add FindByPaths comment
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 17:34:03 -05:00
Deluan
15de641bea refactor: consolidate path resolution logic
Collapse resolveRelativePath and resolveAbsolutePath into a unified
resolvePath function, extracting common library matching logic into a
new findInLibraries helper method.

This eliminates duplicate code (~20 lines) while maintaining clear
separation of concerns: resolvePath handles path normalization
(relative vs absolute), and findInLibraries handles library matching.

Update tests to call resolvePath directly with appropriate parameters,
maintaining full test coverage for both absolute and relative path
scenarios.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 17:28:26 -05:00
Deluan
b4b618eb0c refactor: remove unnecessary path validation fallback
Remove validatePathInLibrary function and its fallback logic in
resolveRelativePath. The library matcher should always find the correct
library, including the playlist's own library. If this fails, we now
return an invalid resolution instead of attempting a fallback validation.

This simplifies the code by removing redundant validation logic that
was masking test setup issues. Also fixes test mock configuration to
properly set up library paths that match folder LibraryPath values.
2025-11-07 17:28:24 -05:00
Deluan
f8c430586d refactor
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 16:54:07 -05:00
Deluan
b0cc0a2f40 fix: improve path resolution for cross-library playlists and enhance error handling
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 16:41:26 -05:00
Deluan
a875606c28 fix: lint
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 14:51:02 -05:00
Deluan
7cd4a482c2 refactor: simplify mocks
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 14:41:10 -05:00
Deluan
5c4df0912e fix: enhance handling of library-qualified paths and improve cross-library playlist support
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 13:11:19 -05:00
Deluan
9a50079978 test: refactor tests isolation
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 08:46:59 -05:00
Deluan
5925e8dbd8 fix: ensure longest library path matches first to resolve prefix conflicts in playlists
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 00:08:48 -05:00
Deluan
3761503d7a refactor: improve handling of relative paths in playlists for cross-library compatibility
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 23:50:33 -05:00
Deluan
66d4a75c8b fix: enhance playlist path normalization for cross-library support
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 21:15:38 -05:00
Deluan
6a3fef8e3b fix: handle cross-library relative paths in playlists
Playlists can now reference songs in other libraries using relative paths.
Previously, relative paths like '../Songs/abc.mp3' would not resolve correctly
when pointing to files in a different library than the playlist file.

The fix resolves relative paths to absolute paths first, then checks which
library they belong to using the library regex. This allows playlists to
reference files across library boundaries while maintaining backward
compatibility with existing single-library relative paths.

Fixes #4617
2025-11-06 20:47:41 -05:00
25 changed files with 1106 additions and 764 deletions

View File

@ -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"))

View File

@ -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

View File

@ -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"`

View File

@ -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 {

View 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"))
})
})

View File

@ -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
} }

View File

@ -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
} }

View File

@ -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"

View File

@ -0,0 +1 @@
deb

View File

@ -0,0 +1 @@
rpm

View File

@ -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

View File

@ -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>

View File

@ -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": ""
}
}

View File

@ -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))

View File

@ -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
}

View File

@ -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
} }

View File

@ -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}}),
)) ))
}) })
}) })

View File

@ -6,6 +6,7 @@
"openSubsonic": true, "openSubsonic": true,
"playQueue": { "playQueue": {
"username": "", "username": "",
"changed": "0001-01-01T00:00:00Z",
"changedBy": "" "changedBy": ""
} }
} }

View File

@ -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>

View File

@ -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"
}
}

View File

@ -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>

View File

@ -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": ""
}
}

View File

@ -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>

View File

@ -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 {

View File

@ -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{}