mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-15 14:48:41 +01:00
Merge remote-tracking branch 'origin/master' into pr5949
# Conflicts: # cmd/wire_gen.go
This commit is contained in:
commit
bd6a3fb16e
58 changed files with 1732 additions and 543 deletions
|
|
@ -35,13 +35,14 @@ type Archiver interface {
|
|||
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
}
|
||||
|
||||
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
|
||||
return &archiver{ds: ds, ms: ms, shares: shares, artwork: artwork}
|
||||
func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
|
||||
return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork}
|
||||
}
|
||||
|
||||
type archiver struct {
|
||||
ds model.DataStore
|
||||
ms stream.MediaStreamer
|
||||
decider stream.TranscodeDecider
|
||||
shares Share
|
||||
artwork artwork.Artwork
|
||||
}
|
||||
|
|
@ -78,8 +79,9 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
|||
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder,
|
||||
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
||||
for _, mf := range album {
|
||||
file := a.albumFilename(mf, format, isMultiDisc, folder)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
req := a.resolveRequest(ctx, &mf, format, bitrate)
|
||||
file := a.albumFilename(mf, req.Format, isMultiDisc, folder)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Stop iterating: continuing would just rack up more
|
||||
// rejections from the limiter. Close finalises whatever
|
||||
// tracks were already written; the rejected one is not
|
||||
|
|
@ -204,8 +206,9 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
|||
|
||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||
for idx, mf := range mfs {
|
||||
file := a.playlistFilename(mf, format, idx)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
req := a.resolveRequest(ctx, &mf, format, bitrate)
|
||||
file := a.playlistFilename(mf, req.Format, idx)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Abort the whole archive: continuing would silently emit
|
||||
// empty zip entries since the headers are already written.
|
||||
_ = z.Close()
|
||||
|
|
@ -251,7 +254,14 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
|
|||
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
|
||||
}
|
||||
|
||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
||||
func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request {
|
||||
if format == "" || format == "raw" {
|
||||
return stream.Request{Format: "raw"}
|
||||
}
|
||||
return a.decider.ResolveRequest(ctx, mf, format, bitrate, 0)
|
||||
}
|
||||
|
||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, req stream.Request, filename string) error {
|
||||
path := mf.AbsolutePath()
|
||||
|
||||
// Open the source before writing the zip entry header so a rejection
|
||||
|
|
@ -259,13 +269,13 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
|||
// archive.
|
||||
var r io.ReadCloser
|
||||
var err error
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||
if req.Format != "raw" {
|
||||
r, err = a.ms.NewStream(ctx, &mf, req)
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.Format, err)
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ var _ = Describe("Archiver", func() {
|
|||
var (
|
||||
arch core.Archiver
|
||||
ms *mockMediaStreamer
|
||||
dc *fakeDecider
|
||||
ds *mockDataStore
|
||||
sh *mockShare
|
||||
ca *mockCoverArt
|
||||
|
|
@ -33,10 +34,11 @@ var _ = Describe("Archiver", func() {
|
|||
|
||||
BeforeEach(func() {
|
||||
ms = &mockMediaStreamer{}
|
||||
dc = &fakeDecider{}
|
||||
sh = &mockShare{}
|
||||
ds = &mockDataStore{}
|
||||
ca = &mockCoverArt{images: map[string][]byte{}}
|
||||
arch = core.NewArchiver(ms, ds, sh, ca)
|
||||
arch = core.NewArchiver(ms, dc, ds, sh, ca)
|
||||
})
|
||||
|
||||
Context("ZipAlbum", func() {
|
||||
|
|
@ -66,6 +68,23 @@ var _ = Describe("Archiver", func() {
|
|||
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
|
||||
})
|
||||
|
||||
It("streams the request resolved by the transcode decider and names the entry after its format", func() {
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{Path: "test_data/01 - track1.flac", Suffix: "flac", AlbumID: "1"}}, nil)
|
||||
ds.On("MediaFile").Return(mfRepo)
|
||||
resolved := stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2}
|
||||
dc.resolved = &resolved
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, resolved).Return(io.NopCloser(strings.NewReader("test")), nil).Once()
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipAlbum(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
|
||||
ms.AssertExpectations(GinkgoT())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(zr.File[0].Name).To(HaveSuffix("01 - track1.opus"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipArtist", func() {
|
||||
|
|
@ -296,6 +315,30 @@ var _ = Describe("Archiver", func() {
|
|||
})
|
||||
|
||||
Context("ZipPlaylist", func() {
|
||||
It("names the entries and the M3U lines after the resolved format", func() {
|
||||
pls := &model.Playlist{ID: "1", Name: "Test Playlist", Tracks: []model.PlaylistTrack{
|
||||
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.flac", Suffix: "flac", Artist: "Artist 1", Title: "track1"}},
|
||||
}}
|
||||
plRepo := &mockPlaylistRepository{}
|
||||
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
||||
ds.On("Playlist").Return(plRepo)
|
||||
dc.resolved = &stream.Request{Format: "opus", BitRate: 128}
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, *dc.resolved).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipPlaylist(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.opus"))
|
||||
m3u, err := zr.File[1].Open()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer m3u.Close()
|
||||
content, err := io.ReadAll(m3u)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(content)).To(ContainSubstring("01 - Artist 1 - track1.opus"))
|
||||
})
|
||||
|
||||
It("zips a playlist correctly", func() {
|
||||
tracks := []model.PlaylistTrack{
|
||||
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
|
||||
|
|
@ -571,6 +614,19 @@ func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile,
|
|||
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
||||
}
|
||||
|
||||
// fakeDecider echoes the legacy format/bitrate unless a resolved request is set.
|
||||
type fakeDecider struct {
|
||||
stream.TranscodeDecider
|
||||
resolved *stream.Request
|
||||
}
|
||||
|
||||
func (f *fakeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request {
|
||||
if f.resolved != nil {
|
||||
return *f.resolved
|
||||
}
|
||||
return stream.Request{Format: format, BitRate: bitRate, Offset: offset}
|
||||
}
|
||||
|
||||
type mockShare struct {
|
||||
mock.Mock
|
||||
core.Share
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ type InspectOutput struct {
|
|||
MappedTags *model.MediaFile `json:"mappedTags,omitempty"`
|
||||
}
|
||||
|
||||
func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, error) {
|
||||
func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutput, error) {
|
||||
path, file := filepath.Split(filePath)
|
||||
|
||||
s, err := storage.For(path)
|
||||
|
|
@ -39,12 +39,22 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e
|
|||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
md := metadata.New(path, tag)
|
||||
md := metadata.New(scannerPath(lib, filePath), tag)
|
||||
result := &InspectOutput{
|
||||
File: filePath,
|
||||
RawTags: tags[file].Tags,
|
||||
MappedTags: new(md.ToMediaFile(libraryId, folderId)),
|
||||
MappedTags: new(md.ToMediaFile(lib, folderId)),
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// scannerPath returns the path the scanner uses for the file (relative to its library), so
|
||||
// folder-based PIDs match the DB. Files outside the library keep their absolute path.
|
||||
func scannerPath(lib model.Library, filePath string) string {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil || lib.Path == "" {
|
||||
return filePath
|
||||
}
|
||||
return model.LibraryRelativePath(lib.Path, absPath)
|
||||
}
|
||||
|
|
|
|||
45
core/inspect_test.go
Normal file
45
core/inspect_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Inspect", func() {
|
||||
var fixtures string
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
fixtures, err = filepath.Abs(filepath.Join("tests", "fixtures"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("maps the file with the library-relative path the scanner uses", func() {
|
||||
lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"}
|
||||
out, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out.MappedTags.Path).To(Equal("fixtures/test.mp3"))
|
||||
Expect(out.MappedTags.LibraryID).To(Equal(2))
|
||||
})
|
||||
|
||||
It("gives the same IDs for relative and absolute paths", func() {
|
||||
lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"}
|
||||
abs, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rel, err := core.Inspect(filepath.Join("tests", "fixtures", "test.mp3"), lib, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(rel.MappedTags.AlbumID).To(Equal(abs.MappedTags.AlbumID))
|
||||
Expect(rel.MappedTags.PID).To(Equal(abs.MappedTags.PID))
|
||||
})
|
||||
|
||||
It("keeps the given path for a file outside the library", func() {
|
||||
filePath := filepath.Join(fixtures, "test.mp3")
|
||||
out, err := core.Inspect(filePath, model.Library{ID: model.DefaultLibraryID}, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out.MappedTags.Path).To(Equal(filePath))
|
||||
})
|
||||
})
|
||||
|
|
@ -2,10 +2,12 @@ package core
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -15,6 +17,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
|
|
@ -200,23 +203,22 @@ func (r *libraryRepositoryWrapper) Update(ctx context.Context, id string, entity
|
|||
}
|
||||
|
||||
pathChanged := originalLib.Path != lib.Path
|
||||
pidChanged := (updatesColumn(cols, "pidAlbum") && originalLib.PIDAlbum != lib.PIDAlbum) ||
|
||||
(updatesColumn(cols, "pidTrack") && originalLib.PIDTrack != lib.PIDTrack)
|
||||
|
||||
err = r.LibraryRepository.Put(ctx, lib, cols...)
|
||||
if err != nil {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
||||
// Restart watcher and trigger scan if path was updated
|
||||
if pathChanged {
|
||||
if r.watcher != nil {
|
||||
if err := r.watcher.Watch(ctx, lib); err != nil {
|
||||
log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
||||
}
|
||||
if pathChanged && r.watcher != nil {
|
||||
if err := r.watcher.Watch(ctx, lib); err != nil {
|
||||
log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.scanner != nil {
|
||||
go r.triggerScan(ctx, lib, "updated")
|
||||
}
|
||||
if (pathChanged || pidChanged) && r.scanner != nil {
|
||||
go r.triggerScan(ctx, lib, "updated")
|
||||
}
|
||||
|
||||
// Send library refresh event to all clients
|
||||
|
|
@ -325,6 +327,15 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library
|
|||
}
|
||||
}
|
||||
|
||||
library.PIDAlbum = strings.TrimSpace(library.PIDAlbum)
|
||||
library.PIDTrack = strings.TrimSpace(library.PIDTrack)
|
||||
if err := metadata.ValidatePIDSpec(library.PIDAlbum, true); err != nil {
|
||||
validationErrors["pidAlbum"] = err.Error()
|
||||
}
|
||||
if err := metadata.ValidatePIDSpec(library.PIDTrack, false); err != nil {
|
||||
validationErrors["pidTrack"] = err.Error()
|
||||
}
|
||||
|
||||
if len(validationErrors) > 0 {
|
||||
return &rest.ValidationError{Errors: validationErrors}
|
||||
}
|
||||
|
|
@ -332,6 +343,11 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library
|
|||
return nil
|
||||
}
|
||||
|
||||
// updatesColumn reports whether an update with these columns writes col. No columns means all of them.
|
||||
func updatesColumn(cols []string, col string) bool {
|
||||
return len(cols) == 0 || slices.Contains(cols, col)
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error {
|
||||
// Validate path format
|
||||
if !filepath.IsAbs(library.Path) {
|
||||
|
|
@ -399,11 +415,27 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
|
|||
return nil
|
||||
}
|
||||
|
||||
var scanWaitInterval = time.Second
|
||||
|
||||
func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) {
|
||||
// Runs in its own goroutine and outlives the HTTP request
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
|
||||
// A running scan loaded the libraries before this change, and would reject a new request
|
||||
for {
|
||||
status, err := r.scanner.Status(ctx)
|
||||
if err != nil || !status.Scanning {
|
||||
break
|
||||
}
|
||||
time.Sleep(scanWaitInterval)
|
||||
}
|
||||
|
||||
log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
|
||||
start := time.Now()
|
||||
warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan for new library
|
||||
if err != nil {
|
||||
warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan: the scanner rescans libraries with a changed PID config in full
|
||||
if errors.Is(err, model.ErrAlreadyScanning) {
|
||||
log.Debug(ctx, "Scan already running, it covers this change", "libraryID", lib.ID, "name", lib.Name)
|
||||
} else if err != nil {
|
||||
log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
|
||||
} else {
|
||||
log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))
|
||||
|
|
|
|||
|
|
@ -322,6 +322,37 @@ var _ = Describe("Library Service", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("PID validation", func() {
|
||||
pidError := func(err error, field string) string {
|
||||
var validationErr *rest.ValidationError
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
return validationErr.Errors[field]
|
||||
}
|
||||
|
||||
It("rejects an unknown attribute in the album PID", func() {
|
||||
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albmversion"})
|
||||
Expect(pidError(err, "pidAlbum")).To(ContainSubstring(`unknown attribute "albmversion"`))
|
||||
})
|
||||
|
||||
It("rejects albumid in the album PID", func() {
|
||||
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albumid"})
|
||||
Expect(pidError(err, "pidAlbum")).To(ContainSubstring("albumid"))
|
||||
})
|
||||
|
||||
It("rejects an unknown attribute in the track PID", func() {
|
||||
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDTrack: "nosuchtag"})
|
||||
Expect(pidError(err, "pidTrack")).To(ContainSubstring(`unknown attribute "nosuchtag"`))
|
||||
})
|
||||
|
||||
It("trims spaces", func() {
|
||||
library := &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: " folder ", PIDTrack: " "}
|
||||
_, err := repo.Save(ctx, library)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(library.PIDAlbum).To(Equal("folder"))
|
||||
Expect(library.PIDTrack).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Path Validation", func() {
|
||||
Context("Create operation", func() {
|
||||
It("fails when path is not absolute", func() {
|
||||
|
|
@ -679,6 +710,48 @@ var _ = Describe("Library Service", func() {
|
|||
}, "100ms", "10ms").Should(Equal(0))
|
||||
})
|
||||
|
||||
It("triggers scan when updating the library PID config", func() {
|
||||
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}})
|
||||
|
||||
library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}
|
||||
Expect(repo.Update(ctx, "1", library)).To(Succeed())
|
||||
|
||||
Eventually(func() int {
|
||||
return scanner.GetScanAllCallCount()
|
||||
}, "1s", "10ms").Should(Equal(1))
|
||||
// A quick scan: the scanner itself rescans this library in full
|
||||
Expect(scanner.GetScanAllCalls()[0].FullScan).To(BeFalse())
|
||||
})
|
||||
|
||||
It("does not trigger scan when the PID fields were not sent", func() {
|
||||
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}})
|
||||
|
||||
// The REST layer decodes a missing pidAlbum as "". Only the sent fields count.
|
||||
library := model.Library{ID: 1, Name: "Renamed", Path: tempDir}
|
||||
Expect(repo.Update(ctx, "1", library, "name", "path")).To(Succeed())
|
||||
|
||||
Consistently(func() int {
|
||||
return scanner.GetScanAllCallCount()
|
||||
}, "100ms", "10ms").Should(Equal(0))
|
||||
})
|
||||
|
||||
It("waits for a running scan before triggering a new one", func() {
|
||||
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}})
|
||||
scanner.SetScanning(true)
|
||||
|
||||
library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}
|
||||
Expect(repo.Update(ctx, "1", library)).To(Succeed())
|
||||
|
||||
Consistently(func() int {
|
||||
return scanner.GetScanAllCallCount()
|
||||
}, "200ms", "20ms").Should(Equal(0))
|
||||
|
||||
scanner.SetScanning(false)
|
||||
Eventually(func() int {
|
||||
return scanner.GetScanAllCallCount()
|
||||
}, "3s", "20ms").Should(Equal(1))
|
||||
})
|
||||
|
||||
It("does not trigger scan when library creation fails", func() {
|
||||
// Try to create library with invalid data (empty name)
|
||||
library := &model.Library{Path: tempDir}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
|
@ -269,9 +270,13 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
|
|||
if err != nil {
|
||||
log.Trace(ctx, "Error reading radios count", err)
|
||||
}
|
||||
data.Library.Libraries, err = c.ds.Library().CountAll(ctx)
|
||||
libs, err := c.ds.Library().GetAll(ctx)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading libraries count", err)
|
||||
log.Trace(ctx, "Error reading libraries", err)
|
||||
}
|
||||
data.Library.Libraries = int64(len(libs))
|
||||
if slices.ContainsFunc(libs, func(lib model.Library) bool { return lib.PIDAlbum != "" || lib.PIDTrack != "" }) {
|
||||
data.Config.HasCustomPID = true
|
||||
}
|
||||
data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@ func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folde
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matcher := newLibraryMatcher(libs)
|
||||
lib, ok := matcher.findLibrary(dir)
|
||||
matcher := model.NewLibraryMatcher(libs)
|
||||
lib, ok := matcher.FindLibrary(dir)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
package playlists
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -156,61 +154,9 @@ func (r pathResolution) ToQualifiedString() (string, error) {
|
|||
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) {
|
||||
lib, ok := lm.findLibrary(absolutePath)
|
||||
if !ok {
|
||||
return 0, ""
|
||||
}
|
||||
return lib.ID, filepath.Clean(lib.Path)
|
||||
}
|
||||
|
||||
// findLibrary checks if the absolute path is under any of the library paths.
|
||||
func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) {
|
||||
// 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], true
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.Library{}, false
|
||||
}
|
||||
|
||||
// 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
|
||||
matcher *model.LibraryMatcher
|
||||
}
|
||||
|
||||
// newPathResolver creates a pathResolver with libraries loaded from the datastore.
|
||||
|
|
@ -219,7 +165,7 @@ func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, er
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matcher := newLibraryMatcher(libs)
|
||||
matcher := model.NewLibraryMatcher(libs)
|
||||
return &pathResolver{matcher: matcher}, nil
|
||||
}
|
||||
|
||||
|
|
@ -246,14 +192,14 @@ func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolu
|
|||
// 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 {
|
||||
lib, ok := r.matcher.FindLibrary(absolutePath)
|
||||
if !ok {
|
||||
return pathResolution{valid: false}
|
||||
}
|
||||
return pathResolution{
|
||||
absolutePath: absolutePath,
|
||||
libraryPath: libPath,
|
||||
libraryID: libID,
|
||||
libraryPath: filepath.Clean(lib.Path),
|
||||
libraryID: lib.ID,
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
|
|
@ -288,7 +234,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
|
|||
// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt).
|
||||
// Local paths (file://, absolute, or relative) are resolved to an absolute path
|
||||
// and validated against known library boundaries via matcher.
|
||||
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string {
|
||||
func resolveImageURL(value string, folder *model.Folder, matcher *model.LibraryMatcher, owner model.User) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
|
|
@ -308,7 +254,7 @@ func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher
|
|||
return ""
|
||||
}
|
||||
|
||||
lib, ok := matcher.findLibrary(localPath)
|
||||
lib, ok := matcher.FindLibrary(localPath)
|
||||
// A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries.
|
||||
if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) {
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -9,187 +9,6 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("libraryMatcher", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var mockLibRepo *tests.MockLibraryRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
|
||||
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().GetAll(ctx)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/podcasts"
|
||||
"github.com/navidrome/navidrome/core/quickconnect"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
|
|
@ -36,4 +37,5 @@ var Set = wire.NewSet(
|
|||
quickconnect.GetInstance,
|
||||
metrics.GetInstance,
|
||||
lyrics.NewLyrics,
|
||||
podcasts.NewPodcastService,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue