mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-11 11:57:12 +02:00
Merge branch 'master' into listenbrainz-artist-url/6165
This commit is contained in:
commit
06e068d5b1
122 changed files with 3725 additions and 599 deletions
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
|
|
@ -549,7 +549,7 @@ jobs:
|
|||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
||||
for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
||||
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
||||
done
|
||||
|
||||
|
|
|
|||
|
|
@ -241,6 +241,10 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti
|
|||
var (
|
||||
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
||||
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
|
||||
|
||||
// Not a RetryLaterError on purpose: parking the agent would also stall its API-backed
|
||||
// methods, which the page block does not affect.
|
||||
errNoArtistPage = errors.New("no artist image in Last.fm page")
|
||||
)
|
||||
|
||||
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
|
|
@ -267,7 +271,9 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
|
|||
var res []agents.ExternalImage
|
||||
n := cascadia.Query(node, artistOpenGraphQuery)
|
||||
if n == nil {
|
||||
return res, nil
|
||||
// A real artist page always has og:image; its absence means a bot challenge or a redesign.
|
||||
log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL)
|
||||
return nil, errNoArtistPage
|
||||
}
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Key != "content" {
|
||||
|
|
|
|||
|
|
@ -648,18 +648,41 @@ var _ = Describe("lastfmAgent", func() {
|
|||
Expect(images).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty list if page has no meta tags", func() {
|
||||
It("errors when the page has no meta tags", func() {
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
|
||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
||||
|
||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
Expect(err).To(MatchError(errNoArtistPage))
|
||||
})
|
||||
|
||||
It("errors when Last.fm serves a bot challenge page", func() {
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
|
||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
||||
|
||||
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(err).To(MatchError(errNoArtistPage))
|
||||
Expect(images).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not park the agent: the failure is not a retry-later", func() {
|
||||
// A RetryLaterError would cool down the agent's API-backed methods too.
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
|
||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
||||
|
||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns error if API call fails", func() {
|
||||
apiClient.Err = errors.New("api error")
|
||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
|
|
|
|||
28
cmd/root.go
28
cmd/root.go
|
|
@ -375,10 +375,26 @@ func startPlaybackServer(ctx context.Context) func() error {
|
|||
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
|
||||
return func() error {
|
||||
log.Info(ctx, "Starting artwork worker")
|
||||
// The scanner writes to the DB for its whole run; competing for the write lock makes both fail.
|
||||
worker.PauseWhile(scanner.IsScanning)
|
||||
return worker.Run(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// outsideScan runs a DB maintenance job unless a scan is running, and keeps a scan from starting
|
||||
// until it ends; both write to the DB, and competing for the lock can make either fail.
|
||||
func outsideScan(ctx context.Context, job string, run func(context.Context) error) {
|
||||
release, ok := scanner.LockForMaintenance()
|
||||
if !ok {
|
||||
log.Debug(ctx, "Skipping "+job+" because a scan is in progress")
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
if err := run(ctx); err != nil {
|
||||
log.Error(ctx, "Error running "+job, err)
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and
|
||||
// reports an artwork config change without acting on it.
|
||||
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
|
||||
|
|
@ -386,26 +402,20 @@ func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) fu
|
|||
schedulerInstance := scheduler.GetInstance()
|
||||
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() {
|
||||
if err := worker.EnqueueMissingAll(ctx); err != nil {
|
||||
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
|
||||
}
|
||||
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
|
||||
}); err != nil {
|
||||
log.Error(ctx, "Error scheduling artwork missing-state recheck", err)
|
||||
}
|
||||
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
|
||||
if err := worker.RunPrune(ctx); err != nil {
|
||||
log.Error(ctx, "Error running artwork prune", err)
|
||||
}
|
||||
outsideScan(ctx, "artwork prune", worker.RunPrune)
|
||||
}); err != nil {
|
||||
log.Error(ctx, "Error scheduling artwork prune", err)
|
||||
}
|
||||
|
||||
// Also run the missing-row recheck once at startup so a never-scanned entity is picked up
|
||||
// immediately, not only on the next hourly tick (e.g. after enabling the feature).
|
||||
if err := worker.EnqueueMissingAll(ctx); err != nil {
|
||||
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
|
||||
}
|
||||
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
|
||||
|
||||
if err := worker.ReconcileConfig(ctx); err != nil {
|
||||
log.Error(ctx, "Error checking the artwork config fingerprint", err)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ RestrictNamespaces=yes
|
|||
RestrictRealtime=yes
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged @resources
|
||||
SystemCallFilter=setrlimit
|
||||
SystemCallFilter=setrlimit mbind
|
||||
SystemCallArchitectures=native
|
||||
UMask=0066
|
||||
|
||||
|
|
|
|||
|
|
@ -168,6 +168,11 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode
|
|||
if !entityExists(ctx, s.ds, artID) {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
// Checked here, not in openOriginal: a resize-cache hit never opens the source.
|
||||
if isFileBacked(ia.Source) && !model.IsImageFile(ia.SourcePath) {
|
||||
log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath)
|
||||
return s.dangling(ctx, artID)
|
||||
}
|
||||
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
|
|
|
|||
|
|
@ -2,18 +2,21 @@ package artwork
|
|||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/tests"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"go.uber.org/goleak"
|
||||
|
|
@ -37,6 +40,14 @@ func TestArtwork(t *testing.T) {
|
|||
RunSpecs(t, "Artwork Suite")
|
||||
}
|
||||
|
||||
// productionImageClient keeps the guarded client for the specs that assert it refuses loopback.
|
||||
var productionImageClient = remoteImageClient
|
||||
|
||||
// httptest servers listen on loopback, which the production client refuses.
|
||||
var _ = BeforeSuite(func() {
|
||||
remoteImageClient = httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128"))
|
||||
})
|
||||
|
||||
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
|
||||
type osDirFS struct{ fs.FS }
|
||||
|
||||
|
|
|
|||
|
|
@ -159,6 +159,53 @@ var _ = Describe("Artwork", func() {
|
|||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("treats a file-backed row pointing at a non-image file as dangling", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
secretPath := filepath.Join(dir, "config.ini")
|
||||
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "alni")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd",
|
||||
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
||||
})).To(Succeed())
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alni"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "alni")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||
})
|
||||
|
||||
It("refuses a non-image file-backed row even when a resized copy is already cached", func() {
|
||||
secret := []byte("password=secret")
|
||||
dir := GinkgoT().TempDir()
|
||||
secretPath := filepath.Join(dir, "config.ini")
|
||||
Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "alnic")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee",
|
||||
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
||||
})).To(Succeed())
|
||||
|
||||
// Older versions cached the raw bytes when the resize failed.
|
||||
seed := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(secret)), nil }
|
||||
stream, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, open: seed, ffmpeg: ffm})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(io.ReadAll(stream)).To(Equal(secret))
|
||||
Expect(stream.Close()).To(Succeed())
|
||||
Eventually(func(g Gomega) {
|
||||
s, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, ffmpeg: ffm,
|
||||
open: func() (io.ReadCloser, error) { return nil, os.ErrNotExist }})
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
g.Expect(s.Cached).To(BeTrue())
|
||||
_ = s.Close()
|
||||
}).Should(Succeed())
|
||||
|
||||
_, err = svc.Get(ctx, model.MustParseArtworkID("al-alnic"), 100, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "alnic")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||
})
|
||||
|
||||
It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bo
|
|||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- worker.Run(runCtx) }()
|
||||
Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue())
|
||||
// Long enough for one retry (3-7s backoff, 5s poll tick).
|
||||
Eventually(until, 15*time.Second, 10*time.Millisecond).Should(BeTrue())
|
||||
cancel()
|
||||
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error {
|
|||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), 0640); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), dst)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,17 @@ var _ = Describe("ImageStore", func() {
|
|||
Expect(got).To(Equal(data))
|
||||
})
|
||||
|
||||
It("writes group-readable image files", func() {
|
||||
tests.SkipOnWindows("uses Unix file permission bits")
|
||||
data := []byte("jpeg-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
|
||||
info, err := os.Stat(store.path(h, "image/jpeg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0640)))
|
||||
})
|
||||
|
||||
It("is idempotent on duplicate writes and preserves the original content", func() {
|
||||
data := []byte("dup")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
|
|
|
|||
|
|
@ -572,7 +572,7 @@ func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFold
|
|||
// resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other
|
||||
// open failure says nothing about whether the image exists.
|
||||
func resolveLocalFile(path, source string) (resolution, bool) {
|
||||
if path == "" {
|
||||
if path == "" || !model.IsImageFile(path) {
|
||||
return resolution{}, false
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
|
|
|
|||
|
|
@ -498,6 +498,23 @@ var _ = Describe("resolveItem", func() {
|
|||
Expect(res.refMtime).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("never opens a local ExternalImageURL that is not an image file", func() {
|
||||
folderRepo.result = nil // no grid tiles, so only the local file could produce a reader
|
||||
dir := GinkgoT().TempDir()
|
||||
secretPath := filepath.Join(dir, "config.ini")
|
||||
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "plni", Name: "Playlist", ExternalImageURL: secretPath}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plni"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.sourcePath).ToNot(Equal(secretPath))
|
||||
})
|
||||
|
||||
It("routes ExternalImageURL through extGate and sets extError on transient failure", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
"github.com/navidrome/navidrome/utils/netguard"
|
||||
"go.senan.xyz/taglib"
|
||||
)
|
||||
|
||||
|
|
@ -37,7 +38,7 @@ func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern
|
|||
log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file)
|
||||
continue
|
||||
}
|
||||
if !match {
|
||||
if !match || !model.IsImageFile(name) {
|
||||
continue
|
||||
}
|
||||
f, err := libFS.Open(file)
|
||||
|
|
@ -150,10 +151,18 @@ type readCloser struct {
|
|||
io.Closer
|
||||
}
|
||||
|
||||
// remoteImageClient fetches URLs from playlists and agents (plugins included), so it must not reach
|
||||
// internal hosts. Shared so fetches reuse connections.
|
||||
var remoteImageClient = httpclient.NewExternal(5 * time.Second)
|
||||
|
||||
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
|
||||
hc := httpclient.New(5 * time.Second)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
|
||||
resp, err := hc.Do(req) //nolint:gosec
|
||||
resp, err := remoteImageClient.Do(req)
|
||||
if errors.Is(err, netguard.ErrPrivateAddress) {
|
||||
// Retrying cannot change where the URL points: settle absent instead of tripping the breaker.
|
||||
log.Warn(ctx, "Artwork: Refused to fetch image from a private or loopback address", "url", imageUrl, err)
|
||||
return nil, "", model.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,87 @@ import (
|
|||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("fromURL", func() {
|
||||
var (
|
||||
hits atomic.Int32
|
||||
target *httptest.Server
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
hits.Store(0)
|
||||
target = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
_, _ = w.Write([]byte("image-bytes"))
|
||||
}))
|
||||
DeferCleanup(target.Close)
|
||||
})
|
||||
|
||||
useClient := func(c *http.Client) {
|
||||
prev := remoteImageClient
|
||||
remoteImageClient = c
|
||||
DeferCleanup(func() { remoteImageClient = prev })
|
||||
}
|
||||
fetch := func(rawURL string) ([]byte, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, _, err := fromURL(GinkgoT().Context(), u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
}
|
||||
// Stand-in for a public host: only 127.0.0.1 is allowed, so every other private address stays refused.
|
||||
onlyLocalhostV4 := func() *http.Client {
|
||||
return httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.1/32"))
|
||||
}
|
||||
|
||||
DescribeTable("refuses private and loopback targets as a definitive miss",
|
||||
func(rawURL string) {
|
||||
useClient(productionImageClient)
|
||||
u, _ := url.Parse(target.URL)
|
||||
_, err := fetch(strings.ReplaceAll(rawURL, "PORT", u.Port()))
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(hits.Load()).To(BeZero())
|
||||
},
|
||||
Entry("IPv4 loopback", "http://127.0.0.1:PORT/x"),
|
||||
Entry("localhost", "http://localhost:PORT/x"),
|
||||
Entry("cloud metadata", "http://169.254.169.254/"),
|
||||
Entry("IPv6 loopback", "http://[::1]/"),
|
||||
)
|
||||
|
||||
It("refuses a redirect from an allowed host to a loopback address", func() {
|
||||
useClient(onlyLocalhostV4())
|
||||
redirector := httptest.NewServer(http.RedirectHandler(strings.Replace(target.URL, "127.0.0.1", "127.0.0.2", 1), http.StatusFound))
|
||||
DeferCleanup(redirector.Close)
|
||||
|
||||
_, err := fetch(redirector.URL)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(hits.Load()).To(BeZero())
|
||||
})
|
||||
|
||||
It("fetches from an allowed address", func() {
|
||||
useClient(onlyLocalhostV4())
|
||||
Expect(fetch(target.URL + "/cover.jpg")).To(Equal([]byte("image-bytes")))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("fromExternalFile", func() {
|
||||
It("opens a matching file via the library FS", func() {
|
||||
fsys := fstest.MapFS{
|
||||
|
|
@ -48,6 +122,18 @@ var _ = Describe("fromExternalFile", func() {
|
|||
Expect(b).To(Equal([]byte("a")))
|
||||
Expect(path).To(Equal("a/cover.jpg"))
|
||||
})
|
||||
|
||||
It("skips a matching file that is not an image", func() {
|
||||
fsys := fstest.MapFS{
|
||||
"a/cover.ini": &fstest.MapFile{Data: []byte("password=secret")},
|
||||
"a/cover.jpg": &fstest.MapFile{Data: []byte("a")},
|
||||
}
|
||||
f := fromExternalFile(GinkgoT().Context(), fsys, []string{"a/cover.ini", "a/cover.jpg"}, "cover.*")
|
||||
r, path, err := f()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer r.Close()
|
||||
Expect(path).To(Equal("a/cover.jpg"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("fromTag", func() {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ type Worker struct {
|
|||
pruneMu sync.RWMutex
|
||||
pools []*drainPool
|
||||
runCtx context.Context
|
||||
paused func() bool
|
||||
|
||||
gatesMu sync.Mutex
|
||||
gates map[string]*extGate
|
||||
|
|
@ -59,6 +60,7 @@ func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg
|
|||
broker: broker,
|
||||
pools: newDrainPools(),
|
||||
runCtx: context.Background(),
|
||||
paused: func() bool { return false },
|
||||
gates: map[string]*extGate{},
|
||||
}
|
||||
w.proc.resolver = newResolver(ds, ag, ffmpeg, w.gate)
|
||||
|
|
@ -90,6 +92,11 @@ var (
|
|||
}
|
||||
)
|
||||
|
||||
// PauseWhile holds off queue draining whenever paused reports true. Call it before Run.
|
||||
func (w *Worker) PauseWhile(paused func() bool) {
|
||||
w.paused = paused
|
||||
}
|
||||
|
||||
// Run blocks draining the queue until ctx is cancelled.
|
||||
func (w *Worker) Run(ctx context.Context) error {
|
||||
w.runCtx = ctx
|
||||
|
|
@ -143,6 +150,9 @@ func (w *Worker) EnqueueMissingAll(ctx context.Context) error {
|
|||
}
|
||||
|
||||
func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (int, error) {
|
||||
if w.paused() {
|
||||
return 0, nil
|
||||
}
|
||||
// Dequeue well past the pool size so a slow external lookup never idles the other slots.
|
||||
// DequeueBatch does not mark rows taken, so this is one query per pass, not per slot.
|
||||
items, err := w.proc.ds.ArtworkQueue(ctx).DequeueBatch(max(16, 4*concurrency), kinds...)
|
||||
|
|
@ -170,6 +180,9 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i
|
|||
wg.Wait()
|
||||
return len(items), nil //nolint:nilerr // a cancelled drain is a clean stop, not an error
|
||||
}
|
||||
if w.paused() {
|
||||
break
|
||||
}
|
||||
wg.Go(func() {
|
||||
defer func() { <-sem }()
|
||||
out, got := w.process(ctx, item)
|
||||
|
|
|
|||
|
|
@ -866,6 +866,34 @@ var _ = Describe("Worker", func() {
|
|||
}
|
||||
})
|
||||
|
||||
It("stops dispatching and leaves the rest queued when paused mid-batch", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
albums := model.Albums{}
|
||||
for i := range 8 {
|
||||
id := fmt.Sprintf("alp%d", i)
|
||||
albums = append(albums, model.Album{ID: id, Name: "Album", FolderIDs: []string{"f1"}})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums)
|
||||
// Pauses as soon as the first item has left the queue.
|
||||
w.PauseWhile(func() bool {
|
||||
n, _ := queueRepo.Count()
|
||||
return n < 8
|
||||
})
|
||||
|
||||
_, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(7)), "only the item dispatched before the pause may leave the queue")
|
||||
})
|
||||
|
||||
It("dequeues past the worker pool so one drain covers many items", func() {
|
||||
for i := range 16 {
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}})
|
||||
|
|
@ -896,6 +924,30 @@ var _ = Describe("Worker", func() {
|
|||
Eventually(done, time.Second).Should(Receive(BeNil()))
|
||||
})
|
||||
|
||||
It("does not drain the queue while paused", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
w.PauseWhile(func() bool { return true })
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- w.Run(runCtx) }()
|
||||
DeferCleanup(func() {
|
||||
cancel()
|
||||
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
||||
})
|
||||
|
||||
Consistently(func() any { return findQueued(queueRepo, "al", "al1") }, 300*time.Millisecond).ShouldNot(BeNil())
|
||||
})
|
||||
|
||||
It("does not leak goroutines after Run exits", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip
|
|||
user, _ := request.UserFrom(ctx)
|
||||
if playerID != "" {
|
||||
plr, err = p.ds.Player(ctx).Get(playerID)
|
||||
if err == nil && plr.Client != client {
|
||||
if err == nil && (plr.Client != client || plr.UserId != user.ID) {
|
||||
playerID = ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,8 +61,19 @@ var _ = Describe("Players", func() {
|
|||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("does not reuse another user's player by ID", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "otheruser", UserAgent: "Pixel", TranscodingId: "1"}
|
||||
repo.add(plr)
|
||||
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).ToNot(Equal("123"))
|
||||
Expect(p.UserId).To(Equal("userid"))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("finds players by ID", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}}
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -93,7 +104,7 @@ var _ = Describe("Players", func() {
|
|||
})
|
||||
|
||||
It("finds player by ID and return its transcoding", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"}
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}, TranscodingId: "1"}
|
||||
repo.add(plr)
|
||||
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
|
|||
|
|
@ -236,6 +236,24 @@ var _ = Describe("Playlists - Import", func() {
|
|||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("rejects #EXTALBUMARTURL pointing at a non-image file inside the library", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(tmpDir, "config.ini"), []byte("password=secret"), 0600)).To(Succeed())
|
||||
|
||||
m3u := "#EXTALBUMARTURL:config.ini\ntest.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
|
|
@ -1011,6 +1029,19 @@ var _ = Describe("Playlists - Import", func() {
|
|||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
|
||||
DescribeTable("restricts a local #EXTALBUMARTURL to the owner's libraries",
|
||||
func(imageURL, expected string) {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123", Libraries: model.Libraries{{ID: 1, Path: "/music"}}})
|
||||
repo.data = []string{"tests/test.mp3"}
|
||||
m3u := "#EXTALBUMARTURL:" + imageURL + "\n/music/tests/test.mp3\n"
|
||||
pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(Equal(expected))
|
||||
},
|
||||
Entry("accepts a library the owner can access", "file:///music/cover.jpg", filepath.Clean("/music/cover.jpg")),
|
||||
Entry("ignores a library the owner cannot access", "file:///new/cover.jpg", ""),
|
||||
)
|
||||
|
||||
// Fullwidth characters (e.g., ABCD) are not handled by SQLite's NOCASE collation,
|
||||
// so we need exact matching for non-ASCII characters.
|
||||
It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
|
@ -36,7 +37,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
|||
continue
|
||||
}
|
||||
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
|
||||
pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher)
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher, owner)
|
||||
continue
|
||||
}
|
||||
// Skip empty lines and extended info
|
||||
|
|
@ -286,7 +288,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) string {
|
||||
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
|
|
@ -302,12 +304,13 @@ func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher
|
|||
|
||||
// Resolve to local absolute path
|
||||
localPath, ok := resolveLocalPath(value, folder)
|
||||
if !ok {
|
||||
if !ok || !model.IsImageFile(localPath) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Validate path is within a known library
|
||||
if libID, _ := matcher.findLibraryForPath(localPath); libID == 0 {
|
||||
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 ""
|
||||
}
|
||||
return localPath
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -444,8 +445,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
|||
return nil
|
||||
}
|
||||
|
||||
func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) {
|
||||
func (p *playTracker) GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) {
|
||||
// The cache is process-global, so it holds every user's playback, across all libraries.
|
||||
res := p.playMap.Values()
|
||||
if user, ok := request.UserFrom(ctx); ok {
|
||||
res = slice.Filter(res, func(s PlaybackSession) bool {
|
||||
return user.HasLibraryAccess(s.MediaFile.LibraryID)
|
||||
})
|
||||
}
|
||||
slices.SortFunc(res, func(a, b PlaybackSession) int {
|
||||
return b.Start.Compare(a.Start)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ var _ = Describe("PlayTracker", func() {
|
|||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = GinkgoT().Context()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1", Libraries: model.Libraries{{ID: 1}}})
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
||||
ds = &tests.MockDataStore{}
|
||||
fake = &fakeScrobbler{Authorized: true}
|
||||
|
|
@ -108,6 +108,7 @@ var _ = Describe("PlayTracker", func() {
|
|||
|
||||
track = model.MediaFile{
|
||||
ID: "123",
|
||||
LibraryID: 1,
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
AlbumID: "al-1",
|
||||
|
|
@ -174,6 +175,46 @@ var _ = Describe("PlayTracker", func() {
|
|||
Expect(playing[1].Username).To(Equal("user-1"))
|
||||
Expect(playing[1].MediaFile.ID).To(Equal("123"))
|
||||
})
|
||||
|
||||
It("hides sessions playing from libraries the caller cannot access", func() {
|
||||
hidden := track
|
||||
hidden.ID = "789"
|
||||
hidden.LibraryID = 2
|
||||
_ = ds.MediaFile(ctx).Put(&hidden)
|
||||
reporter := request.WithPlayer(
|
||||
request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}),
|
||||
model.Player{ScrobbleEnabled: true},
|
||||
)
|
||||
_ = tracker.ReportPlayback(reporter, ReportPlaybackParams{
|
||||
MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
|
||||
})
|
||||
|
||||
playing, err := tracker.GetNowPlaying(ctx)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playing).To(BeEmpty(), "u-1 is granted library 1 only")
|
||||
})
|
||||
|
||||
It("shows every session to an admin", func() {
|
||||
hidden := track
|
||||
hidden.ID = "789"
|
||||
hidden.LibraryID = 2
|
||||
_ = ds.MediaFile(ctx).Put(&hidden)
|
||||
reporter := request.WithPlayer(
|
||||
request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}),
|
||||
model.Player{ScrobbleEnabled: true},
|
||||
)
|
||||
_ = tracker.ReportPlayback(reporter, ReportPlaybackParams{
|
||||
MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
|
||||
})
|
||||
|
||||
adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "adm", IsAdmin: true})
|
||||
playing, err := tracker.GetNowPlaying(adminCtx)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playing).To(HaveLen(1))
|
||||
Expect(playing[0].MediaFile.ID).To(Equal("789"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Expiration events", func() {
|
||||
|
|
|
|||
6
db/db.go
6
db/db.go
|
|
@ -133,6 +133,12 @@ func ErrorCodes(err error) (code, extended int, ok bool) {
|
|||
return int(se.Code), int(se.ExtendedCode), true
|
||||
}
|
||||
|
||||
// IsBusy reports whether err is SQLITE_BUSY, including BUSY_SNAPSHOT, which only a new transaction clears.
|
||||
func IsBusy(err error) bool {
|
||||
code, _, ok := ErrorCodes(err)
|
||||
return ok && code == int(sqlite3.ErrBusy)
|
||||
}
|
||||
|
||||
type statusLogger struct{ numPending int }
|
||||
|
||||
func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) }
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@ package db_test
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
|
@ -19,6 +23,18 @@ func TestDB(t *testing.T) {
|
|||
RunSpecs(t, "DB Suite")
|
||||
}
|
||||
|
||||
var _ = DescribeTable("IsBusy",
|
||||
func(err error, expected bool) {
|
||||
Expect(db.IsBusy(err)).To(Equal(expected))
|
||||
},
|
||||
Entry("SQLITE_BUSY", sqlite3.Error{Code: sqlite3.ErrBusy}, true),
|
||||
Entry("SQLITE_BUSY_SNAPSHOT", sqlite3.Error{Code: sqlite3.ErrBusy, ExtendedCode: sqlite3.ErrBusySnapshot}, true),
|
||||
Entry("a wrapped SQLITE_BUSY", fmt.Errorf("persisting: %w", sqlite3.Error{Code: sqlite3.ErrBusy}), true),
|
||||
Entry("another SQLite error", sqlite3.Error{Code: sqlite3.ErrConstraint}, false),
|
||||
Entry("a non-SQLite error", errors.New("database is locked"), false),
|
||||
Entry("nil", nil, false),
|
||||
)
|
||||
|
||||
var _ = Describe("IsSchemaEmpty", func() {
|
||||
var database *sql.DB
|
||||
var ctx context.Context
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
-- 32-bit builds cannot read values above the int32 range written by 64-bit builds.
|
||||
update media_file set track_number = 0
|
||||
where track_number < 0 or track_number > 2147483647;
|
||||
|
||||
update media_file set disc_number = 0
|
||||
where disc_number < 0 or disc_number > 2147483647;
|
||||
|
||||
update media_file set bpm = null
|
||||
where bpm < 0 or bpm > 2147483647;
|
||||
|
||||
update album set discs = (
|
||||
select json_group_object(key, value) from json_each(album.discs)
|
||||
where cast(key as integer) between 0 and 2147483647
|
||||
)
|
||||
where json_valid(discs) and exists (
|
||||
select 1 from json_each(album.discs)
|
||||
where cast(key as integer) not between 0 and 2147483647
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
SELECT 1;
|
||||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -134,16 +135,65 @@ func optimizeAt(ctx context.Context, db *sql.DB, now time.Time) error {
|
|||
return recordAnalyzeError(ctx, db, now, fmt.Errorf("marking ANALYZE pending: %w", err))
|
||||
}
|
||||
log.Debug(ctx, "Refreshing query planner statistics")
|
||||
_, err := db.ExecContext(ctx, "ANALYZE")
|
||||
if err != nil {
|
||||
if err := analyzeInSteps(ctx, db); err != nil {
|
||||
return recordAnalyzeError(ctx, db, now, fmt.Errorf("running ANALYZE: %w", err))
|
||||
}
|
||||
if err = recordAnalyzeSuccess(ctx, db, now); err != nil {
|
||||
if err := recordAnalyzeSuccess(ctx, db, now); err != nil {
|
||||
return recordAnalyzeError(ctx, db, now, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// One ANALYZE per index (whole table if WITHOUT ROWID or lacking a non-partial index) yields the
|
||||
// same sqlite_stat1 rows as a full ANALYZE, but frees the write lock between steps.
|
||||
const analyzeTargetsSQL = `
|
||||
SELECT i.name FROM sqlite_schema i JOIN pragma_table_list t ON t.schema = 'main' AND t.name = i.tbl_name
|
||||
WHERE i.type = 'index' AND t.wr = 0 AND EXISTS (SELECT 1 FROM pragma_index_list(t.name) l WHERE l.partial = 0)
|
||||
UNION ALL
|
||||
SELECT t.name FROM pragma_table_list t
|
||||
WHERE t.schema = 'main' AND t.type IN ('table', 'shadow') AND t.name NOT LIKE 'sqlite_%'
|
||||
AND (t.wr = 1 OR NOT EXISTS (SELECT 1 FROM pragma_index_list(t.name) l WHERE l.partial = 0))`
|
||||
|
||||
// analyzeMaxYield is just above SQLite's longest busy-handler sleep, so every waiting writer
|
||||
// retries during the pause.
|
||||
const analyzeMaxYield = 150 * time.Millisecond
|
||||
|
||||
func analyzeInSteps(ctx context.Context, db *sql.DB) error {
|
||||
targets, err := analyzeTargets(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, target := range targets {
|
||||
start := time.Now()
|
||||
if _, err := db.ExecContext(ctx, `ANALYZE "`+strings.ReplaceAll(target, `"`, `""`)+`"`); err != nil {
|
||||
return fmt.Errorf("analyzing %s: %w", target, err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(min(time.Since(start), analyzeMaxYield)):
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func analyzeTargets(ctx context.Context, db *sql.DB) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx, analyzeTargetsSQL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing ANALYZE targets: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var targets []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, fmt.Errorf("listing ANALYZE targets: %w", err)
|
||||
}
|
||||
targets = append(targets, name)
|
||||
}
|
||||
return targets, rows.Err()
|
||||
}
|
||||
|
||||
func recordAnalyzeSuccess(ctx context.Context, db *sql.DB, now time.Time) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,43 @@ var _ = Describe("Optimize", func() {
|
|||
Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0"))
|
||||
})
|
||||
|
||||
It("produces the same statistics as a single full ANALYZE", func() {
|
||||
putProperty(consts.DBAnalyzePendingKey, "1")
|
||||
for _, stmt := range []string{
|
||||
"create table unindexed(id integer primary key, v int)",
|
||||
"insert into unindexed(v) select flag from analyze_probe",
|
||||
"create table no_rowid(k text primary key, v int) without rowid",
|
||||
"insert into no_rowid select 'k' || id, id % 7 from analyze_probe",
|
||||
"create index no_rowid_v on no_rowid(v)",
|
||||
"create table partial_only(id integer primary key, v int)",
|
||||
"insert into partial_only(v) select id % 5 from analyze_probe",
|
||||
"create index partial_only_v on partial_only(v) where v = 1",
|
||||
"analyze",
|
||||
} {
|
||||
_, err := database.Exec(stmt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
statRows := func() []string {
|
||||
rows, err := database.Query("select tbl || '|' || coalesce(idx, '') || '|' || stat from sqlite_stat1 order by 1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer rows.Close()
|
||||
var res []string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
Expect(rows.Scan(&s)).To(Succeed())
|
||||
res = append(res, s)
|
||||
}
|
||||
return res
|
||||
}
|
||||
fullAnalyze := statRows()
|
||||
_, err := database.Exec("delete from sqlite_stat1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(db.OptimizeDBAt(ctx, database, now)).To(Succeed())
|
||||
|
||||
Expect(statRows()).To(Equal(fullAnalyze))
|
||||
})
|
||||
|
||||
It("runs when no previous analysis was recorded", func() {
|
||||
ran, err := db.OptimizeDBIfNeeded(ctx, database, now)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
|
|||
|
|
@ -64,18 +64,21 @@ func (c Criteria) IsPercentageLimit() bool {
|
|||
}
|
||||
|
||||
func (c Criteria) ChildPlaylistIds() []string {
|
||||
if c.Expression == nil {
|
||||
return nil
|
||||
}
|
||||
return c.childPlaylistRefs(conjunction.ChildPlaylistIds)
|
||||
}
|
||||
|
||||
func (c Criteria) ChildPlaylistPaths() []string {
|
||||
return c.childPlaylistRefs(conjunction.ChildPlaylistPaths)
|
||||
}
|
||||
|
||||
func (c Criteria) childPlaylistRefs(extract func(conjunction) []string) []string {
|
||||
parent, ok := c.Expression.(conjunction)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := parent.ChildPlaylistIds()
|
||||
slices.Sort(ids)
|
||||
return slices.Compact(ids)
|
||||
refs := extract(parent)
|
||||
slices.Sort(refs)
|
||||
return slices.Compact(refs)
|
||||
}
|
||||
|
||||
func (c Criteria) MarshalJSON() ([]byte, error) {
|
||||
|
|
|
|||
|
|
@ -323,19 +323,23 @@ var _ = Describe("Criteria", func() {
|
|||
|
||||
Context("with child playlists", func() {
|
||||
var (
|
||||
topLevelInPlaylistID string
|
||||
topLevelNotInPlaylistID string
|
||||
nestedAnyInPlaylistID string
|
||||
nestedAnyNotInPlaylistID string
|
||||
nestedAllInPlaylistID string
|
||||
nestedAllNotInPlaylistID string
|
||||
topLevelInPlaylistID string
|
||||
topLevelInPlaylistPath string
|
||||
topLevelNotInPlaylistID string
|
||||
nestedAnyInPlaylistID string
|
||||
nestedAnyNotInPlaylistID string
|
||||
nestedAllInPlaylistID string
|
||||
nestedAllNotInPlaylistID string
|
||||
nestedAnyNotInPlaylistPath string
|
||||
)
|
||||
BeforeEach(func() {
|
||||
topLevelInPlaylistID = uuid.NewString()
|
||||
topLevelInPlaylistPath = "./test.nsp"
|
||||
topLevelNotInPlaylistID = uuid.NewString()
|
||||
|
||||
nestedAnyInPlaylistID = uuid.NewString()
|
||||
nestedAnyNotInPlaylistID = uuid.NewString()
|
||||
nestedAnyNotInPlaylistPath = "../not-in-playlist.m3u"
|
||||
|
||||
nestedAllInPlaylistID = uuid.NewString()
|
||||
nestedAllNotInPlaylistID = uuid.NewString()
|
||||
|
|
@ -343,10 +347,12 @@ var _ = Describe("Criteria", func() {
|
|||
goObj = Criteria{
|
||||
Expression: All{
|
||||
InPlaylist{"id": topLevelInPlaylistID},
|
||||
InPlaylist{"path": topLevelInPlaylistPath},
|
||||
NotInPlaylist{"id": topLevelNotInPlaylistID},
|
||||
Any{
|
||||
InPlaylist{"id": nestedAnyInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
|
||||
NotInPlaylist{"path": nestedAnyNotInPlaylistPath},
|
||||
},
|
||||
All{
|
||||
InPlaylist{"id": nestedAllInPlaylistID},
|
||||
|
|
@ -359,6 +365,18 @@ var _ = Describe("Criteria", func() {
|
|||
ids := goObj.ChildPlaylistIds()
|
||||
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
|
||||
})
|
||||
It("extracts all child smart playlist paths from expression criteria", func() {
|
||||
paths := goObj.ChildPlaylistPaths()
|
||||
gomega.Expect(paths).To(gomega.ConsistOf(topLevelInPlaylistPath, nestedAnyNotInPlaylistPath))
|
||||
})
|
||||
It("ignores empty child playlist paths", func() {
|
||||
c := Criteria{Expression: All{InPlaylist{"path": ""}, NotInPlaylist{"path": ""}}}
|
||||
gomega.Expect(c.ChildPlaylistPaths()).To(gomega.BeEmpty())
|
||||
})
|
||||
It("ignores empty child playlist ids", func() {
|
||||
c := Criteria{Expression: All{InPlaylist{"id": ""}, NotInPlaylist{"id": ""}}}
|
||||
gomega.Expect(c.ChildPlaylistIds()).To(gomega.BeEmpty())
|
||||
})
|
||||
It("extracts child smart playlist IDs from deeply nested expression", func() {
|
||||
goObj = Criteria{
|
||||
Expression: Any{
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
package criteria
|
||||
|
||||
// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively
|
||||
// Conjunctions need to implement this interface, to allow Criteria to extract child playlist references recursively
|
||||
type conjunction interface {
|
||||
ChildPlaylistIds() []string
|
||||
ChildPlaylistPaths() []string
|
||||
}
|
||||
|
||||
type (
|
||||
|
|
@ -16,9 +17,9 @@ func (all All) MarshalJSON() ([]byte, error) {
|
|||
return marshalConjunction("all", all)
|
||||
}
|
||||
|
||||
func (all All) ChildPlaylistIds() (ids []string) {
|
||||
return extractPlaylistIds(all)
|
||||
}
|
||||
func (all All) ChildPlaylistIds() []string { return extractPlaylistField(all, "id") }
|
||||
|
||||
func (all All) ChildPlaylistPaths() []string { return extractPlaylistField(all, "path") }
|
||||
|
||||
type (
|
||||
Any []Expression
|
||||
|
|
@ -31,9 +32,9 @@ func (any Any) MarshalJSON() ([]byte, error) {
|
|||
return marshalConjunction("any", any)
|
||||
}
|
||||
|
||||
func (any Any) ChildPlaylistIds() (ids []string) {
|
||||
return extractPlaylistIds(any)
|
||||
}
|
||||
func (any Any) ChildPlaylistIds() []string { return extractPlaylistField(any, "id") }
|
||||
|
||||
func (any Any) ChildPlaylistPaths() []string { return extractPlaylistField(any, "path") }
|
||||
|
||||
type Is map[string]any
|
||||
type Eq = Is
|
||||
|
|
@ -172,28 +173,20 @@ func (ip IsPresent) MarshalJSON() ([]byte, error) {
|
|||
|
||||
func (ip IsPresent) fields() map[string]any { return ip }
|
||||
|
||||
func extractPlaylistIds(inputRule any) (ids []string) {
|
||||
var id string
|
||||
var ok bool
|
||||
|
||||
func extractPlaylistField(inputRule any, field string) (values []string) {
|
||||
switch rule := inputRule.(type) {
|
||||
case Any:
|
||||
for _, rules := range rule {
|
||||
ids = append(ids, extractPlaylistIds(rules)...)
|
||||
values = append(values, extractPlaylistField(rules, field)...)
|
||||
}
|
||||
case All:
|
||||
for _, rules := range rule {
|
||||
ids = append(ids, extractPlaylistIds(rules)...)
|
||||
values = append(values, extractPlaylistField(rules, field)...)
|
||||
}
|
||||
case InPlaylist:
|
||||
if id, ok = rule["id"].(string); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
case NotInPlaylist:
|
||||
if id, ok = rule["id"].(string); ok {
|
||||
ids = append(ids, id)
|
||||
case InPlaylist, NotInPlaylist:
|
||||
if value, ok := rule.(Expression).fields()[field].(string); ok && value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,5 +47,8 @@ type DataStore interface {
|
|||
|
||||
WithTx(block func(tx DataStore) error, scope ...string) error
|
||||
WithTxImmediate(block func(tx DataStore) error, scope ...string) error
|
||||
// WithTxRetry runs block in a transaction, rerunning it while SQLite reports the database busy.
|
||||
// For background work only (it can take minutes), and block must be safe to rerun after a rollback.
|
||||
WithTxRetry(ctx context.Context, block func(ctx context.Context, tx DataStore) error, scope ...string) error
|
||||
GC(ctx context.Context, libraryIDs ...int) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
|||
mf.CatalogNum = md.String(model.TagCatalogNumber)
|
||||
mf.Comment = md.String(model.TagComment)
|
||||
if f := md.NullableFloat(model.TagBPM); f != nil {
|
||||
if v := int(math.Round(*f)); v != 0 {
|
||||
mf.BPM = new(v)
|
||||
if r := math.Round(*f); r > 0 && r <= math.MaxInt32 {
|
||||
mf.BPM = new(int(r))
|
||||
}
|
||||
}
|
||||
mf.Lyrics = md.mapLyrics()
|
||||
|
|
|
|||
|
|
@ -131,6 +131,15 @@ var _ = Describe("ToMediaFile", func() {
|
|||
Expect(toMediaFile(model.RawTags{"BPM": {"0"}}).BPM).To(BeNil())
|
||||
Expect(toMediaFile(model.RawTags{"BPM": {"fast"}}).BPM).To(BeNil())
|
||||
})
|
||||
It("leaves BPM nil when the tag does not fit in 32 bits", func() {
|
||||
Expect(toMediaFile(model.RawTags{"BPM": {"4294967295"}}).BPM).To(BeNil())
|
||||
})
|
||||
It("leaves BPM nil when the tag is negative", func() {
|
||||
Expect(toMediaFile(model.RawTags{"BPM": {"-120"}}).BPM).To(BeNil())
|
||||
})
|
||||
It("keeps the largest 32-bit BPM value", func() {
|
||||
Expect(toMediaFile(model.RawTags{"BPM": {"2147483647"}}).BPM).To(Equal(new(2147483647)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("BitDepth", func() {
|
||||
|
|
|
|||
|
|
@ -148,15 +148,20 @@ func (md Metadata) tuple(key model.TagName) (int, int) {
|
|||
return 0, 0
|
||||
}
|
||||
tuple := strings.Split(tag, "/")
|
||||
t1, t2 := 0, 0
|
||||
t1, _ = strconv.Atoi(tuple[0])
|
||||
total := md.first(key + "total")
|
||||
if len(tuple) > 1 {
|
||||
t2, _ = strconv.Atoi(tuple[1])
|
||||
} else {
|
||||
t2tag := md.first(key + "total")
|
||||
t2, _ = strconv.Atoi(t2tag)
|
||||
total = tuple[1]
|
||||
}
|
||||
return t1, t2
|
||||
return tagNumber(tuple[0]), tagNumber(total)
|
||||
}
|
||||
|
||||
// tagNumber rejects negatives and values above int32, so the DB stays readable by 32-bit builds.
|
||||
func tagNumber(s string) int {
|
||||
v, err := strconv.ParseInt(s, 10, 32)
|
||||
if err != nil || v < 0 {
|
||||
return 0
|
||||
}
|
||||
return int(v)
|
||||
}
|
||||
|
||||
var dateRegex = regexp.MustCompile(`([12]\d\d\d)`)
|
||||
|
|
|
|||
|
|
@ -226,6 +226,10 @@ var _ = Describe("Metadata", func() {
|
|||
Entry(nil, "2/10", "", 2, 10),
|
||||
Entry(nil, "", "", 0, 0),
|
||||
Entry(nil, "A", "", 0, 0),
|
||||
Entry("ignores values that do not fit in 32 bits", "4294967295", "4294967296", 0, 0),
|
||||
Entry("ignores a total that does not fit in 32 bits", "2/4294967295", "", 2, 0),
|
||||
Entry("keeps the largest 32-bit value", "2147483647", "", 2147483647, 0),
|
||||
Entry("ignores negative values", "-1", "-2", 0, 0),
|
||||
)
|
||||
|
||||
Describe("Performers", func() {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@ package model
|
|||
|
||||
import (
|
||||
"iter"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
)
|
||||
|
||||
|
|
@ -137,6 +141,66 @@ func (pls Playlist) UploadedImagePath() string {
|
|||
return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage)
|
||||
}
|
||||
|
||||
// NormalizedRules returns the rules with child playlist paths resolved to absolute, OS-native paths.
|
||||
func (pls Playlist) NormalizedRules() *criteria.Criteria {
|
||||
if pls.Rules == nil || pls.Rules.Expression == nil {
|
||||
return pls.Rules
|
||||
}
|
||||
|
||||
rules := *pls.Rules
|
||||
rules.Expression = normalizePlaylistPaths(pls.Rules.Expression, pls.Path)
|
||||
return &rules
|
||||
}
|
||||
|
||||
func normalizePlaylistPaths(inputRule criteria.Expression, referencingPlaylistPath string) criteria.Expression {
|
||||
switch rule := inputRule.(type) {
|
||||
case criteria.Any:
|
||||
anyCriteria := make(criteria.Any, len(rule))
|
||||
for i, rules := range rule {
|
||||
anyCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath)
|
||||
}
|
||||
return anyCriteria
|
||||
case criteria.All:
|
||||
allCriteria := make(criteria.All, len(rule))
|
||||
for i, rules := range rule {
|
||||
allCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath)
|
||||
}
|
||||
return allCriteria
|
||||
case criteria.InPlaylist:
|
||||
return criteria.InPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath))
|
||||
case criteria.NotInPlaylist:
|
||||
return criteria.NotInPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath))
|
||||
}
|
||||
|
||||
return inputRule
|
||||
}
|
||||
|
||||
func normalizeChildPathRule(rule map[string]any, referencingPlaylistPath string) map[string]any {
|
||||
path, ok := rule["path"].(string)
|
||||
if !ok || path == "" {
|
||||
return rule
|
||||
}
|
||||
|
||||
// References use forward slashes to stay portable, while Playlist.Path is OS-native.
|
||||
path = filepath.FromSlash(path)
|
||||
switch {
|
||||
case isAbsPlaylistRef(path):
|
||||
path = filepath.Clean(path)
|
||||
case referencingPlaylistPath != "":
|
||||
path = filepath.Join(filepath.Dir(referencingPlaylistPath), path)
|
||||
default:
|
||||
log.Warn("Cannot resolve relative playlist reference: playlist has no file path", "reference", path)
|
||||
}
|
||||
normalized := maps.Clone(rule)
|
||||
normalized["path"] = path
|
||||
return normalized
|
||||
}
|
||||
|
||||
// filepath.IsAbs rejects a bare leading separator on Windows, but that is how Unix spells absolute.
|
||||
func isAbsPlaylistRef(path string) bool {
|
||||
return filepath.IsAbs(path) || os.IsPathSeparator(path[0])
|
||||
}
|
||||
|
||||
type Playlists []Playlist
|
||||
|
||||
type PlaylistCursor iter.Seq2[Playlist, error]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package model_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -88,4 +89,102 @@ var _ = Describe("Playlist", func() {
|
|||
Expect(model.Playlist{Sync: true}.TracksEditable()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NormalizedRules()", func() {
|
||||
// absPath builds an OS-native absolute path so these specs also run on Windows.
|
||||
absPath := func(parts ...string) string {
|
||||
abs, err := filepath.Abs(filepath.Join(parts...))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return abs
|
||||
}
|
||||
normalize := func(pls model.Playlist) criteria.Expression {
|
||||
return pls.NormalizedRules().Expression
|
||||
}
|
||||
|
||||
It("resolves relative references against the playlist folder", func() {
|
||||
pls := model.Playlist{
|
||||
Path: absPath("test", "nested", "my-playlist.nsp"),
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{
|
||||
criteria.InPlaylist{"path": "../up.m3u"},
|
||||
criteria.NotInPlaylist{"path": "./sibling.nsp"},
|
||||
criteria.Any{criteria.InPlaylist{"path": "sub/deep.nsp"}},
|
||||
}},
|
||||
}
|
||||
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
|
||||
criteria.InPlaylist{"path": absPath("test", "up.m3u")},
|
||||
criteria.NotInPlaylist{"path": absPath("test", "nested", "sibling.nsp")},
|
||||
criteria.Any{criteria.InPlaylist{"path": absPath("test", "nested", "sub", "deep.nsp")}},
|
||||
}))
|
||||
})
|
||||
|
||||
It("cleans absolute references", func() {
|
||||
dirty := absPath("music") + string(filepath.Separator) + "." + string(filepath.Separator) + "child.nsp"
|
||||
pls := model.Playlist{
|
||||
Path: absPath("test", "my-playlist.nsp"),
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{criteria.NotInPlaylist{"path": dirty}}},
|
||||
}
|
||||
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
|
||||
criteria.NotInPlaylist{"path": absPath("music", "child.nsp")},
|
||||
}))
|
||||
})
|
||||
|
||||
It("treats a leading slash as absolute on every OS", func() {
|
||||
pls := model.Playlist{
|
||||
Path: absPath("test", "my-playlist.nsp"),
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "/other/./root.m3u"}}},
|
||||
}
|
||||
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
|
||||
criteria.InPlaylist{"path": filepath.FromSlash("/other/root.m3u")},
|
||||
}))
|
||||
})
|
||||
|
||||
It("leaves empty paths and id references untouched", func() {
|
||||
pls := model.Playlist{
|
||||
Path: absPath("test", "my-playlist.nsp"),
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{
|
||||
criteria.InPlaylist{"path": ""},
|
||||
criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"},
|
||||
criteria.Eq{"artist": "Bob Dealin"},
|
||||
}},
|
||||
}
|
||||
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
|
||||
criteria.InPlaylist{"path": ""},
|
||||
criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"},
|
||||
criteria.Eq{"artist": "Bob Dealin"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("skips relative references when the playlist has no path", func() {
|
||||
pls := model.Playlist{
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "../up.m3u"}}},
|
||||
}
|
||||
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
|
||||
criteria.InPlaylist{"path": filepath.FromSlash("../up.m3u")},
|
||||
}))
|
||||
})
|
||||
|
||||
It("preserves every other criteria field", func() {
|
||||
rules := criteria.Criteria{
|
||||
Expression: criteria.All{criteria.InPlaylist{"path": "child.nsp"}},
|
||||
Sort: "title",
|
||||
Order: "desc",
|
||||
Limit: 10,
|
||||
LimitPercent: 25,
|
||||
Offset: 5,
|
||||
RefreshDelay: 3 * time.Hour,
|
||||
}
|
||||
pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &rules}
|
||||
|
||||
normalized := *pls.NormalizedRules()
|
||||
normalized.Expression = rules.Expression
|
||||
Expect(normalized).To(Equal(rules))
|
||||
})
|
||||
|
||||
It("does not mutate the original playlist rules", func() {
|
||||
original := criteria.All{criteria.InPlaylist{"path": "child.nsp"}}
|
||||
pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &criteria.Criteria{Expression: original}}
|
||||
_ = pls.NormalizedRules()
|
||||
Expect(original[0]).To(BeEquivalentTo(criteria.InPlaylist{"path": "child.nsp"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
type smartPlaylistJoinType int
|
||||
|
|
@ -344,11 +345,15 @@ func startOfPeriod(numDays int64, from time.Time) string {
|
|||
}
|
||||
|
||||
func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) {
|
||||
playlistID, ok := values["id"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New("playlist id not given")
|
||||
var condition squirrel.Sqlizer
|
||||
if playlistId, ok := values["id"].(string); ok && playlistId != "" {
|
||||
condition = squirrel.Eq{"pl.playlist_id": playlistId}
|
||||
} else if playlistPath, ok := values["path"].(string); ok && playlistPath != "" {
|
||||
condition = squirrel.Eq{"playlist.path": pathVariants(playlistPath)}
|
||||
} else {
|
||||
return nil, errors.New("playlist id or path not given")
|
||||
}
|
||||
filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}}
|
||||
filters := squirrel.And{condition}
|
||||
if !c.owner.IsAdmin {
|
||||
if c.owner.ID == "" {
|
||||
filters = append(filters, squirrel.Eq{"playlist.public": 1})
|
||||
|
|
@ -373,6 +378,18 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir
|
|||
return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil
|
||||
}
|
||||
|
||||
// Filesystems disagree on the Unicode form of a name, so match the path in NFC and NFD.
|
||||
func pathVariants(path string) []string {
|
||||
variants := []string{path}
|
||||
if alt := norm.NFC.String(path); alt != path {
|
||||
variants = append(variants, alt)
|
||||
}
|
||||
if alt := norm.NFD.String(path); alt != path {
|
||||
variants = append(variants, alt)
|
||||
}
|
||||
return variants
|
||||
}
|
||||
|
||||
func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
|
||||
if info.IsRole {
|
||||
return roleCond{role: info.Name(), cond: cond, not: negate}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
|
||||
Entry("before", criteria.Before{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
|
||||
Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
|
||||
Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
|
||||
Entry("in playlist [path]", criteria.InPlaylist{"path": "lacuslacus.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "lacuslacus.nsp", 1),
|
||||
Entry("in playlist [id]", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
|
||||
Entry("in playlist [empty id falls back to path]", criteria.InPlaylist{"id": "", "path": "/music/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "/music/x.nsp", 1),
|
||||
Entry("in playlist [decomposed unicode path]", criteria.InPlaylist{"path": "/m\u00fasica/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?,?) AND playlist.public = ?))", "/m\u00fasica/x.nsp", "/mu\u0301sica/x.nsp", 1),
|
||||
Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
|
||||
Entry("album annotation", criteria.Gt{"albumRating": 3}, "album_annotation.rating > ?", 3),
|
||||
Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true),
|
||||
|
|
@ -268,6 +271,13 @@ var _ = Describe("Smart playlist criteria SQL", func() {
|
|||
Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression")))
|
||||
})
|
||||
|
||||
It("returns an error when inPlaylist has empty path", func() {
|
||||
_, err := newSmartPlaylistCriteria(
|
||||
criteria.Criteria{Expression: criteria.InPlaylist{"path": ""}},
|
||||
withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false})).where()
|
||||
Expect(err).To(MatchError(ContainSubstring("playlist id or path not given")))
|
||||
})
|
||||
|
||||
It("returns an error for a range over a tag/role field", func() {
|
||||
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).where()
|
||||
Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field")))
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@ type dbFolder struct {
|
|||
ImageFiles string `structs:"-" json:"-"`
|
||||
}
|
||||
|
||||
// String guards the promoted Folder.String(), which would dereference a nil Folder.
|
||||
func (f dbFolder) String() string {
|
||||
if f.Folder == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return f.Folder.String()
|
||||
}
|
||||
|
||||
func (f *dbFolder) PostScan() error {
|
||||
var err error
|
||||
if f.ImageFiles != "" {
|
||||
|
|
|
|||
|
|
@ -310,6 +310,13 @@ var _ = Describe("FolderRepository", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("dbFolder.String", func() {
|
||||
It("does not dereference a nil Folder", func() {
|
||||
Expect(fmt.Sprint(dbFolder{})).To(Equal("<nil>"))
|
||||
Expect(fmt.Sprint(&dbFolder{})).To(Equal("<nil>"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("wrapFolderCursor", func() {
|
||||
It("does not panic when the cursor yields a dbFolder with nil Folder", func() {
|
||||
// Simulate what queryWithStableResults does on the rows.Err() path:
|
||||
|
|
|
|||
|
|
@ -38,6 +38,14 @@ type dbMediaFile struct {
|
|||
RgTrackPeak *float64 `structs:"-" json:"-"`
|
||||
}
|
||||
|
||||
// String guards the promoted MediaFile.String(), which would dereference a nil MediaFile.
|
||||
func (m dbMediaFile) String() string {
|
||||
if m.MediaFile == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return m.MediaFile.String()
|
||||
}
|
||||
|
||||
func (m *dbMediaFile) PostScan() error {
|
||||
m.RGTrackGain = m.RgTrackGain
|
||||
m.RGTrackPeak = m.RgTrackPeak
|
||||
|
|
|
|||
|
|
@ -1203,6 +1203,13 @@ var _ = Describe("MediaRepository", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("dbMediaFile.String", func() {
|
||||
It("does not dereference a nil MediaFile", func() {
|
||||
Expect(fmt.Sprint(dbMediaFile{})).To(Equal("<nil>"))
|
||||
Expect(fmt.Sprint(&dbMediaFile{})).To(Equal("<nil>"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("wrapMediaFileCursor", func() {
|
||||
It("does not panic when the cursor yields a dbMediaFile with nil MediaFile", func() {
|
||||
// Simulate what queryWithStableResults does on the rows.Err() path:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package persistence
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
|
|
@ -138,11 +139,15 @@ func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) WithTx(block func(tx model.DataStore) error, scope ...string) error {
|
||||
var msg string
|
||||
func scopeLabel(scope []string) string {
|
||||
if len(scope) > 0 {
|
||||
msg = scope[0]
|
||||
return scope[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *SQLStore) WithTx(block func(tx model.DataStore) error, scope ...string) error {
|
||||
msg := scopeLabel(scope)
|
||||
start := time.Now()
|
||||
conn, inTx := s.db.(*dbx.DB)
|
||||
if !inTx {
|
||||
|
|
@ -177,6 +182,51 @@ func (s *SQLStore) WithTxImmediate(block func(tx model.DataStore) error, scope .
|
|||
}, scope...)
|
||||
}
|
||||
|
||||
// txRetryDelay spaces out reruns of a busy transaction. Each attempt has already waited out the
|
||||
// busy timeout, so WithTxRetry gives up only after a sustained lock.
|
||||
var txRetryDelay = 5 * time.Second
|
||||
|
||||
const txMaxRetries = 3
|
||||
|
||||
func (s *SQLStore) WithTxRetry(ctx context.Context, block func(ctx context.Context, tx model.DataStore) error, scope ...string) error {
|
||||
// Inside a transaction, join it: the outer one holds the lock and owns commit and rollback
|
||||
if _, ok := s.db.(*dbx.DB); !ok {
|
||||
return block(ctx, s)
|
||||
}
|
||||
for attempt := 0; ; attempt++ {
|
||||
attemptCtx := ctx
|
||||
if attempt < txMaxRetries {
|
||||
attemptCtx = withBusyRetry(ctx)
|
||||
}
|
||||
err := s.WithTx(func(tx model.DataStore) error { return block(attemptCtx, tx) }, scope...)
|
||||
if attempt == txMaxRetries || !db.IsBusy(err) {
|
||||
return err
|
||||
}
|
||||
log.Warn(ctx, "Database busy, retrying transaction", "scope", scopeLabel(scope), "attempt", attempt+1, err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Duration(attempt+1) * txRetryDelay):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type busyRetryKey struct{}
|
||||
|
||||
// withBusyRetry marks a transaction attempt that WithTxRetry will rerun, so a busy statement in it
|
||||
// is logged as a warning rather than an error.
|
||||
func withBusyRetry(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, busyRetryKey{}, true)
|
||||
}
|
||||
|
||||
func hasBusyRetry(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
retry, _ := ctx.Value(busyRetryKey{}).(bool)
|
||||
return retry
|
||||
}
|
||||
|
||||
func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error {
|
||||
trace := func(ctx context.Context, msg string, f func() error) func() error {
|
||||
return func() error {
|
||||
|
|
@ -207,9 +257,9 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error {
|
|||
trace(ctx, "remove orphan playlist tracks", func() error { return s.Playlist(ctx).(*playlistRepository).removeOrphans() }),
|
||||
)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error tidying up database", err)
|
||||
return fmt.Errorf("tidying up database: %w", err)
|
||||
}
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getDBXBuilder() dbx.Builder {
|
||||
|
|
|
|||
|
|
@ -169,6 +169,28 @@ func p(path string) string {
|
|||
return filepath.FromSlash(path)
|
||||
}
|
||||
|
||||
// restrictedFixture creates a second library plus a non-admin user granted library 1 only, so
|
||||
// specs can assert that a query filters by library. Cleans itself up after the spec.
|
||||
func restrictedFixture(name string) (context.Context, model.Library, model.User) {
|
||||
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
||||
db := GetDBXBuilder()
|
||||
|
||||
lib := model.Library{Name: name + " Library", Path: "/" + name}
|
||||
lr := NewLibraryRepository(adminCtx, db)
|
||||
Expect(lr.Put(&lib)).To(Succeed())
|
||||
|
||||
user := createUserWithLibraries(name+"-restricted", []int{1})
|
||||
ur := NewUserRepository(adminCtx, db)
|
||||
Expect(ur.Put(&user)).To(Succeed())
|
||||
Expect(ur.SetUserLibraries(user.ID, []int{1})).To(Succeed())
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = NewUserRepository(adminCtx, db).Delete(user.ID)
|
||||
_ = NewLibraryRepository(adminCtx, db).(*libraryRepository).delete(squirrel.Eq{"id": lib.ID})
|
||||
})
|
||||
return adminCtx, lib, user
|
||||
}
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
conn := GetDBXBuilder()
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ package persistence
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
|
|
@ -55,4 +58,74 @@ var _ = Describe("SQLStore", func() {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("WithTxRetry", func() {
|
||||
busy := sqlite3.Error{Code: sqlite3.ErrBusy}
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(func(d time.Duration) { txRetryDelay = d }, txRetryDelay)
|
||||
txRetryDelay = 0
|
||||
})
|
||||
|
||||
It("reruns a busy transaction from a clean rollback", func() {
|
||||
var attempts []bool
|
||||
err := ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||
attempts = append(attempts, hasBusyRetry(ctx))
|
||||
Expect(tx.Property(ctx).Put("retry-key", "attempt")).To(Succeed())
|
||||
if len(attempts) < 3 {
|
||||
return busy
|
||||
}
|
||||
return nil
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(attempts).To(Equal([]bool{true, true, true}))
|
||||
Expect(ds.Property(ctx).Get("retry-key")).To(Equal("attempt"))
|
||||
})
|
||||
|
||||
It("gives up after the last retry, which is not marked as retried", func() {
|
||||
var attempts []bool
|
||||
err := ds.WithTxRetry(ctx, func(ctx context.Context, _ model.DataStore) error {
|
||||
attempts = append(attempts, hasBusyRetry(ctx))
|
||||
return busy
|
||||
})
|
||||
Expect(db.IsBusy(err)).To(BeTrue())
|
||||
Expect(attempts).To(Equal([]bool{true, true, true, false}))
|
||||
})
|
||||
|
||||
It("does not rerun on other errors", func() {
|
||||
calls := 0
|
||||
err := ds.WithTxRetry(ctx, func(context.Context, model.DataStore) error {
|
||||
calls++
|
||||
return sqlite3.Error{Code: sqlite3.ErrConstraint}
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(calls).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not rerun when called inside a transaction", func() {
|
||||
calls := 0
|
||||
err := ds.WithTx(func(tx model.DataStore) error {
|
||||
return tx.WithTxRetry(ctx, func(context.Context, model.DataStore) error {
|
||||
calls++
|
||||
return busy
|
||||
})
|
||||
})
|
||||
Expect(db.IsBusy(err)).To(BeTrue())
|
||||
Expect(calls).To(Equal(1))
|
||||
})
|
||||
|
||||
It("joins the enclosing transaction instead of opening another", func() {
|
||||
rollback := errors.New("rollback")
|
||||
err := ds.WithTx(func(tx model.DataStore) error {
|
||||
Expect(tx.Property(ctx).Put("outer-key", "v")).To(Succeed())
|
||||
Expect(tx.WithTxRetry(ctx, func(ctx context.Context, inner model.DataStore) error {
|
||||
Expect(inner.Property(ctx).Get("outer-key")).To(Equal("v"))
|
||||
return inner.Property(ctx).Put("inner-key", "v")
|
||||
})).To(Succeed())
|
||||
return rollback
|
||||
})
|
||||
Expect(err).To(MatchError(rollback))
|
||||
_, err = ds.Property(ctx).Get("inner-key")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ func (r *playerRepository) Save(entity any) (string, error) {
|
|||
if !r.isPermitted(t) {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
return r.put(t.ID, t)
|
||||
return r.put("", t) // Save only creates; edits go through the owner-scoped Update
|
||||
}
|
||||
|
||||
func (r *playerRepository) Update(id string, entity any, cols ...string) error {
|
||||
|
|
|
|||
|
|
@ -288,6 +288,27 @@ var _ = Describe("PlayerRepository", func() {
|
|||
Expect(*stored).To(Equal(adminPlayer1))
|
||||
})
|
||||
|
||||
It("does not let a regular user overwrite another user's player via Save with a spoofed id", func() {
|
||||
spoofed := model.Player{
|
||||
ID: adminPlayer1.ID,
|
||||
Name: "HIJACKED",
|
||||
UserId: regularUser.ID,
|
||||
ReportRealPath: true,
|
||||
}
|
||||
|
||||
id, err := regularRepo.Save(&spoofed)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(id).ToNot(Equal(adminPlayer1.ID))
|
||||
|
||||
stored, err := adminRepo.Get(adminPlayer1.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(*stored).To(Equal(adminPlayer1))
|
||||
|
||||
created, err := adminRepo.Get(id)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(created.UserId).To(Equal(regularUser.ID))
|
||||
})
|
||||
|
||||
It("does not let a regular user reassign their own player to another user", func() {
|
||||
// Owner updates their own player but tries to give it away to the admin. The update
|
||||
// succeeds for the other fields, but user_id is never written, so ownership stays put.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
|
|
@ -276,10 +277,17 @@ func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []st
|
|||
return err
|
||||
}
|
||||
|
||||
return r.addTracks(playlistId, 1, mediaFileIds)
|
||||
_, err = r.addTracks(playlistId, 1, mediaFileIds)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error {
|
||||
// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns
|
||||
// the library check: every caller, including a full replace through Put, goes through it.
|
||||
func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) (int, error) {
|
||||
mediaFileIds, err := r.keepAccessible(mediaFileIds)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Break the track list in chunks to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit
|
||||
// Add new tracks, chunk by chunk
|
||||
pos := startingPos
|
||||
|
|
@ -289,14 +297,36 @@ func (r *playlistRepository) addTracks(playlistId string, startingPos int, media
|
|||
ins = ins.Values(playlistId, t, pos)
|
||||
pos++
|
||||
}
|
||||
_, err := r.executeSQL(ins)
|
||||
if err != nil {
|
||||
return err
|
||||
if _, err := r.executeSQL(ins); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
r.enqueueCoverRebuild(playlistId)
|
||||
return r.refreshCounters(&model.Playlist{ID: playlistId})
|
||||
return len(mediaFileIds), r.refreshCounters(&model.Playlist{ID: playlistId})
|
||||
}
|
||||
|
||||
// keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked
|
||||
// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER.
|
||||
func (r *playlistRepository) keepAccessible(mediaFileIds []string) ([]string, error) {
|
||||
if visible, err := r.visibleLibraryIDs(); err == nil && r.userSeesAllLibraries(visible) {
|
||||
return mediaFileIds, nil
|
||||
}
|
||||
accessible := make(map[string]struct{}, len(mediaFileIds))
|
||||
for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) {
|
||||
sq := r.applyLibraryFilter(Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file")
|
||||
var found []string
|
||||
if err := r.queryAllSlice(sq, &found); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range found {
|
||||
accessible[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
return slice.Filter(mediaFileIds, func(id string) bool {
|
||||
_, ok := accessible[id]
|
||||
return ok
|
||||
}), nil
|
||||
}
|
||||
|
||||
// refreshCounters updates total playlist duration, size and count
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er
|
|||
query := Select().
|
||||
LeftJoin("media_file f on f.id = media_file_id").
|
||||
Where(Eq{"playlist_id": r.playlistId})
|
||||
query = r.applyLibraryFilter(query, "f")
|
||||
return r.count(query, r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
|
|
@ -113,6 +114,7 @@ func (r *playlistTrackRepository) Read(id string) (any, error) {
|
|||
).
|
||||
Join("media_file f on f.id = media_file_id").
|
||||
Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}})
|
||||
sel = r.applyLibraryFilter(sel, "f")
|
||||
var trk dbPlaylistTrack
|
||||
err := r.queryOne(sel, &trk)
|
||||
return trk.PlaylistTrack, err
|
||||
|
|
@ -157,6 +159,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]
|
|||
query := r.newSelect(options...).Columns("distinct mf.album_id").
|
||||
Join("media_file mf on mf.id = media_file_id").
|
||||
Where(Eq{"playlist_id": r.playlistId})
|
||||
query = r.applyLibraryFilter(query, "mf")
|
||||
var ids []string
|
||||
err := r.queryAllSlice(query, &ids)
|
||||
if err != nil {
|
||||
|
|
@ -187,12 +190,11 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
|
|||
// Get next pos (ID) in playlist
|
||||
sq := r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId})
|
||||
var res struct{ Max sql.NullInt32 }
|
||||
err := r.queryOne(sq, &res)
|
||||
if err != nil {
|
||||
if err := r.queryOne(sq, &res); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return len(mediaFileIds), r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds)
|
||||
return r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds)
|
||||
}
|
||||
|
||||
// Insert adds tracks before the 1-based position pos, shifting the following entries down; a
|
||||
|
|
@ -215,7 +217,13 @@ func (r *playlistTrackRepository) Insert(mediaFileIds []string, pos int) (int, e
|
|||
if res == 0 {
|
||||
return r.Add(mediaFileIds)
|
||||
}
|
||||
return n, r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds)
|
||||
inserted, err := r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds)
|
||||
if err != nil || inserted == n {
|
||||
return inserted, err
|
||||
}
|
||||
// The shift above reserved a slot per requested id, so ids dropped by the library filter
|
||||
// leave a hole. Close it.
|
||||
return inserted, r.playlistRepo.renumber(r.playlistId)
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
|
@ -221,4 +223,151 @@ var _ = Describe("PlaylistTrackRepository", func() {
|
|||
Expect(tracks.CountAll()).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("library access", func() {
|
||||
var otherLib model.Library
|
||||
var restrictedUser model.User
|
||||
var adminCtx, userCtx context.Context
|
||||
var userTracks model.PlaylistTrackRepository
|
||||
var plsID string
|
||||
|
||||
BeforeEach(func() {
|
||||
adminCtx, otherLib, restrictedUser = restrictedFixture("pls")
|
||||
userCtx = request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser)
|
||||
db := GetDBXBuilder()
|
||||
|
||||
adminMr := NewMediaFileRepository(adminCtx, db)
|
||||
Expect(adminMr.Put(&model.MediaFile{
|
||||
ID: "pls-otherlib-track", LibraryID: otherLib.ID, AlbumID: "pls-hidden-album",
|
||||
Path: "hidden/in-playlist.mp3", Title: "Hidden In Playlist",
|
||||
})).To(Succeed())
|
||||
DeferCleanup(func() { _ = adminMr.Delete("pls-otherlib-track") })
|
||||
|
||||
adminPls := NewPlaylistRepository(adminCtx, db)
|
||||
pls := model.Playlist{Name: "Public Mixed", OwnerID: adminUser.ID, OwnerName: adminUser.UserName, Public: true}
|
||||
Expect(adminPls.Put(&pls)).To(Succeed())
|
||||
plsID = pls.ID
|
||||
DeferCleanup(func() { _ = adminPls.Delete(plsID) })
|
||||
Expect(adminPls.Tracks(plsID, false).Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(2))
|
||||
|
||||
userTracks = NewPlaylistRepository(userCtx, db).Tracks(plsID, false)
|
||||
})
|
||||
|
||||
It("Read does not return a track outside the user's libraries", func() {
|
||||
_, err := userTracks.Read("2")
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "position 2 holds a track the user cannot access")
|
||||
})
|
||||
|
||||
It("Read still returns a track inside the user's libraries", func() {
|
||||
trk, err := userTracks.Read("1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(trk.(*model.PlaylistTrack).MediaFile.ID).To(Equal(songDayInALife.ID))
|
||||
})
|
||||
|
||||
It("Count excludes tracks outside the user's libraries", func() {
|
||||
Expect(userTracks.Count()).To(Equal(int64(1)), "Count must agree with the filtered listing")
|
||||
})
|
||||
|
||||
It("GetAlbumIDs excludes albums outside the user's libraries", func() {
|
||||
Expect(userTracks.GetAlbumIDs()).ToNot(ContainElement("pls-hidden-album"))
|
||||
})
|
||||
|
||||
Describe("Add", func() {
|
||||
var ownTracks model.PlaylistTrackRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
userPls := NewPlaylistRepository(userCtx, GetDBXBuilder())
|
||||
own := model.Playlist{Name: "Own Playlist", OwnerID: restrictedUser.ID, OwnerName: restrictedUser.UserName}
|
||||
Expect(userPls.Put(&own)).To(Succeed())
|
||||
DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(own.ID) })
|
||||
ownTracks = userPls.Tracks(own.ID, false)
|
||||
})
|
||||
|
||||
It("drops ids outside the user's libraries", func() {
|
||||
Expect(ownTracks.Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(1))
|
||||
Expect(ownTracks.GetMediaFileIDs()).To(ConsistOf(songDayInALife.ID))
|
||||
})
|
||||
|
||||
It("drops them when reached through AddAlbums", func() {
|
||||
Expect(ownTracks.AddAlbums([]string{"pls-hidden-album"})).To(BeZero())
|
||||
})
|
||||
|
||||
It("drops them when reached through Insert", func() {
|
||||
Expect(ownTracks.Add([]string{songDayInALife.ID})).To(Equal(1))
|
||||
|
||||
Expect(ownTracks.Insert([]string{"pls-otherlib-track", songComeTogether.ID}, 1)).To(Equal(1))
|
||||
|
||||
Expect(ownTracks.GetMediaFileIDs()).To(Equal([]string{songComeTogether.ID, songDayInALife.ID}))
|
||||
trks, err := ownTracks.GetAll(model.QueryOptions{Sort: "id"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(slice.Map(trks, func(t model.PlaylistTrack) string { return t.ID })).To(Equal([]string{"1", "2"}),
|
||||
"positions must stay contiguous when an id is dropped")
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put", func() {
|
||||
storedIDs := func(id string) []string {
|
||||
ids, err := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(id, false).GetMediaFileIDs()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return ids
|
||||
}
|
||||
put := func(ctx context.Context, owner model.User, pls *model.Playlist, ids ...string) string {
|
||||
pls.OwnerID = owner.ID
|
||||
pls.Tracks = nil
|
||||
pls.AddMediaFilesByID(ids)
|
||||
Expect(NewPlaylistRepository(ctx, GetDBXBuilder()).Put(pls)).To(Succeed())
|
||||
DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(pls.ID) })
|
||||
return pls.ID
|
||||
}
|
||||
|
||||
It("drops ids outside the user's libraries when creating a playlist", func() {
|
||||
id := put(userCtx, restrictedUser, &model.Playlist{Name: "Created"}, songDayInALife.ID, "pls-otherlib-track")
|
||||
|
||||
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID}))
|
||||
})
|
||||
|
||||
It("drops them when replacing the tracks of an existing playlist", func() {
|
||||
pls := &model.Playlist{Name: "Replaced"}
|
||||
put(userCtx, restrictedUser, pls, songDayInALife.ID)
|
||||
|
||||
put(userCtx, restrictedUser, pls, "pls-otherlib-track")
|
||||
|
||||
Expect(storedIDs(pls.ID)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not count a dropped id, so it cannot be told apart from an unknown one", func() {
|
||||
hidden := put(userCtx, restrictedUser, &model.Playlist{Name: "Hidden"}, songDayInALife.ID, "pls-otherlib-track")
|
||||
unknown := put(userCtx, restrictedUser, &model.Playlist{Name: "Unknown"}, songDayInALife.ID, "no-such-track")
|
||||
|
||||
userPls := NewPlaylistRepository(userCtx, GetDBXBuilder())
|
||||
h, err := userPls.Get(hidden)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
u, err := userPls.Get(unknown)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(h.SongCount).To(Equal(u.SongCount))
|
||||
Expect(h.Duration).To(Equal(u.Duration))
|
||||
Expect(h.Size).To(Equal(u.Size))
|
||||
})
|
||||
|
||||
It("keeps order and duplicates of the accessible ids", func() {
|
||||
id := put(userCtx, restrictedUser, &model.Playlist{Name: "Ordered"},
|
||||
songDayInALife.ID, "pls-otherlib-track", songComeTogether.ID, songDayInALife.ID)
|
||||
|
||||
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, songComeTogether.ID, songDayInALife.ID}))
|
||||
})
|
||||
|
||||
It("keeps every id when run as an admin, as the scanner's playlist sync does", func() {
|
||||
id := put(adminCtx, adminUser, &model.Playlist{Name: "Synced"}, songDayInALife.ID, "pls-otherlib-track")
|
||||
|
||||
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, "pls-otherlib-track"}))
|
||||
})
|
||||
})
|
||||
|
||||
It("still shows everything to an admin", func() {
|
||||
adminTracks := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(plsID, false)
|
||||
Expect(adminTracks.Count()).To(Equal(int64(2)))
|
||||
_, err := adminTracks.Read("2")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated
|
||||
|
|
@ -16,6 +19,17 @@ import (
|
|||
|
||||
// refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly.
|
||||
func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
return r.refreshSmartPlaylistTree(pls, map[string]struct{}{})
|
||||
}
|
||||
|
||||
// The visited set stops playlists that reference each other from recursing forever.
|
||||
func (r *playlistRepository) refreshSmartPlaylistTree(pls *model.Playlist, visited map[string]struct{}) bool {
|
||||
if _, seen := visited[pls.ID]; seen {
|
||||
log.Trace(r.ctx, "Skipping already visited smart playlist", "playlist", pls.Name, "id", pls.ID)
|
||||
return false
|
||||
}
|
||||
visited[pls.ID] = struct{}{}
|
||||
|
||||
usr := loggedUser(r.ctx)
|
||||
if !r.shouldRefreshSmartPlaylist(pls, usr) {
|
||||
return false
|
||||
|
|
@ -30,9 +44,9 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr))
|
||||
rulesSQL := newSmartPlaylistCriteria(*pls.NormalizedRules(), withSmartPlaylistOwner(*usr))
|
||||
|
||||
if !r.refreshChildPlaylists(pls, rulesSQL) {
|
||||
if !r.refreshChildPlaylists(pls, rulesSQL, visited) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -89,28 +103,47 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr
|
|||
|
||||
// refreshChildPlaylists handles refreshing any child playlists that are referenced in the smart playlist criteria.
|
||||
// Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort.
|
||||
func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool {
|
||||
func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria, visited map[string]struct{}) bool {
|
||||
childPlaylistIds := rulesSQL.ChildPlaylistIds()
|
||||
if len(childPlaylistIds) == 0 {
|
||||
childPlaylistPaths := rulesSQL.ChildPlaylistPaths()
|
||||
if len(childPlaylistIds) == 0 && len(childPlaylistPaths) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}})
|
||||
var conditions Or
|
||||
if len(childPlaylistIds) > 0 {
|
||||
conditions = append(conditions, Eq{"playlist.id": childPlaylistIds})
|
||||
}
|
||||
if len(childPlaylistPaths) > 0 {
|
||||
lookupPaths := slices.Concat(slice.Map(childPlaylistPaths, pathVariants)...)
|
||||
conditions = append(conditions, Eq{"playlist.path": lookupPaths})
|
||||
}
|
||||
|
||||
childPlaylists, err := r.GetAll(model.QueryOptions{Filters: conditions})
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err)
|
||||
log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, "childPaths", childPlaylistPaths, err)
|
||||
return false
|
||||
}
|
||||
|
||||
found := make(map[string]struct{}, len(childPlaylists))
|
||||
found := make(map[string]struct{}, len(childPlaylists)*2)
|
||||
for i := range childPlaylists {
|
||||
found[childPlaylists[i].ID] = struct{}{}
|
||||
r.refreshSmartPlaylist(&childPlaylists[i])
|
||||
if childPlaylists[i].Path != "" {
|
||||
found[norm.NFC.String(childPlaylists[i].Path)] = struct{}{}
|
||||
}
|
||||
r.refreshSmartPlaylistTree(&childPlaylists[i], visited)
|
||||
}
|
||||
for _, id := range childPlaylistIds {
|
||||
if _, ok := found[id]; !ok {
|
||||
log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID)
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range childPlaylistPaths {
|
||||
if _, ok := found[norm.NFC.String(path)]; !ok {
|
||||
log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "path", path, "ownerId", pls.OwnerID)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -124,13 +125,23 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() {
|
|||
criteria.Contains{"title": "Day"},
|
||||
},
|
||||
}
|
||||
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules}
|
||||
nestedPls := model.Playlist{Name: "Nested [ID]", OwnerID: "userid", Public: true, Rules: childRules}
|
||||
Expect(repo.Put(&nestedPls)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
|
||||
|
||||
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
|
||||
childRules = &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.Eq{"artist": "シートベルツ"},
|
||||
},
|
||||
}
|
||||
nestedPathPls := model.Playlist{Name: "Nested [Path]", OwnerID: "userid", Path: "test.nsp", Public: true, Rules: childRules}
|
||||
Expect(repo.Put(&nestedPathPls)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(nestedPathPls.ID) })
|
||||
|
||||
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
|
||||
Expression: criteria.Any{
|
||||
criteria.InPlaylist{"id": nestedPls.ID},
|
||||
criteria.InPlaylist{"path": nestedPathPls.Path},
|
||||
},
|
||||
}}
|
||||
Expect(repo.Put(&parentPls)).To(Succeed())
|
||||
|
|
@ -148,17 +159,88 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() {
|
|||
Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
|
||||
|
||||
// Parent should have tracks from the nested playlist
|
||||
Expect(pls.Tracks).To(HaveLen(1))
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
|
||||
|
||||
// Nested playlist should now have been refreshed (EvaluatedAt set)
|
||||
// Nested playlists should now have been refreshed (EvaluatedAt set)
|
||||
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
|
||||
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
|
||||
|
||||
nestedPlsAfterParentGet, err = repo.Get(nestedPathPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
|
||||
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
|
||||
})
|
||||
})
|
||||
|
||||
It("does not recurse forever when two smart playlists reference each other", func() {
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
|
||||
plsA := model.Playlist{Name: "Cycle A", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
|
||||
Expression: criteria.All{criteria.Contains{"title": "Day"}},
|
||||
}}
|
||||
Expect(repo.Put(&plsA)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(plsA.ID) })
|
||||
|
||||
plsB := model.Playlist{Name: "Cycle B", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
|
||||
Expression: criteria.All{criteria.InPlaylist{"id": plsA.ID}},
|
||||
}}
|
||||
Expect(repo.Put(&plsB)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(plsB.ID) })
|
||||
|
||||
plsA.Rules = &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"id": plsB.ID}}}
|
||||
Expect(repo.Put(&plsA)).To(Succeed())
|
||||
|
||||
_, err := repo.GetWithTracks(plsA.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("does not treat an empty path as a reference to every playlist without a path", func() {
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
|
||||
bystander := model.Playlist{Name: "Bystander", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
|
||||
Expression: criteria.All{criteria.Contains{"title": "Day"}},
|
||||
}}
|
||||
Expect(repo.Put(&bystander)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(bystander.ID) })
|
||||
|
||||
parent := model.Playlist{Name: "Empty Path", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
|
||||
Expression: criteria.All{criteria.InPlaylist{"path": ""}},
|
||||
}}
|
||||
Expect(repo.Put(&parent)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(parent.ID) })
|
||||
|
||||
_, err := repo.GetWithTracks(parent.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
reloaded, err := repo.Get(bystander.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reloaded.EvaluatedAt).To(BeNil())
|
||||
})
|
||||
|
||||
It("matches a child path stored in a different Unicode normalization form", func() {
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
|
||||
child := model.Playlist{Name: "NFD Child", OwnerID: "userid", Public: true, Path: filepath.FromSlash("/mu\u0301sica/child.nsp"), Rules: &criteria.Criteria{
|
||||
Expression: criteria.All{criteria.Contains{"title": "Day"}},
|
||||
}}
|
||||
Expect(repo.Put(&child)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(child.ID) })
|
||||
|
||||
parent := model.Playlist{Name: "NFC Parent", OwnerID: "userid", Rules: &criteria.Criteria{
|
||||
Expression: criteria.All{criteria.InPlaylist{"path": "/m\u00fasica/child.nsp"}},
|
||||
}}
|
||||
Expect(repo.Put(&parent)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(parent.ID) })
|
||||
|
||||
pls, err := repo.GetWithTracks(parent.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(1))
|
||||
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
|
||||
})
|
||||
|
||||
When("refresh delay has not expired", func() {
|
||||
It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
|
||||
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
|
||||
|
|
|
|||
|
|
@ -378,8 +378,9 @@ func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.S
|
|||
for row, err := range cursor {
|
||||
m := toModel(row)
|
||||
if m == nil {
|
||||
// Don't format row: its String() derefs the nil model (golang/go#81238).
|
||||
var zero T
|
||||
yield(zero, fmt.Errorf("unexpected nil %T (%v): %w", zero, row, err))
|
||||
yield(zero, fmt.Errorf("unexpected nil %T: %w", zero, err))
|
||||
return
|
||||
}
|
||||
if !yield(*m, err) || err != nil {
|
||||
|
|
@ -642,5 +643,9 @@ func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffect
|
|||
if code, extended, ok := db.ErrorCodes(err); ok {
|
||||
fields = append(fields, "sqliteCode", code, "sqliteExtended", extended)
|
||||
}
|
||||
if db.IsBusy(err) && hasBusyRetry(r.ctx) {
|
||||
log.Warn(append(fields, err)...)
|
||||
return
|
||||
}
|
||||
log.Error(append(fields, err)...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) {
|
|||
sq := r.newSelect().Columns(r.tableName + ".*")
|
||||
sq = r.withAnnotation(sq, idField)
|
||||
sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil})
|
||||
sq = r.applyLibraryFilter(sq)
|
||||
var mfs dbMediaFiles // TODO Decouple from media_file
|
||||
err := r.queryAll(sq, &mfs)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -71,4 +71,52 @@ var _ = Describe("sqlBookmarks", func() {
|
|||
Expect(mr.GetBookmarks()).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("library access", func() {
|
||||
var otherLib model.Library
|
||||
var restrictedUser model.User
|
||||
var adminCtx context.Context
|
||||
var userMr model.MediaFileRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
adminCtx, otherLib, restrictedUser = restrictedFixture("bmk")
|
||||
|
||||
adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder())
|
||||
Expect(adminMr.Put(&model.MediaFile{
|
||||
ID: "bmk-otherlib-track", LibraryID: otherLib.ID,
|
||||
Path: "hidden/bookmarked.mp3", Title: "Hidden Bookmarked",
|
||||
})).To(Succeed())
|
||||
DeferCleanup(func() { _ = adminMr.Delete("bmk-otherlib-track") })
|
||||
|
||||
userCtx := request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser)
|
||||
userMr = NewMediaFileRepository(userCtx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
It("does not return bookmarks for tracks outside the user's libraries", func() {
|
||||
Expect(userMr.AddBookmark("bmk-otherlib-track", "sneaky", 1)).To(Succeed())
|
||||
|
||||
Expect(userMr.GetBookmarks()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("still returns the bookmark for an admin", func() {
|
||||
adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder())
|
||||
Expect(adminMr.AddBookmark("bmk-otherlib-track", "mine", 1)).To(Succeed())
|
||||
DeferCleanup(func() { _ = adminMr.DeleteBookmark("bmk-otherlib-track") })
|
||||
|
||||
bms, err := adminMr.GetBookmarks()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bms).To(HaveLen(1))
|
||||
Expect(bms[0].Item.ID).To(Equal("bmk-otherlib-track"))
|
||||
})
|
||||
|
||||
It("keeps returning bookmarks for tracks inside the user's libraries", func() {
|
||||
Expect(userMr.AddBookmark(songAntenna.ID, "allowed", 5)).To(Succeed())
|
||||
DeferCleanup(func() { _ = userMr.DeleteBookmark(songAntenna.ID) })
|
||||
|
||||
bms, err := userMr.GetBookmarks()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bms).To(HaveLen(1))
|
||||
Expect(bms[0].Item.ID).To(Equal(songAntenna.ID))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/host"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
"github.com/navidrome/navidrome/utils/netguard"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -53,6 +54,7 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI
|
|||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
Control: svc.dialControl,
|
||||
Resolver: dialResolver,
|
||||
}).DialContext
|
||||
// No client timeout: it is set per-request via context deadline.
|
||||
svc.client = &http.Client{Transport: httpclient.NewTransport(svc.transport)}
|
||||
|
|
@ -201,7 +203,7 @@ func isPrivateOrLoopback(hostname string) bool {
|
|||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return isPrivateIP(ip)
|
||||
return netguard.IsPrivateIP(ip)
|
||||
}
|
||||
|
||||
// Verify interface implementation
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ var _ = Describe("httpServiceImpl", func() {
|
|||
ts *httptest.Server
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
stubLocalhostDNS()
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if ts != nil {
|
||||
ts.Close()
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@ import (
|
|||
"fmt"
|
||||
"net"
|
||||
"slices"
|
||||
|
||||
"github.com/navidrome/navidrome/utils/netguard"
|
||||
)
|
||||
|
||||
// dialResolver is nil in production (the system resolver); tests swap in a stub to avoid real DNS.
|
||||
var dialResolver *net.Resolver
|
||||
|
||||
// checkPrivateDial runs at dial time on the resolved IP, so hostnames can't reach private addresses unless a
|
||||
// literal IP/CIDR entry or a bare "*" (plugins targeting user-configured LAN services) allows it.
|
||||
func checkPrivateDial(requiredHosts []string, address string) error {
|
||||
|
|
@ -17,7 +22,7 @@ func checkPrivateDial(requiredHosts []string, address string) error {
|
|||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !isPrivateIP(ip) {
|
||||
if ip == nil || !netguard.IsPrivateIP(ip) {
|
||||
return nil
|
||||
}
|
||||
for _, entry := range requiredHosts {
|
||||
|
|
@ -51,7 +56,3 @@ func ipMatchesEntry(entry string, ip net.IP) bool {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -689,11 +689,12 @@ var _ = Describe("TaskQueueService", func() {
|
|||
copy(times, dispatchTimes)
|
||||
mu.Unlock()
|
||||
|
||||
// Consecutive dispatches should have at least ~160ms gap (80% of 200ms)
|
||||
// Wake-up latency varies per worker, so check offsets from the first dispatch, not gaps.
|
||||
for i := 1; i < len(times); i++ {
|
||||
gap := times[i].Sub(times[i-1])
|
||||
Expect(gap).To(BeNumerically(">=", 160*time.Millisecond),
|
||||
fmt.Sprintf("gap between dispatch %d and %d was %v, expected >= 160ms", i-1, i, gap))
|
||||
offset := times[i].Sub(times[0])
|
||||
minOffset := time.Duration(i)*200*time.Millisecond - 50*time.Millisecond
|
||||
Expect(offset).To(BeNumerically(">=", minOffset),
|
||||
fmt.Sprintf("dispatch %d ran %v after the first, expected >= %v", i, offset, minOffset))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ func (s *webSocketServiceImpl) Connect(ctx context.Context, urlStr string, heade
|
|||
// Establish WebSocket connection
|
||||
dialer := websocket.Dialer{
|
||||
HandshakeTimeout: 30 * time.Second,
|
||||
NetDialContext: (&net.Dialer{Control: s.dialControl}).DialContext,
|
||||
NetDialContext: (&net.Dialer{Control: s.dialControl, Resolver: dialResolver}).DialContext,
|
||||
}
|
||||
|
||||
conn, resp, err := dialer.DialContext(ctx, urlStr, httpHeaders)
|
||||
|
|
|
|||
|
|
@ -505,6 +505,7 @@ var _ = Describe("WebSocketService", Ordered, func() {
|
|||
var savedHosts []string
|
||||
|
||||
BeforeEach(func() {
|
||||
stubLocalhostDNS()
|
||||
savedHosts = testService.requiredHosts
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
||||
wsServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ func TestPlugins(t *testing.T) {
|
|||
RunSpecs(t, "Plugins Suite")
|
||||
}
|
||||
|
||||
// stubLocalhostDNS resolves "localhost." without real DNS: the trailing dot never matches /etc/hosts.
|
||||
func stubLocalhostDNS() {
|
||||
dialResolver = tests.StubResolver(map[string]string{"localhost.": "127.0.0.1"})
|
||||
DeferCleanup(func() { dialResolver = nil })
|
||||
}
|
||||
|
||||
// createTestManager creates a new plugin Manager with the given plugin config.
|
||||
// It creates a temp directory, copies the test-metadata-agent plugin, and starts the manager.
|
||||
// Returns the manager, temp directory path, and a cleanup function.
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@
|
|||
"download": "Herunterladen",
|
||||
"info": "Mehr Informationen",
|
||||
"share": "Freigabe erstellen",
|
||||
"refresh": ""
|
||||
"refresh": "Metadaten aktualisieren"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Alle",
|
||||
|
|
@ -157,12 +157,12 @@
|
|||
"token": "Token",
|
||||
"lastAccessAt": "Letzter Zugriff am",
|
||||
"libraries": "Bibliotheken",
|
||||
"scrobbleFilter": ""
|
||||
"scrobbleFilter": "Scrobble-Filter"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Die Änderung wird erst nach dem nächsten Login gültig",
|
||||
"libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken",
|
||||
"scrobbleFilter": ""
|
||||
"scrobbleFilter": "Titel, die diesen Regeln für intelligente Wiedergabelisten entsprechen, werden nicht an Last.fm, ListenBrainz oder Scrobbler-Plugins übermittelt. Es werden dieselbe JSON-Syntax und dasselbe Verhalten wie bei intelligenten Wiedergabelisten verwendet. Beispiel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Ist das Feld leer, wird alles gescrobbelt. Lokale Wiedergabezahlen bleiben davon unberührt."
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Benutzer erstellt",
|
||||
|
|
@ -177,7 +177,7 @@
|
|||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein",
|
||||
"invalidScrobbleFilter": ""
|
||||
"invalidScrobbleFilter": "Es müssen gültige Regeln für intelligente Wiedergabelisten sein. Limit, Offset und Aktualisierungsverzögerung werden nicht unterstützt."
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
|
|
@ -202,7 +202,7 @@
|
|||
"command": "Befehl"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": ""
|
||||
"noDefaultBitRate": "Keine"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
|
|
@ -398,6 +398,7 @@
|
|||
"invalidJson": "Konfiguration muss valides JSON sein"
|
||||
},
|
||||
"messages": {
|
||||
"idHelp": "Die Plugin-ID, abgeleitet vom Dateinamen. Verwende diese, um in Konfigurationen (z. B. bei Agents) auf dieses Plugin zu verweisen.",
|
||||
"configHelp": "Plugin mit Schlüssel-Werte Paaren konfigurieren. Leer lassen wenn das Plugin keine Konfiguration benötigt.",
|
||||
"clickPermissions": "Berechtigung anklicken für mehr Details",
|
||||
"noConfig": "Keine Konfiguration gesetzt",
|
||||
|
|
@ -411,8 +412,7 @@
|
|||
"requiredHosts": "Benötigte Hosts",
|
||||
"configValidationError": "Validierung der Konfiguration fehlgeschlagen:",
|
||||
"schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt.",
|
||||
"allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff.",
|
||||
"idHelp": ""
|
||||
"allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "Schlüssel",
|
||||
|
|
@ -573,7 +573,7 @@
|
|||
"noPlaylistsAvailable": "Keine Wiedergabeliste verfügbar",
|
||||
"delete_user_title": "Benutzer '%{name}' löschen",
|
||||
"delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Wiedergabelisten und Einstellungen) wirklich löschen?",
|
||||
"notifications_blocked": "Sie haben Benachrichtigungen für diese Seite in den Einstellungen Ihres Browsers blockiert",
|
||||
"notifications_blocked": "Benachrichtigungen für diese Seite sind in den Browsereinstellungen blockiert",
|
||||
"notifications_not_available": "Dieser Browser unterstützt keine Desktop-Benachrichtigungen",
|
||||
"lastfmLinkSuccess": "Last.fm Verbindung hergestellt und scrobbling aktiviert",
|
||||
"lastfmLinkFailure": "Last.fm konnte nicht verbunden werden",
|
||||
|
|
@ -609,7 +609,7 @@
|
|||
"coverRemoved": "Cover entfernt",
|
||||
"coverUploadError": "Fehler beim Hochladen des Covers",
|
||||
"coverRemoveError": "Fehler beim Entfernen des Covers",
|
||||
"metadataRefreshStarted": ""
|
||||
"metadataRefreshStarted": "Aktualisiert Metadaten im Hintergrund"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothek",
|
||||
|
|
@ -731,4 +731,4 @@
|
|||
"empty": "Keine Wiedergabe",
|
||||
"minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@
|
|||
"command": "Komento"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": ""
|
||||
"noDefaultBitRate": "Ei mikään"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
|
|
@ -609,7 +609,10 @@
|
|||
"coverRemoved": "Kansikuva poistettu",
|
||||
"coverUploadError": "Virhe ladattaessa kansikuvaa",
|
||||
"coverRemoveError": "Virhe poistettaessa kansikuvaa",
|
||||
"metadataRefreshStarted": "Metatietoja päivitetään taustalla"
|
||||
"metadataRefreshStarted": "Metatietoja päivitetään taustalla",
|
||||
"quickConnectApproved": "",
|
||||
"quickConnectInvalidCode": "",
|
||||
"quickConnectError": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Kirjasto",
|
||||
|
|
@ -645,7 +648,15 @@
|
|||
"selectLibraries": "Valitse kirjastot",
|
||||
"none": "Ei mitään"
|
||||
},
|
||||
"onlyFavourites": "Näytä vain suosikit"
|
||||
"onlyFavourites": "Näytä vain suosikit",
|
||||
"quickConnect": {
|
||||
"name": "",
|
||||
"code": "",
|
||||
"help": "",
|
||||
"confirm": "",
|
||||
"continue": "",
|
||||
"approve": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Jono",
|
||||
|
|
|
|||
|
|
@ -93,7 +93,8 @@
|
|||
"addToPlaylist": "Toevoegen aan afspeellijst",
|
||||
"download": "Downloaden",
|
||||
"info": "Meer info",
|
||||
"share": "Delen"
|
||||
"share": "Delen",
|
||||
"refresh": "Metadata verversen"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Alle",
|
||||
|
|
@ -155,11 +156,13 @@
|
|||
"newPassword": "Nieuw wachtwoord",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Meest recente toegang",
|
||||
"libraries": "Bibliotheken"
|
||||
"libraries": "Bibliotheken",
|
||||
"scrobbleFilter": "Scrobble filter"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Naamswijziging wordt pas zichtbaar bij de volgende login",
|
||||
"libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken"
|
||||
"libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken",
|
||||
"scrobbleFilter": "Nummers binnen deze slimme afspeellijst regels worden niet gestuurd naar Last.fm, ListenBrainz of scrobbler plugins. Gebruikt dezelfde JSON syntaxis en gedrag als slimme afspeellijsten. Voorbeeld: {\"all\":[{\"lt\":{\"rating\":4}}]}. Leeglaten om alles te scrobblen. Lokale afspeelaantallen vallen hierbuiten."
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Aangemaakt door gebruiker",
|
||||
|
|
@ -173,7 +176,8 @@
|
|||
"adminAutoLibraries": "Admin gebruikers hebben automatisch toegang tot alle bibliotheken"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers"
|
||||
"librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers",
|
||||
"invalidScrobbleFilter": "Moeten geldige slimme afspeellijst regels zijn. Geen ondersteuning voor Limit, Offset en Refresh Delay"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
|
|
@ -196,6 +200,9 @@
|
|||
"targetFormat": "Doelformaat",
|
||||
"defaultBitRate": "Standaard bitrate",
|
||||
"command": "Commando"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": "Geen"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
|
|
@ -210,7 +217,8 @@
|
|||
"songCount": "Nummers",
|
||||
"comment": "Commentaar",
|
||||
"sync": "Auto-importeren",
|
||||
"path": "Importeer vanuit"
|
||||
"path": "Importeer vanuit",
|
||||
"starred": "Favoriet"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Selecteer een afspeellijst:",
|
||||
|
|
@ -403,7 +411,8 @@
|
|||
"requiredHosts": "Benodigde hosts",
|
||||
"configValidationError": "Configuratiecheck mislukt",
|
||||
"schemaRenderError": "Kan het configuratieformulier niet verwerken. Het plugin schema is wellicht ongeldig.",
|
||||
"allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen."
|
||||
"allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen.",
|
||||
"idHelp": "De plugin ID, afgeleid van zijn bestandsnaam. Gebruik dit als gerefereerd wordt aan deze plugin in de configuratie opties, zoals Agenten."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "Sleutel",
|
||||
|
|
@ -599,7 +608,11 @@
|
|||
"coverUploaded": "Albumhoes bijgewerkt",
|
||||
"coverRemoved": "Albumhoes verwijderd",
|
||||
"coverUploadError": "Fout bij het toevoegen albumhoes",
|
||||
"coverRemoveError": "Fout bij verwijderen albumhoes"
|
||||
"coverRemoveError": "Fout bij verwijderen albumhoes",
|
||||
"metadataRefreshStarted": "Metadata verversen op de achtergrond",
|
||||
"quickConnectApproved": "",
|
||||
"quickConnectInvalidCode": "",
|
||||
"quickConnectError": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotheek",
|
||||
|
|
@ -634,6 +647,15 @@
|
|||
"multipleLibraries": "%{selected} van %{total} bibliotheken",
|
||||
"selectLibraries": "Selecteer bibliotheken",
|
||||
"none": "Geen"
|
||||
},
|
||||
"onlyFavourites": "Toon alleen favorieten",
|
||||
"quickConnect": {
|
||||
"name": "",
|
||||
"code": "",
|
||||
"help": "",
|
||||
"confirm": "",
|
||||
"continue": "",
|
||||
"approve": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@
|
|||
"command": "Comando"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": ""
|
||||
"noDefaultBitRate": "Nenhum"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
|
|
@ -609,7 +609,10 @@
|
|||
"coverRemoved": "Capa removida",
|
||||
"coverUploadError": "Erro ao enviar capa",
|
||||
"coverRemoveError": "Erro ao remover capa",
|
||||
"metadataRefreshStarted": "Atualizando metadados em segundo plano"
|
||||
"metadataRefreshStarted": "Atualizando metadados em segundo plano",
|
||||
"quickConnectApproved": "%{app} em %{device} está conectado agora",
|
||||
"quickConnectInvalidCode": "Código inválido ou expirado",
|
||||
"quickConnectError": "Não foi possível aprovar o código"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
|
|
@ -645,7 +648,15 @@
|
|||
"selectLibraries": "Selecionar Bibliotecas",
|
||||
"none": "Nenhuma"
|
||||
},
|
||||
"onlyFavourites": "Somente favoritas"
|
||||
"onlyFavourites": "Somente favoritas",
|
||||
"quickConnect": {
|
||||
"name": "Conexão Rápida",
|
||||
"code": "Código",
|
||||
"help": "Digite o código exibido por um aplicativo Jellyfin para conectá-lo à sua conta",
|
||||
"confirm": "Conectar %{app} %{version} em %{device} à sua conta?",
|
||||
"continue": "Continuar",
|
||||
"approve": "Aprovar"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Fila de Execução",
|
||||
|
|
|
|||
|
|
@ -11,22 +11,22 @@
|
|||
"title": "Titel",
|
||||
"artist": "Artist",
|
||||
"album": "Album",
|
||||
"path": "Sökväg",
|
||||
"path": "Filsökväg",
|
||||
"genre": "Genre",
|
||||
"compilation": "Samling",
|
||||
"compilation": "Samlingsalbum",
|
||||
"year": "År",
|
||||
"size": "Filstorlek",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"bitRate": "Bitrate",
|
||||
"discSubtitle": "Underrubrik",
|
||||
"bitRate": "Bithastighet",
|
||||
"discSubtitle": "Skivans undertitel",
|
||||
"starred": "Favorit",
|
||||
"comment": "Kommentar",
|
||||
"rating": "Betyg",
|
||||
"quality": "Kvalitet",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Senast spelad",
|
||||
"channels": "Channels",
|
||||
"createdAt": "Skapad",
|
||||
"channels": "Kanaler",
|
||||
"createdAt": "Tillagd",
|
||||
"grouping": "Gruppering",
|
||||
"mood": "Stämning",
|
||||
"participants": "Ytterligare medverkande",
|
||||
|
|
@ -35,19 +35,21 @@
|
|||
"rawTags": "Omodifierade taggar",
|
||||
"bitDepth": "Bitdjup",
|
||||
"sampleRate": "Samplingsfrekvens",
|
||||
"missing": "Saknade",
|
||||
"missing": "Saknas",
|
||||
"libraryName": "Bibliotek",
|
||||
"composer": "Kompositör",
|
||||
"disc": "Disc %{discNumber}"
|
||||
"disc": "Skiva %{discNumber}",
|
||||
"albumGain": "Volymjustering (album)",
|
||||
"trackGain": "Volymjustering (låt)"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lägg till i kön",
|
||||
"playNow": "Spela nu",
|
||||
"addToPlaylist": "Lägg till i spellista",
|
||||
"shuffleAll": "Shuffle",
|
||||
"shuffleAll": "Blanda alla",
|
||||
"download": "Ladda ner",
|
||||
"playNext": "Spela nästa",
|
||||
"info": "Mer information",
|
||||
"playNext": "Spela härnäst",
|
||||
"info": "Visa information",
|
||||
"showInPlaylist": "Visa i spellista",
|
||||
"instantMix": "Direktmix"
|
||||
}
|
||||
|
|
@ -62,12 +64,12 @@
|
|||
"playCount": "Spelningar",
|
||||
"name": "Namn",
|
||||
"genre": "Genre",
|
||||
"compilation": "Samling",
|
||||
"compilation": "Samlingsalbum",
|
||||
"year": "År",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"comment": "Kommentar",
|
||||
"rating": "Betyg",
|
||||
"createdAt": "Skapad",
|
||||
"createdAt": "Tillagt",
|
||||
"size": "Storlek",
|
||||
"originalDate": "Originaldatum",
|
||||
"releaseDate": "Utgivningsdatum",
|
||||
|
|
@ -80,27 +82,28 @@
|
|||
"media": "Media",
|
||||
"mood": "Stämning",
|
||||
"date": "Inspelningsdatum",
|
||||
"missing": "Saknade",
|
||||
"missing": "Saknas",
|
||||
"libraryName": "Bibliotek"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Spela",
|
||||
"playNext": "Spela härnäst",
|
||||
"addToQueue": "Lägg till i kön",
|
||||
"shuffle": "Shuffle",
|
||||
"shuffle": "Blanda",
|
||||
"addToPlaylist": "Lägg till i spellista",
|
||||
"download": "Ladda ner",
|
||||
"info": "Mer information",
|
||||
"share": "Dela"
|
||||
"info": "Visa information",
|
||||
"share": "Dela",
|
||||
"refresh": "Uppdatera metadata"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Alla",
|
||||
"random": "Blanda",
|
||||
"recentlyAdded": "Senast tillagda",
|
||||
"recentlyPlayed": "Senast spelade",
|
||||
"random": "Slumpmässiga",
|
||||
"recentlyAdded": "Nyligen tillagda",
|
||||
"recentlyPlayed": "Nyligen spelade",
|
||||
"mostPlayed": "Mest spelade",
|
||||
"starred": "Favoriter",
|
||||
"topRated": "Bästa betyg"
|
||||
"topRated": "Högst betygsatta"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
|
|
@ -114,26 +117,26 @@
|
|||
"genre": "Genre",
|
||||
"size": "Storlek",
|
||||
"role": "Roll",
|
||||
"missing": "Saknade"
|
||||
"missing": "Saknas"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albumartist |||| Albumartister",
|
||||
"artist": "Artist |||| Artister",
|
||||
"composer": "Kompositör |||| Kompositörer",
|
||||
"composer": "Kompositör |||| Kompositörer",
|
||||
"conductor": "Dirigent |||| Dirigenter",
|
||||
"lyricist": "Textförfattare |||| Textförfattare",
|
||||
"arranger": "Arrangör |||| Arrangörer",
|
||||
"producer": "Producent |||| Producenter",
|
||||
"director": "Inspelningsledare |||| Inspelningsledare",
|
||||
"engineer": "Ljudtekniker |||| Ljudtekniker",
|
||||
"mixer": "Mixare |||| Mixare",
|
||||
"remixer": "Remixare |||| Remixare",
|
||||
"djmixer": "DJ-mixare |||| DJ-mixare",
|
||||
"performer": "Utövande artist |||| Utövande artister",
|
||||
"maincredit": "Albumartister eller Artist |||| Albumartister eller Artister"
|
||||
"mixer": "Mixare |||| Mixare",
|
||||
"remixer": "Remixare |||| Remixare",
|
||||
"djmixer": "DJ-mixare |||| DJ-mixare",
|
||||
"performer": "Utövande artist |||| Utövande artister",
|
||||
"maincredit": "Albumartist eller artist |||| Albumartister eller artister"
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "Shuffle",
|
||||
"shuffle": "Blanda",
|
||||
"radio": "Radio",
|
||||
"topSongs": "Topplåtar"
|
||||
}
|
||||
|
|
@ -142,7 +145,7 @@
|
|||
"name": "Användare |||| Användare",
|
||||
"fields": {
|
||||
"userName": "Användarnamn",
|
||||
"isAdmin": "Är admin",
|
||||
"isAdmin": "Administratör",
|
||||
"lastLoginAt": "Senaste inloggning",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"name": "Namn",
|
||||
|
|
@ -151,13 +154,15 @@
|
|||
"changePassword": "Byt lösenord?",
|
||||
"currentPassword": "Nuvarande lösenord",
|
||||
"newPassword": "Nytt lösenord",
|
||||
"token": "Token",
|
||||
"token": "Åtkomsttoken",
|
||||
"lastAccessAt": "Senaste åtkomst",
|
||||
"libraries": "Bibliotek"
|
||||
"libraries": "Bibliotek",
|
||||
"scrobbleFilter": "Scrobblingsfilter"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Ändringar av ditt namn syns först vid nästa inloggning",
|
||||
"libraries": "Välj ett bibliotek för denna användare eller lämna blankt för standardbibliotek"
|
||||
"libraries": "Välj vilka bibliotek användaren ska ha tillgång till, eller lämna tomt för att använda standardbiblioteken",
|
||||
"scrobbleFilter": "Låtar som matchar dessa regler för smarta spellistor skickas inte till Last.fm, ListenBrainz eller scrobblingtillägg. Samma JSON-syntax och beteende som för smarta spellistor används. Exempel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Lämna tomt för att scrobbla allt. Antalet spelningar som lagras lokalt påverkas inte."
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Användare skapad",
|
||||
|
|
@ -165,13 +170,14 @@
|
|||
"deleted": "Användare borttagen"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Ange din ListenBrainz användar-token.",
|
||||
"listenBrainzToken": "Ange din användartoken för ListenBrainz.",
|
||||
"clickHereForToken": "Klicka här för att hämta din token",
|
||||
"selectAllLibraries": "Välj alla bibliotek",
|
||||
"adminAutoLibraries": "Administratörer har automatiskt tillgång till alla bibliotek"
|
||||
"adminAutoLibraries": "Administratörer har automatiskt åtkomst till alla bibliotek"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Minst ett bibliotek måste väljas för icke-administratörer"
|
||||
"librariesRequired": "Minst ett bibliotek måste väljas för användare som inte är administratörer",
|
||||
"invalidScrobbleFilter": "Måste innehålla giltiga regler för smarta spellistor. Begränsning av antalet låtar, förskjutning och uppdateringsfördröjning stöds inte."
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
|
|
@ -179,12 +185,12 @@
|
|||
"fields": {
|
||||
"name": "Namn",
|
||||
"transcodingId": "Omkodning",
|
||||
"maxBitRate": "Max. bitrate",
|
||||
"maxBitRate": "Högsta bithastighet",
|
||||
"client": "Klient",
|
||||
"userName": "Användarnamn",
|
||||
"lastSeen": "Senast sedd",
|
||||
"reportRealPath": "Visa hela sökvägen",
|
||||
"scrobbleEnabled": "Scrobbla till extern tjänst"
|
||||
"reportRealPath": "Rapportera den faktiska sökvägen",
|
||||
"scrobbleEnabled": "Scrobbla till externa tjänster"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
|
|
@ -192,8 +198,11 @@
|
|||
"fields": {
|
||||
"name": "Namn",
|
||||
"targetFormat": "Målformat",
|
||||
"defaultBitRate": "Standardbitrate",
|
||||
"defaultBitRate": "Standardbithastighet",
|
||||
"command": "Kommando"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": "Ingen"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
|
|
@ -207,8 +216,9 @@
|
|||
"createdAt": "Skapad",
|
||||
"songCount": "Låtar",
|
||||
"comment": "Kommentar",
|
||||
"sync": "Auto-import",
|
||||
"path": "Importera från"
|
||||
"sync": "Automatisk import",
|
||||
"path": "Importera från",
|
||||
"starred": "Favorit"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Välj en spellista:",
|
||||
|
|
@ -216,24 +226,24 @@
|
|||
"export": "Exportera",
|
||||
"makePublic": "Gör offentlig",
|
||||
"makePrivate": "Gör privat",
|
||||
"saveQueue": "Spara kö till spellista",
|
||||
"searchOrCreate": "Sök spellista eller skapa ny...",
|
||||
"pressEnterToCreate": "Tryck Enter för att skapa ny spellista",
|
||||
"removeFromSelection": "Ta bort från urval"
|
||||
"saveQueue": "Spara kön i en spellista",
|
||||
"searchOrCreate": "Sök spellistor eller skriv för att skapa en ny...",
|
||||
"pressEnterToCreate": "Tryck på Enter för att skapa en ny spellista",
|
||||
"removeFromSelection": "Ta bort från urvalet"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Lägg till dubletter",
|
||||
"song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?",
|
||||
"noPlaylistsFound": "Hittade inga spellistor",
|
||||
"duplicate_song": "Lägg till dubbletter",
|
||||
"song_exist": "Du håller på att lägga till dubbletter i spellistan. Vill du lägga till dem eller hoppa över dem?",
|
||||
"noPlaylistsFound": "Inga spellistor hittades",
|
||||
"noPlaylists": "Inga spellistor tillgängliga"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "Radio |||| Radior",
|
||||
"name": "Radiostation |||| Radiostationer",
|
||||
"fields": {
|
||||
"name": "Namn",
|
||||
"streamUrl": "Stream-URL",
|
||||
"homePageUrl": "Hemside-URL",
|
||||
"streamUrl": "Strömmens URL",
|
||||
"homePageUrl": "Webbplatsens URL",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"createdAt": "Skapad"
|
||||
},
|
||||
|
|
@ -242,7 +252,7 @@
|
|||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Dela |||| Delningar",
|
||||
"name": "Delning |||| Delningar",
|
||||
"fields": {
|
||||
"username": "Delad av",
|
||||
"url": "URL",
|
||||
|
|
@ -252,11 +262,13 @@
|
|||
"lastVisitedAt": "Senast besökt",
|
||||
"visitCount": "Besök",
|
||||
"format": "Format",
|
||||
"maxBitRate": "Max. bitrate",
|
||||
"maxBitRate": "Högsta bithastighet",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"createdAt": "Skapad",
|
||||
"downloadable": "Tillåt nedladdning?"
|
||||
}
|
||||
"downloadable": "Tillåt nedladdningar?"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Saknad fil |||| Saknade filer",
|
||||
|
|
@ -267,11 +279,11 @@
|
|||
"libraryName": "Bibliotek"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Radera",
|
||||
"remove_all": "Radera alla"
|
||||
"remove": "Ta bort",
|
||||
"remove_all": "Ta bort alla"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Saknade fil(er) borttagna"
|
||||
"removed": "Saknade filer har tagits bort"
|
||||
},
|
||||
"empty": "Inga saknade filer"
|
||||
},
|
||||
|
|
@ -280,8 +292,8 @@
|
|||
"fields": {
|
||||
"name": "Namn",
|
||||
"path": "Sökväg",
|
||||
"remotePath": "Ta bort sökväg",
|
||||
"lastScanAt": "Senaste scan",
|
||||
"remotePath": "Fjärrsökväg",
|
||||
"lastScanAt": "Senaste skanning",
|
||||
"songCount": "Låtar",
|
||||
"albumCount": "Album",
|
||||
"artistCount": "Artister",
|
||||
|
|
@ -302,33 +314,33 @@
|
|||
"statistics": "Statistik"
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Scanna bibliotek",
|
||||
"scan": "Skanna biblioteket",
|
||||
"manageUsers": "Hantera användaråtkomst",
|
||||
"viewDetails": "Se detaljer",
|
||||
"quickScan": "Snabbscan",
|
||||
"fullScan": "Komplett scan"
|
||||
"viewDetails": "Visa detaljer",
|
||||
"quickScan": "Snabbskanning",
|
||||
"fullScan": "Fullständig skanning"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Biblioteket har skapats",
|
||||
"updated": "Biblioteket har uppdaterats",
|
||||
"deleted": "Biblioteket har raderats",
|
||||
"scanStarted": "Biblioteksscan startad",
|
||||
"scanCompleted": "Biblioteksscan avslutad",
|
||||
"quickScanStarted": "Snabbscan startad",
|
||||
"fullScanStarted": "Komplett scan startad",
|
||||
"scanError": "Fel vid start av scan. Se loggarna"
|
||||
"deleted": "Biblioteket har tagits bort",
|
||||
"scanStarted": "Skanning av biblioteket har startat",
|
||||
"scanCompleted": "Skanning av biblioteket är klar",
|
||||
"quickScanStarted": "Snabbskanning har startat",
|
||||
"fullScanStarted": "Fullständig skanning har startat",
|
||||
"scanError": "Kunde inte starta skanningen. Kontrollera loggarna"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Biblioteksnamn krävs",
|
||||
"pathRequired": "Bibliotekssökväg krävs",
|
||||
"pathNotDirectory": "Bibliotekssökvägen måste vara en katalog",
|
||||
"pathNotFound": "Bibliotekssökväg hittades inte",
|
||||
"pathNotAccessible": "Bibliotekssökväg inte tillgänglig",
|
||||
"pathInvalid": "Ogiltig bibliotekssökväg"
|
||||
"nameRequired": "Ange ett biblioteksnamn",
|
||||
"pathRequired": "Ange en sökväg till biblioteket",
|
||||
"pathNotDirectory": "Bibliotekets sökväg måste peka på en katalog",
|
||||
"pathNotFound": "Bibliotekets sökväg hittades inte",
|
||||
"pathNotAccessible": "Bibliotekets sökväg är inte åtkomlig",
|
||||
"pathInvalid": "Ogiltig sökväg till biblioteket"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "Är du säker på att du vill ta bort detta bibliotek? Detta raderar all förbunden data och användartillgång.",
|
||||
"scanInProgress": "Scanning pågår...",
|
||||
"deleteConfirm": "Är du säker på att du vill ta bort det här biblioteket? Alla tillhörande data och användarnas åtkomst till biblioteket tas bort.",
|
||||
"scanInProgress": "Skanning pågår...",
|
||||
"noLibrariesAssigned": "Inga bibliotek har tilldelats den här användaren"
|
||||
}
|
||||
},
|
||||
|
|
@ -339,44 +351,44 @@
|
|||
"name": "Namn",
|
||||
"description": "Beskrivning",
|
||||
"version": "Version",
|
||||
"author": "Författare",
|
||||
"website": "Website",
|
||||
"author": "Upphovsperson",
|
||||
"website": "Webbplats",
|
||||
"permissions": "Behörigheter",
|
||||
"enabled": "Aktiverad",
|
||||
"enabled": "Aktiverat",
|
||||
"status": "Status",
|
||||
"path": "Sökväg",
|
||||
"lastError": "Fel",
|
||||
"hasError": "Fel",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"createdAt": "Installerad",
|
||||
"updatedAt": "Uppdaterat",
|
||||
"createdAt": "Installerat",
|
||||
"configKey": "Nyckel",
|
||||
"configValue": "Värde",
|
||||
"allUsers": "Tillåt alla användare",
|
||||
"selectedUsers": "Valda användare",
|
||||
"allLibraries": "Tillåt alla bibliotek",
|
||||
"selectedLibraries": "Valda bibliotek",
|
||||
"allowWriteAccess": "Tillåt skrivrättigheter"
|
||||
"allowWriteAccess": "Tillåt skrivåtkomst"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
"info": "Tilläggsinformation",
|
||||
"info": "Information om tillägget",
|
||||
"configuration": "Konfiguration",
|
||||
"manifest": "Manifest",
|
||||
"usersPermission": "Användarbehörigheter",
|
||||
"libraryPermission": "Biblioteksbehörigheter"
|
||||
"usersPermission": "Åtkomst till användare",
|
||||
"libraryPermission": "Åtkomst till bibliotek"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Aktiverad",
|
||||
"disabled": "Inaktiverad"
|
||||
"enabled": "Aktiverat",
|
||||
"disabled": "Inaktiverat"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Aktivera",
|
||||
"disable": "Inaktivera",
|
||||
"disabledDueToError": "Åtgärda felet innan aktivering",
|
||||
"disabledDueToError": "Åtgärda felet före aktivering",
|
||||
"disabledUsersRequired": "Välj användare före aktivering",
|
||||
"disabledLibrariesRequired": "Välj bibliotek före aktivering",
|
||||
"addConfig": "Lägg till konfiguration",
|
||||
"rescan": "Scanna om"
|
||||
"rescan": "Skanna om"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Tillägg aktiverat",
|
||||
|
|
@ -391,17 +403,18 @@
|
|||
"configHelp": "Konfigurera tillägget med nyckel–värde-par. Lämna tomt om tillägget inte kräver någon konfiguration.",
|
||||
"clickPermissions": "Klicka på en behörighet för mer information",
|
||||
"noConfig": "Ingen konfiguration angiven",
|
||||
"allUsersHelp": "När den är aktiverad får tillägget tillgång till alla användare, inklusive de som skapas i framtiden.",
|
||||
"allUsersHelp": "När alternativet är aktiverat får tillägget åtkomst till alla användare, även de som skapas i framtiden.",
|
||||
"noUsers": "Inga användare valda",
|
||||
"permissionReason": "Orsak",
|
||||
"usersRequired": "Detta tillägg kräver åtkomst till användarinformation. Välj vilka användare insticksprogrammet ska ha åtkomst till, eller aktivera 'Tillåt alla användare'.",
|
||||
"allLibrariesHelp": "När den är aktiverad får tillägget tillgång till alla bibliotek, inklusive de som skapas i framtiden.",
|
||||
"usersRequired": "Det här tillägget behöver åtkomst till användarinformation. Välj vilka användare tillägget får åtkomst till eller aktivera 'Tillåt alla användare'.",
|
||||
"allLibrariesHelp": "När alternativet är aktiverat får tillägget åtkomst till alla bibliotek, även de som skapas i framtiden.",
|
||||
"noLibraries": "Inga bibliotek valda",
|
||||
"librariesRequired": "Detta tillägg kräver tillgång till biblioteksinformation. Välj vilka bibliotek tillägget kan komma åt eller aktivera 'Tillåt alla bibliotek'.",
|
||||
"requiredHosts": "Krävda värdar",
|
||||
"librariesRequired": "Det här tillägget behöver åtkomst till biblioteksinformation. Välj vilka bibliotek tillägget får åtkomst till eller aktivera 'Tillåt alla bibliotek'.",
|
||||
"requiredHosts": "Värdar som krävs",
|
||||
"configValidationError": "Validering av konfigurationen misslyckades:",
|
||||
"schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.",
|
||||
"allowWriteAccessHelp": "När detta är aktiverat kan tillägget ändra filer i bibliotekets kataloger. Som standard har tillägget endast läsrättigheter."
|
||||
"schemaRenderError": "Kunde inte visa konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.",
|
||||
"allowWriteAccessHelp": "När alternativet är aktiverat kan tillägget ändra filer i bibliotekens kataloger. Som standard har tillägg endast läsåtkomst.",
|
||||
"idHelp": "Tilläggets ID hämtas från filnamnet. Använd det när du hänvisar till tillägget i konfigurationsalternativ, till exempel Agents."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "nyckel",
|
||||
|
|
@ -412,40 +425,40 @@
|
|||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Tack för att du installerade Navidrome!",
|
||||
"welcome2": "Skapa först ett admin-konto",
|
||||
"welcome2": "Börja med att skapa ett administratörskonto",
|
||||
"confirmPassword": "Bekräfta lösenord",
|
||||
"buttonCreateAdmin": "Skapa admin-konto",
|
||||
"buttonCreateAdmin": "Skapa administratörskonto",
|
||||
"auth_check_error": "Logga in för att fortsätta",
|
||||
"user_menu": "Profil",
|
||||
"username": "Användarnamn",
|
||||
"password": "Lösenord",
|
||||
"sign_in": "Logga in",
|
||||
"sign_in_error": "Felaktig inloggning, försök igen",
|
||||
"sign_in_error": "Inloggningen misslyckades. Försök igen",
|
||||
"logout": "Logga ut",
|
||||
"insightsCollectionNote": "Navidrome samlar anonym användardata för att\nhjälpa projektet att bli bättre. Klicka [här]\nför att läsa mer och avaktivera om du vill"
|
||||
"insightsCollectionNote": "Navidrome samlar in anonyma användningsdata för\natt förbättra projektet. Klicka [här] för att läsa mer\noch välja bort insamlingen"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Använd enbart bokstäver och siffror",
|
||||
"passwordDoesNotMatch": "Lösenordet matchar inte",
|
||||
"required": "Krävs",
|
||||
"minLength": "Måste ha minst %{min} tecken",
|
||||
"maxLength": "Får maximalt ha %{max} tecken",
|
||||
"passwordDoesNotMatch": "Lösenorden stämmer inte överens",
|
||||
"required": "Obligatoriskt",
|
||||
"minLength": "Måste innehålla minst %{min} tecken",
|
||||
"maxLength": "Får innehålla högst %{max} tecken",
|
||||
"minValue": "Måste vara minst %{min}",
|
||||
"maxValue": "Får maximalt vara %{max}",
|
||||
"number": "Måste vara ett nummer",
|
||||
"maxValue": "Får vara högst %{max}",
|
||||
"number": "Måste vara ett tal",
|
||||
"email": "Måste vara en giltig e-postadress",
|
||||
"oneOf": "Måste vara en av: %{options}",
|
||||
"regex": "Måste matcha ett specifikt format (regexp): %{pattern}",
|
||||
"unique": "Måste vara unik",
|
||||
"oneOf": "Måste vara något av: %{options}",
|
||||
"regex": "Måste matcha det reguljära uttrycket: %{pattern}",
|
||||
"unique": "Måste vara unikt",
|
||||
"url": "Måste vara en giltig URL"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Lägg till filter",
|
||||
"add": "Lägg till",
|
||||
"back": "Tillbaka",
|
||||
"bulk_actions": "1 objekt vald |||| %{smart_count} objekt valda",
|
||||
"bulk_actions": "1 objekt valt |||| %{smart_count} objekt valda",
|
||||
"cancel": "Avbryt",
|
||||
"clear_input_value": "Rensa",
|
||||
"clear_input_value": "Rensa värdet",
|
||||
"clone": "Klona",
|
||||
"confirm": "Bekräfta",
|
||||
"create": "Skapa",
|
||||
|
|
@ -454,8 +467,8 @@
|
|||
"export": "Exportera",
|
||||
"list": "Lista",
|
||||
"refresh": "Uppdatera",
|
||||
"remove_filter": "Ta bort filter",
|
||||
"remove": "Radera",
|
||||
"remove_filter": "Ta bort det här filtret",
|
||||
"remove": "Ta bort",
|
||||
"save": "Spara",
|
||||
"search": "Sök",
|
||||
"show": "Visa",
|
||||
|
|
@ -477,15 +490,15 @@
|
|||
},
|
||||
"page": {
|
||||
"create": "Skapa %{name}",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard": "Översikt",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Ett fel uppstod",
|
||||
"list": "%{name}",
|
||||
"loading": "Laddar",
|
||||
"not_found": "Hittade inget",
|
||||
"loading": "Läser in",
|
||||
"not_found": "Hittades inte",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Ingen %{name} ännu.",
|
||||
"invite": "Vill du lägga till en?"
|
||||
"empty": "%{name}: inga poster ännu.",
|
||||
"invite": "Vill du lägga till något?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
|
|
@ -497,13 +510,13 @@
|
|||
"upload_single": "Dra och släpp en bild som ska laddas upp eller klicka för att välja en bild."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Hittade ingen referensdata.",
|
||||
"many_missing": "Minst en av de associerade referenserna verkar inte längre vara tillgänglig.",
|
||||
"single_missing": "Associerade referenser verkar inte längre vara tillgängliga."
|
||||
"all_missing": "Kunde inte hitta referensdata.",
|
||||
"many_missing": "Minst en av de kopplade referenserna verkar inte längre vara tillgänglig.",
|
||||
"single_missing": "Den kopplade referensen verkar inte längre vara tillgänglig."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Dölj password",
|
||||
"toggle_hidden": "Visa password"
|
||||
"toggle_visible": "Dölj lösenord",
|
||||
"toggle_hidden": "Visa lösenord"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
|
|
@ -511,16 +524,16 @@
|
|||
"are_you_sure": "Är du säker?",
|
||||
"bulk_delete_content": "Vill du verkligen ta bort %{name}? |||| Vill du verkligen ta bort dessa %{smart_count} objekt?",
|
||||
"bulk_delete_title": "Ta bort %{name} |||| Ta bort %{smart_count} %{name}",
|
||||
"delete_content": "Vill du verkligen ta bort detta innehåll?",
|
||||
"delete_content": "Vill du verkligen ta bort det här objektet?",
|
||||
"delete_title": "Ta bort %{name} #%{id}",
|
||||
"details": "Detaljer",
|
||||
"error": "Ett klientfel uppstod och begäran kunde inte slutföras.",
|
||||
"invalid_form": "Formuläret är ogiltigt. Kontrollera eventuella fel",
|
||||
"loading": "Sidan läses in, var god vänta",
|
||||
"invalid_form": "Formuläret innehåller fel. Kontrollera uppgifterna",
|
||||
"loading": "Sidan läses in. Vänta ett ögonblick",
|
||||
"no": "Nej",
|
||||
"not_found": "Antingen skrev du fel URL eller så följde du en ogiltig länk.",
|
||||
"yes": "Ja",
|
||||
"unsaved_changes": "Du har osparade ändringar. Ignorera dem?"
|
||||
"unsaved_changes": "Vissa ändringar har inte sparats. Vill du verkligen ignorera dem?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Inga resultat hittades",
|
||||
|
|
@ -529,23 +542,23 @@
|
|||
"page_out_from_end": "Det finns inga fler sidor",
|
||||
"page_out_from_begin": "Det finns ingen sida före sida 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}",
|
||||
"page_rows_per_page": "Antal per sida:",
|
||||
"page_rows_per_page": "Objekt per sida:",
|
||||
"next": "Nästa",
|
||||
"prev": "Föregående",
|
||||
"skip_nav": "Hoppa till innehåll"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Element uppdaterat |||| %{smart_count} element uppdaterade",
|
||||
"created": "Element skapat",
|
||||
"deleted": "Element borttaget |||| %{smart_count} element borttagna",
|
||||
"bad_item": "Felaktigt element",
|
||||
"item_doesnt_exist": "Element finns inte",
|
||||
"updated": "Objekt uppdaterat |||| %{smart_count} objekt uppdaterade",
|
||||
"created": "Objekt skapat",
|
||||
"deleted": "Objekt borttaget |||| %{smart_count} objekt borttagna",
|
||||
"bad_item": "Felaktigt objekt",
|
||||
"item_doesnt_exist": "Objektet finns inte",
|
||||
"http_error": "Kommunikationsfel med servern",
|
||||
"data_provider_error": "Fel i dataProvider. Kontrollera din konsol för mer information.",
|
||||
"i18n_error": "Kunde inte läsa in översättningen av det valda språket",
|
||||
"data_provider_error": "Fel i dataProvider. Kontrollera webbläsarens konsol för mer information.",
|
||||
"i18n_error": "Kunde inte läsa in översättningarna för det valda språket",
|
||||
"canceled": "Åtgärden avbröts",
|
||||
"logged_out": "Sessionen har avslutats, anslut på nytt.",
|
||||
"new_version": "Det finns en ny version! Uppdatera detta fönster."
|
||||
"logged_out": "Sessionen har avslutats. Logga in igen.",
|
||||
"new_version": "En ny version finns tillgänglig! Ladda om det här fönstret."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Kolumner att visa",
|
||||
|
|
@ -556,39 +569,39 @@
|
|||
},
|
||||
"message": {
|
||||
"note": "OBSERVERA",
|
||||
"transcodingDisabled": "Inställning för kodning via webbgränssnittet är av säkerhetsskäl ej aktiverat. Starta om servern med alternativet %{config} markerat om du vill göra ändringar (redigera eller lägga till).",
|
||||
"transcodingEnabled": "Navidrome körs för närvarande med %{config}, vilket gör att systemkommandon kan köras från webbplattformen. Du rekommenderas av säkerhetsskäl att du stänger av den och bara slår på den när du ställer in omkodning.",
|
||||
"songsAddedToPlaylist": "La till en låt i spellistan |||| La till %{smart_count} låtar i spellistan",
|
||||
"noPlaylistsAvailable": "Ingen tillgänglig",
|
||||
"delete_user_title": "Ta bort användare '%{name}'",
|
||||
"delete_user_content": "Är du säker på att du vill ta bort denna användare (inklusive spellistor och inställningar)?",
|
||||
"notifications_blocked": "Du har blockerat meddelanden från denna sajt in din webbläsares inställningar",
|
||||
"notifications_not_available": "Denna webbläsare stödjer inte skrivbordsmeddelanden eller du använder inte Navidrome via https",
|
||||
"lastfmLinkSuccess": "Last.fm är länkat och scrobbling är aktivt",
|
||||
"lastfmLinkFailure": "Last.fm kunde inte länkas",
|
||||
"lastfmUnlinkSuccess": "Last.fm är inte längre länkat och scrobbling är deaktiverat",
|
||||
"lastfmUnlinkFailure": "Last.fm kunde inte avlänkas",
|
||||
"transcodingDisabled": "Av säkerhetsskäl går det inte att ändra omkodningsinställningarna i webbgränssnittet. Starta om servern med konfigurationsalternativet %{config} för att redigera eller lägga till omkodningsinställningar.",
|
||||
"transcodingEnabled": "Navidrome körs med %{config}. Det gör det möjligt att köra systemkommandon via omkodningsinställningarna i webbgränssnittet. Av säkerhetsskäl rekommenderar vi att alternativet är inaktiverat och bara aktiveras när omkodningen ska konfigureras.",
|
||||
"songsAddedToPlaylist": "1 låt har lagts till i spellistan |||| %{smart_count} låtar har lagts till i spellistan",
|
||||
"noPlaylistsAvailable": "Inga tillgängliga",
|
||||
"delete_user_title": "Ta bort användaren '%{name}'",
|
||||
"delete_user_content": "Är du säker på att du vill ta bort den här användaren och alla användarens data, inklusive spellistor och inställningar?",
|
||||
"notifications_blocked": "Du har blockerat aviseringar från den här webbplatsen i webbläsarens inställningar",
|
||||
"notifications_not_available": "Webbläsaren stöder inte skrivbordsaviseringar, eller så ansluter du inte till Navidrome via HTTPS",
|
||||
"lastfmLinkSuccess": "Last.fm har kopplats och scrobbling har aktiverats",
|
||||
"lastfmLinkFailure": "Det gick inte att koppla Last.fm",
|
||||
"lastfmUnlinkSuccess": "Kopplingen till Last.fm har tagits bort och scrobbling har inaktiverats",
|
||||
"lastfmUnlinkFailure": "Det gick inte att ta bort kopplingen till Last.fm",
|
||||
"openIn": {
|
||||
"lastfm": "Öppna i Last.fm",
|
||||
"musicbrainz": "Öppna i MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Läs mer...",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz är länkat och scrobbling är aktivt som användare: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz kunde inte länkas: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz är inte längre länkat och scrobbling är deaktiverat",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz kunde inte avlänkas",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz har kopplats och scrobbling har aktiverats för användaren: %{user}",
|
||||
"listenBrainzLinkFailure": "Det gick inte att koppla ListenBrainz: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "Kopplingen till ListenBrainz har tagits bort och scrobbling har inaktiverats",
|
||||
"listenBrainzUnlinkFailure": "Det gick inte att ta bort kopplingen till ListenBrainz",
|
||||
"downloadOriginalFormat": "Ladda ner i originalformat",
|
||||
"shareOriginalFormat": "Dela i originalformat",
|
||||
"shareDialogTitle": "Dela %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Dela en %{resource} |||| Dela %{smart_count} %{resource}",
|
||||
"shareSuccess": "URL kopierades till urklipp: %{url}",
|
||||
"shareFailure": "Fel vid kopiering av URL %{url} till urklipp",
|
||||
"shareBatchDialogTitle": "Dela 1 %{resource} |||| Dela %{smart_count} %{resource}",
|
||||
"shareSuccess": "URL:en har kopierats till urklipp: %{url}",
|
||||
"shareFailure": "Kunde inte kopiera URL:en %{url} till urklipp",
|
||||
"downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Ta bort saknade filer",
|
||||
"remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
|
||||
"remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.",
|
||||
"remove_all_missing_title": "Ta bort alla saknade filer",
|
||||
"remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
|
||||
"remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.",
|
||||
"noSimilarSongsFound": "Hittade inga liknande låtar",
|
||||
"noTopSongsFound": "Hittade inga topplåtar",
|
||||
"startingInstantMix": "Laddar direktmix...",
|
||||
|
|
@ -597,7 +610,11 @@
|
|||
"coverUploaded": "Omslagsbild uppdaterad",
|
||||
"coverRemoved": "Omslagsbild borttagen",
|
||||
"coverUploadError": "Fel vid uppladdning av omslagsbild",
|
||||
"coverRemoveError": "Fel vid borttagning av omslagsbild"
|
||||
"coverRemoveError": "Fel vid borttagning av omslagsbild",
|
||||
"metadataRefreshStarted": "Metadata uppdateras i bakgrunden",
|
||||
"quickConnectApproved": "%{app} på %{device} är nu inloggad",
|
||||
"quickConnectInvalidCode": "Koden är ogiltig eller har gått ut",
|
||||
"quickConnectError": "Kunde inte godkänna koden"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotek",
|
||||
|
|
@ -610,17 +627,17 @@
|
|||
"theme": "Tema",
|
||||
"language": "Språk",
|
||||
"defaultView": "Standardvy",
|
||||
"desktop_notifications": "Skrivbordsmeddelanden",
|
||||
"desktop_notifications": "Skrivbordsaviseringar",
|
||||
"lastfmScrobbling": "Scrobbla till Last.fm",
|
||||
"listenBrainzScrobbling": "Scrobbla till ListenBrainz",
|
||||
"replaygain": "ReplayGain-läge",
|
||||
"preAmp": "ReplayGain PreAmp (dB)",
|
||||
"preAmp": "Förförstärkning för ReplayGain (dB)",
|
||||
"gain": {
|
||||
"none": "Inaktiverad",
|
||||
"album": "Använd gain för album",
|
||||
"track": "Använd gain für låtar"
|
||||
"album": "Använd albumets volymjustering",
|
||||
"track": "Använd låtens volymjustering"
|
||||
},
|
||||
"lastfmNotConfigured": "Last.fm API-nyckel är inte konfigurerad"
|
||||
"lastfmNotConfigured": "API-nyckeln för Last.fm är inte konfigurerad"
|
||||
}
|
||||
},
|
||||
"albumList": "Album",
|
||||
|
|
@ -632,10 +649,19 @@
|
|||
"multipleLibraries": "%{selected} av %{total} bibliotek",
|
||||
"selectLibraries": "Välj bibliotek",
|
||||
"none": "Inga"
|
||||
}
|
||||
},
|
||||
"quickConnect": {
|
||||
"name": "Snabbanslutning",
|
||||
"code": "Kod",
|
||||
"help": "Ange koden som visas i en Jellyfin-app för att logga in på ditt konto i appen",
|
||||
"confirm": "Vill du logga in med ditt konto i %{app} %{version} på %{device}?",
|
||||
"continue": "Fortsätt",
|
||||
"approve": "Godkänn"
|
||||
},
|
||||
"onlyFavourites": "Visa endast favoriter"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Spela kön",
|
||||
"playListsText": "Uppspelningskö",
|
||||
"openText": "Öppna",
|
||||
"closeText": "Stäng",
|
||||
"notContentText": "Ingen musik",
|
||||
|
|
@ -645,26 +671,26 @@
|
|||
"previousTrackText": "Föregående låt",
|
||||
"reloadText": "Ladda om",
|
||||
"volumeText": "Volym",
|
||||
"toggleLyricText": "Låttext av/på",
|
||||
"toggleLyricText": "Visa eller dölj låttexten",
|
||||
"toggleMiniModeText": "Minimera",
|
||||
"destroyText": "Radera",
|
||||
"destroyText": "Stäng spelaren och rensa kön",
|
||||
"downloadText": "Ladda ner",
|
||||
"removeAudioListsText": "Ta bort audiolistor",
|
||||
"removeAudioListsText": "Rensa kön",
|
||||
"clickToDeleteText": "Klicka för att ta bort %{name}",
|
||||
"emptyLyricText": "Ingen låttext",
|
||||
"playModeText": {
|
||||
"order": "I ordningsföljd",
|
||||
"orderLoop": "Upprepa",
|
||||
"singleLoop": "Upprepa en",
|
||||
"shufflePlay": "Shuffle"
|
||||
"orderLoop": "Upprepa alla",
|
||||
"singleLoop": "Upprepa en låt",
|
||||
"shufflePlay": "Blanda"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Hemsida",
|
||||
"homepage": "Webbplats",
|
||||
"source": "Källkod",
|
||||
"featureRequests": "Funktionalitetförfrågan",
|
||||
"lastInsightsCollection": "Senaste Insights-kollektion",
|
||||
"featureRequests": "Förslag på nya funktioner",
|
||||
"lastInsightsCollection": "Senaste insamling av användningsdata",
|
||||
"insights": {
|
||||
"disabled": "Inaktiverad",
|
||||
"waiting": "Väntar"
|
||||
|
|
@ -672,16 +698,16 @@
|
|||
},
|
||||
"tabs": {
|
||||
"about": "Om",
|
||||
"config": "Inställningar"
|
||||
"config": "Konfiguration"
|
||||
},
|
||||
"config": {
|
||||
"configName": "Inställningsnamn",
|
||||
"configName": "Inställning",
|
||||
"environmentVariable": "Miljövariabel",
|
||||
"currentValue": "Nuvarande värde",
|
||||
"configurationFile": "Inställningsfil",
|
||||
"exportToml": "Exportera inställningar (TOML)",
|
||||
"exportSuccess": "Inställningarna kopierade till urklippet i TOML-format",
|
||||
"exportFailed": "Kopiering av inställningarna misslyckades",
|
||||
"currentValue": "Aktuellt värde",
|
||||
"configurationFile": "Konfigurationsfil",
|
||||
"exportToml": "Exportera konfiguration (TOML)",
|
||||
"exportSuccess": "Konfigurationen har kopierats till urklipp i TOML-format",
|
||||
"exportFailed": "Kunde inte kopiera konfigurationen",
|
||||
"devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)",
|
||||
"devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner",
|
||||
"downloadToml": "Ladda ner konfiguration (TOML)"
|
||||
|
|
@ -689,28 +715,28 @@
|
|||
},
|
||||
"activity": {
|
||||
"title": "Aktivitet",
|
||||
"totalScanned": "Genomsökta mappar",
|
||||
"quickScan": "Snabbscan",
|
||||
"fullScan": "Komplett scan",
|
||||
"serverUptime": "Serverdrifttid",
|
||||
"totalScanned": "Skannade mappar totalt",
|
||||
"quickScan": "Snabb",
|
||||
"fullScan": "Fullständig",
|
||||
"serverUptime": "Serverns drifttid",
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Typ",
|
||||
"status": "Fel vid scanning",
|
||||
"elapsedTime": "Spelad tid",
|
||||
"selectiveScan": "Urval"
|
||||
"scanType": "Senaste skanning",
|
||||
"status": "Skanningsfel",
|
||||
"elapsedTime": "Förfluten tid",
|
||||
"selectiveScan": "Selektiv"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome kortkommandon",
|
||||
"title": "Kortkommandon i Navidrome",
|
||||
"hotkeys": {
|
||||
"show_help": "Visa denna hjälp",
|
||||
"toggle_menu": "Växla sidomeny",
|
||||
"toggle_menu": "Visa eller dölj sidomenyn",
|
||||
"toggle_play": "Spela / pausa",
|
||||
"prev_song": "Föregående låt",
|
||||
"next_song": "Nästa låt",
|
||||
"vol_up": "Volym upp",
|
||||
"vol_down": "Volym ner",
|
||||
"toggle_love": "Lägg till låt i favoriter",
|
||||
"current_song": "Hoppa till nuvarande låt"
|
||||
"vol_up": "Höj volymen",
|
||||
"vol_down": "Sänk volymen",
|
||||
"toggle_love": "Lägg till låten i favoriter",
|
||||
"current_song": "Gå till aktuell låt"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
|
|
@ -718,4 +744,4 @@
|
|||
"empty": "Inget spelas",
|
||||
"minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,9 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor
|
|||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state, imageChanges: &imageChangeCollector{ds: ds}}
|
||||
walkCtx, stopWalk := context.WithCancelCause(ctx)
|
||||
return &phaseFolders{jobs: jobs, ctx: ctx, walkCtx: walkCtx, stopWalk: stopWalk, ds: ds, state: state,
|
||||
imageChanges: &imageChangeCollector{ds: ds}}
|
||||
}
|
||||
|
||||
type scanJob struct {
|
||||
|
|
@ -123,6 +125,8 @@ type phaseFolders struct {
|
|||
jobs []*scanJob
|
||||
ds model.DataStore
|
||||
ctx context.Context
|
||||
walkCtx context.Context // cancelled when a folder fails to persist, so the walk stops early
|
||||
stopWalk context.CancelCauseFunc
|
||||
state *scanState
|
||||
prevAlbumPIDConf string
|
||||
imageChanges *imageChangeCollector
|
||||
|
|
@ -144,15 +148,15 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
|
|||
var total int64
|
||||
var totalChanged int64
|
||||
for _, job := range p.jobs {
|
||||
if utils.IsCtxDone(p.ctx) {
|
||||
if utils.IsCtxDone(p.walkCtx) {
|
||||
break
|
||||
}
|
||||
|
||||
outputChan, err := walkDirTree(p.ctx, job, job.targetFolders...)
|
||||
outputChan, err := walkDirTree(p.walkCtx, job, job.targetFolders...)
|
||||
if err != nil {
|
||||
log.Warn(p.ctx, "Scanner: Error scanning library", "lib", job.lib.Name, err)
|
||||
}
|
||||
for folder := range pl.ReadOrDone(p.ctx, outputChan) {
|
||||
for folder := range pl.ReadOrDone(p.walkCtx, outputChan) {
|
||||
job.numFolders.Add(1)
|
||||
p.state.sendProgress(&ProgressInfo{
|
||||
LibID: job.lib.ID,
|
||||
|
|
@ -208,6 +212,9 @@ func (p *phaseFolders) stages() []ppl.Stage[*folderEntry] {
|
|||
|
||||
func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) {
|
||||
defer p.measure(entry)()
|
||||
if err := context.Cause(p.walkCtx); err != nil {
|
||||
return entry, err
|
||||
}
|
||||
|
||||
// Load children mediafiles from DB
|
||||
cursor, err := p.ds.MediaFile(p.ctx).GetCursor(model.QueryOptions{
|
||||
|
|
@ -331,128 +338,129 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
|
|||
defer p.measure(entry)()
|
||||
p.state.changesDetected.Store(true)
|
||||
|
||||
// Collect artwork queue items for changed albums/artists, enqueued in the same transaction
|
||||
var queueItems []model.ArtworkQueueItem
|
||||
|
||||
err := p.ds.WithTx(func(tx model.DataStore) error {
|
||||
// Instantiate all repositories just once per folder
|
||||
folderRepo := tx.Folder(p.ctx)
|
||||
tagRepo := tx.Tag(p.ctx)
|
||||
artistRepo := tx.Artist(p.ctx)
|
||||
libraryRepo := tx.Library(p.ctx)
|
||||
albumRepo := tx.Album(p.ctx)
|
||||
mfRepo := tx.MediaFile(p.ctx)
|
||||
|
||||
// A new folder's albums/artists are enqueued below; only pre-existing folders need the diff.
|
||||
if !entry.isNew() {
|
||||
if changed, artistImage := entry.imagesChanged(); changed {
|
||||
p.imageChanges.record(entry.job.lib, imageChangedFolder{
|
||||
id: entry.id, path: entry.path, artistImage: artistImage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Save folder to DB
|
||||
folder := entry.toFolder()
|
||||
err := folderRepo.Put(folder)
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error persisting folder to DB", "folder", entry.path, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Save all tags to DB
|
||||
err = tagRepo.Add(entry.job.lib.ID, entry.tags...)
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error persisting tags to DB", "folder", entry.path, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later
|
||||
for i := range entry.artists {
|
||||
err = artistRepo.Put(&entry.artists[i], "name",
|
||||
"mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "search_normalized", "updated_at")
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error persisting artist to DB", "folder", entry.path, "artist", entry.artists[i].Name, err)
|
||||
return err
|
||||
}
|
||||
err = libraryRepo.AddArtist(entry.job.lib.ID, entry.artists[i].ID)
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error adding artist to library", "lib", entry.job.lib.ID, "artist", entry.artists[i].Name, err)
|
||||
return err
|
||||
}
|
||||
if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists {
|
||||
queueItems = append(queueItems, scanArtworkItem(model.KindArtistArtwork, entry.artists[i].ID))
|
||||
}
|
||||
}
|
||||
|
||||
// Save all new/modified albums to DB. Their information will be incomplete, but they will be refreshed later
|
||||
for i := range entry.albums {
|
||||
err = p.persistAlbum(albumRepo, &entry.albums[i], entry.albumIDMap)
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error persisting album to DB", "folder", entry.path, "album", entry.albums[i], err)
|
||||
return err
|
||||
}
|
||||
if entry.albums[i].Name != consts.UnknownAlbum {
|
||||
queueItems = append(queueItems, scanArtworkItem(model.KindAlbumArtwork, entry.albums[i].ID))
|
||||
}
|
||||
}
|
||||
|
||||
// Save all tracks to DB
|
||||
for i := range entry.tracks {
|
||||
err = mfRepo.Put(&entry.tracks[i])
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error persisting mediafile to DB", "folder", entry.path, "track", entry.tracks[i], err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// A re-imported track returns to unresolved so new embedded art is picked up lazily.
|
||||
if len(entry.tracks) > 0 {
|
||||
trackIDs := slice.Map(entry.tracks, func(t model.MediaFile) string { return t.ID })
|
||||
if err := tx.Artwork(p.ctx).DeleteForItems(model.KindMediaFileArtwork, trackIDs); err != nil {
|
||||
log.Warn(p.ctx, "Scanner: could not invalidate media_file artwork", "folder", entry.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all missing tracks as not available
|
||||
if len(entry.missingTracks) > 0 {
|
||||
err = mfRepo.MarkMissing(true, entry.missingTracks...)
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error marking missing tracks", "folder", entry.path, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Touch all albums that have missing tracks, so they get refreshed in later phases
|
||||
groupedMissingTracks := slice.ToMap(entry.missingTracks, func(mf *model.MediaFile) (string, struct{}) {
|
||||
return mf.AlbumID, struct{}{}
|
||||
})
|
||||
albumsToUpdate := slices.Collect(maps.Keys(groupedMissingTracks))
|
||||
err = albumRepo.Touch(albumsToUpdate...)
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error touching album", "folder", entry.path, "albums", albumsToUpdate, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue artwork resolution for changed albums/artists. Never fails the scan.
|
||||
// A full scan re-imports every track, so a re-import is no evidence the art changed.
|
||||
if len(queueItems) > 0 {
|
||||
queue := tx.ArtworkQueue(p.ctx)
|
||||
enqueue := queue.Enqueue
|
||||
if p.state.fullScan {
|
||||
enqueue = queue.EnqueueIfMissing
|
||||
}
|
||||
if err := enqueue(queueItems...); err != nil {
|
||||
log.Warn(p.ctx, "Scanner: could not enqueue artwork resolution", "folder", entry.path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
ctx := log.NewContext(p.ctx, "folder", entry.path)
|
||||
err := p.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||
return p.persistFolder(ctx, tx, entry)
|
||||
}, "scanner: persist changes")
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err)
|
||||
log.Error(ctx, "Scanner: Error persisting changes to DB", err)
|
||||
p.stopWalk(err)
|
||||
return entry, err
|
||||
}
|
||||
|
||||
return entry, err
|
||||
// A new folder's albums/artists were enqueued with it; only pre-existing folders need the diff.
|
||||
if !entry.isNew() {
|
||||
if changed, artistImage := entry.imagesChanged(); changed {
|
||||
p.imageChanges.record(entry.job.lib, imageChangedFolder{
|
||||
id: entry.id, path: entry.path, artistImage: artistImage,
|
||||
})
|
||||
}
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// persistFolder writes the folder in tx. WithTxRetry may rerun it after a rollback.
|
||||
func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, entry *folderEntry) error {
|
||||
// Collect artwork queue items for changed albums/artists, enqueued in the same transaction
|
||||
var queueItems []model.ArtworkQueueItem
|
||||
// persistAlbum consumes the map, so a rerun needs the original
|
||||
albumIDMap := maps.Clone(entry.albumIDMap)
|
||||
|
||||
// Instantiate all repositories just once per folder
|
||||
folderRepo := tx.Folder(ctx)
|
||||
tagRepo := tx.Tag(ctx)
|
||||
artistRepo := tx.Artist(ctx)
|
||||
libraryRepo := tx.Library(ctx)
|
||||
albumRepo := tx.Album(ctx)
|
||||
mfRepo := tx.MediaFile(ctx)
|
||||
|
||||
// Save folder to DB
|
||||
folder := entry.toFolder()
|
||||
err := folderRepo.Put(folder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("persisting folder: %w", err)
|
||||
}
|
||||
|
||||
// Save all tags to DB
|
||||
err = tagRepo.Add(entry.job.lib.ID, entry.tags...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("persisting tags: %w", err)
|
||||
}
|
||||
|
||||
// Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later
|
||||
for i := range entry.artists {
|
||||
err = artistRepo.Put(&entry.artists[i], "name",
|
||||
"mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "search_normalized", "updated_at")
|
||||
if err != nil {
|
||||
return fmt.Errorf("persisting artist %q: %w", entry.artists[i].Name, err)
|
||||
}
|
||||
err = libraryRepo.AddArtist(entry.job.lib.ID, entry.artists[i].ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding artist %q to library: %w", entry.artists[i].Name, err)
|
||||
}
|
||||
if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists {
|
||||
queueItems = append(queueItems, scanArtworkItem(model.KindArtistArtwork, entry.artists[i].ID))
|
||||
}
|
||||
}
|
||||
|
||||
// Save all new/modified albums to DB. Their information will be incomplete, but they will be refreshed later
|
||||
for i := range entry.albums {
|
||||
err = p.persistAlbum(albumRepo, &entry.albums[i], albumIDMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.albums[i].Name != consts.UnknownAlbum {
|
||||
queueItems = append(queueItems, scanArtworkItem(model.KindAlbumArtwork, entry.albums[i].ID))
|
||||
}
|
||||
}
|
||||
|
||||
// Save all tracks to DB
|
||||
for i := range entry.tracks {
|
||||
err = mfRepo.Put(&entry.tracks[i])
|
||||
if err != nil {
|
||||
return fmt.Errorf("persisting track %q: %w", entry.tracks[i].Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A re-imported track returns to unresolved so new embedded art is picked up lazily.
|
||||
if len(entry.tracks) > 0 {
|
||||
trackIDs := slice.Map(entry.tracks, func(t model.MediaFile) string { return t.ID })
|
||||
if err := tx.Artwork(ctx).DeleteForItems(model.KindMediaFileArtwork, trackIDs); err != nil {
|
||||
log.Warn(ctx, "Scanner: could not invalidate media_file artwork", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all missing tracks as not available
|
||||
if len(entry.missingTracks) > 0 {
|
||||
err = mfRepo.MarkMissing(true, entry.missingTracks...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marking missing tracks: %w", err)
|
||||
}
|
||||
|
||||
// Touch all albums that have missing tracks, so they get refreshed in later phases
|
||||
groupedMissingTracks := slice.ToMap(entry.missingTracks, func(mf *model.MediaFile) (string, struct{}) {
|
||||
return mf.AlbumID, struct{}{}
|
||||
})
|
||||
albumsToUpdate := slices.Collect(maps.Keys(groupedMissingTracks))
|
||||
err = albumRepo.Touch(albumsToUpdate...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("touching albums %v: %w", albumsToUpdate, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue artwork resolution for changed albums/artists. Never fails the scan.
|
||||
// A full scan re-imports every track, so a re-import is no evidence the art changed.
|
||||
if len(queueItems) > 0 {
|
||||
queue := tx.ArtworkQueue(ctx)
|
||||
enqueue := queue.Enqueue
|
||||
if p.state.fullScan {
|
||||
enqueue = queue.EnqueueIfMissing
|
||||
}
|
||||
if err := enqueue(queueItems...); err != nil {
|
||||
log.Warn(ctx, "Scanner: could not enqueue artwork resolution", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistAlbum persists the given album to the database, and reassigns annotations from the previous album ID
|
||||
|
|
@ -499,34 +507,32 @@ func (p *phaseFolders) logFolder(entry *folderEntry) (*folderEntry, error) {
|
|||
}
|
||||
|
||||
func (p *phaseFolders) finalize(err error) error {
|
||||
errF := p.ds.WithTx(func(tx model.DataStore) error {
|
||||
p.stopWalk(nil)
|
||||
defer p.imageChanges.enqueue(p.ctx)
|
||||
// A failed phase may not have walked every folder, and unvisited ones must not be marked missing
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||
for _, job := range p.jobs {
|
||||
// Mark all folders that were not updated as missing
|
||||
if len(job.lastUpdates) == 0 {
|
||||
continue
|
||||
}
|
||||
folderIDs := slices.Collect(maps.Keys(job.lastUpdates))
|
||||
err := tx.Folder(p.ctx).MarkMissing(true, folderIDs...)
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error marking missing folders", "lib", job.lib.Name, err)
|
||||
return err
|
||||
if err := tx.Folder(ctx).MarkMissing(true, folderIDs...); err != nil {
|
||||
return fmt.Errorf("marking missing folders in %s: %w", job.lib.Name, err)
|
||||
}
|
||||
err = tx.MediaFile(p.ctx).MarkMissingByFolder(true, folderIDs...)
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error marking tracks in missing folders", "lib", job.lib.Name, err)
|
||||
return err
|
||||
if err := tx.MediaFile(ctx).MarkMissingByFolder(true, folderIDs...); err != nil {
|
||||
return fmt.Errorf("marking tracks in missing folders in %s: %w", job.lib.Name, err)
|
||||
}
|
||||
// Touch all albums that have missing folders, so they get refreshed in later phases
|
||||
_, err = tx.Album(p.ctx).TouchByMissingFolder()
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error touching albums with missing folders", "lib", job.lib.Name, err)
|
||||
return err
|
||||
if _, err := tx.Album(ctx).TouchByMissingFolder(); err != nil {
|
||||
return fmt.Errorf("touching albums with missing folders in %s: %w", job.lib.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, "scanner: finalize phaseFolders")
|
||||
p.imageChanges.enqueue(p.ctx)
|
||||
return errors.Join(err, errF)
|
||||
}
|
||||
|
||||
var _ phase[*folderEntry] = (*phaseFolders)(nil)
|
||||
|
|
|
|||
|
|
@ -273,66 +273,68 @@ func (p *phaseMissingTracks) findCrossLibraryMatch(missing model.MediaFile) (mod
|
|||
}
|
||||
|
||||
func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error {
|
||||
return p.ds.WithTx(func(tx model.DataStore) error {
|
||||
discardedID := target.ID
|
||||
oldAlbumID := missing.AlbumID
|
||||
newAlbumID := target.AlbumID
|
||||
oldAlbumID := missing.AlbumID
|
||||
newAlbumID := target.AlbumID
|
||||
// Use newAlbumID as key since we only care about avoiding duplicate reassignments to the same target.
|
||||
// Claimed before the transaction so a concurrent move skips it, and released if the move fails.
|
||||
reassignAlbum := oldAlbumID != newAlbumID
|
||||
if reassignAlbum {
|
||||
p.annotationMutex.Lock()
|
||||
reassignAlbum = !p.processedAlbumAnnotations[newAlbumID]
|
||||
p.processedAlbumAnnotations[newAlbumID] = true
|
||||
p.annotationMutex.Unlock()
|
||||
if !reassignAlbum {
|
||||
log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID)
|
||||
}
|
||||
}
|
||||
|
||||
err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||
// A rerun must start from the original target, not the one the rolled-back attempt changed
|
||||
moved := target
|
||||
// Preserve the original created_at from the missing file, so moved tracks
|
||||
// don't appear in "Recently Added"
|
||||
target.CreatedAt = missing.CreatedAt
|
||||
moved.CreatedAt = missing.CreatedAt
|
||||
|
||||
// Update the target media file with the missing file's ID. This effectively "moves" the track
|
||||
// to the new location while keeping its annotations and references intact.
|
||||
target.ID = missing.ID
|
||||
err := tx.MediaFile(p.ctx).Put(&target)
|
||||
if err != nil {
|
||||
moved.ID = missing.ID
|
||||
if err := tx.MediaFile(ctx).Put(&moved); err != nil {
|
||||
return fmt.Errorf("update matched track: %w", err)
|
||||
}
|
||||
|
||||
// Discard the new mediafile row (the one that was moved to)
|
||||
err = tx.MediaFile(p.ctx).Delete(discardedID)
|
||||
if err != nil {
|
||||
if err := tx.MediaFile(ctx).Delete(target.ID); err != nil {
|
||||
return fmt.Errorf("delete discarded track: %w", err)
|
||||
}
|
||||
|
||||
// Handle album annotation reassignment if AlbumID changed
|
||||
if oldAlbumID != newAlbumID {
|
||||
// Use newAlbumID as key since we only care about avoiding duplicate reassignments to the same target
|
||||
p.annotationMutex.RLock()
|
||||
alreadyProcessed := p.processedAlbumAnnotations[newAlbumID]
|
||||
p.annotationMutex.RUnlock()
|
||||
|
||||
if !alreadyProcessed {
|
||||
p.annotationMutex.Lock()
|
||||
// Double-check pattern to avoid race conditions
|
||||
if !p.processedAlbumAnnotations[newAlbumID] {
|
||||
// Reassign direct album annotations (starred, rating)
|
||||
log.Debug(p.ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID)
|
||||
if err := tx.Album(p.ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil {
|
||||
log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err)
|
||||
}
|
||||
|
||||
// Keep created_at field from previous instance of the album, so moved albums
|
||||
// don't appear in "Recently Added"
|
||||
if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil {
|
||||
if !errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(p.ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here
|
||||
p.processedAlbumAnnotations[newAlbumID] = true
|
||||
}
|
||||
p.annotationMutex.Unlock()
|
||||
} else {
|
||||
log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID)
|
||||
if reassignAlbum {
|
||||
// Reassign direct album annotations (starred, rating)
|
||||
log.Debug(ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID)
|
||||
if err := tx.Album(ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil {
|
||||
log.Warn(ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err)
|
||||
}
|
||||
}
|
||||
|
||||
p.state.changesDetected.Store(true)
|
||||
// Keep created_at field from previous instance of the album, so moved albums
|
||||
// don't appear in "Recently Added"
|
||||
if err := tx.Album(ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil {
|
||||
if !errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err)
|
||||
}
|
||||
}
|
||||
// Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}, "scanner: move matched track")
|
||||
if err != nil {
|
||||
if reassignAlbum {
|
||||
p.annotationMutex.Lock()
|
||||
delete(p.processedAlbumAnnotations, newAlbumID)
|
||||
p.annotationMutex.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
p.state.changesDetected.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *phaseMissingTracks) finalize(err error) error {
|
||||
|
|
@ -355,7 +357,12 @@ func (p *phaseMissingTracks) finalize(err error) error {
|
|||
}
|
||||
|
||||
func (p *phaseMissingTracks) purgeMissing() error {
|
||||
deletedCount, err := p.ds.MediaFile(p.ctx).DeleteAllMissing()
|
||||
var deletedCount int64
|
||||
err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||
var err error
|
||||
deletedCount, err = tx.MediaFile(ctx).DeleteAllMissing()
|
||||
return err
|
||||
}, "scanner: purge missing")
|
||||
if err != nil {
|
||||
return fmt.Errorf("error deleting missing files: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package scanner
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -146,6 +148,87 @@ var _ = Describe("phaseMissingTracks", func() {
|
|||
Expect(movedTrack.Path).To(Equal(matchedTrack.Path))
|
||||
})
|
||||
|
||||
Context("claiming the album annotation reassignment", func() {
|
||||
var probe *probeTxDS
|
||||
missingTrack := model.MediaFile{ID: "1", PID: "A", AlbumID: "old-album", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
|
||||
matchedTrack := model.MediaFile{ID: "2", PID: "A", AlbumID: "new-album", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
|
||||
BeforeEach(func() {
|
||||
probe = &probeTxDS{MockDataStore: ds.(*tests.MockDataStore)}
|
||||
probe.MockedAlbum = tests.CreateMockAlbumRepo()
|
||||
phase = createPhaseMissingTracks(ctx, state, probe)
|
||||
_ = ds.MediaFile(ctx).Put(&missingTrack)
|
||||
_ = ds.MediaFile(ctx).Put(&matchedTrack)
|
||||
})
|
||||
|
||||
It("claims the target album before the transaction, so a concurrent move skips it", func() {
|
||||
probe.during = func() {
|
||||
phase.annotationMutex.RLock()
|
||||
defer phase.annotationMutex.RUnlock()
|
||||
Expect(phase.processedAlbumAnnotations).To(HaveKeyWithValue("new-album", true))
|
||||
}
|
||||
Expect(phase.moveMatched(matchedTrack, missingTrack)).To(Succeed())
|
||||
})
|
||||
|
||||
It("releases the claim when the move fails, so a later move can reassign", func() {
|
||||
probe.err = errors.New("boom")
|
||||
Expect(phase.moveMatched(matchedTrack, missingTrack)).To(MatchError("boom"))
|
||||
Expect(phase.processedAlbumAnnotations).ToNot(HaveKey("new-album"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when the move transaction is rerun after a busy rollback", func() {
|
||||
var rerunDS *rerunTxDS
|
||||
BeforeEach(func() {
|
||||
rerunDS = &rerunTxDS{MockDataStore: ds.(*tests.MockDataStore)}
|
||||
rerunDS.snapshot = func() func() {
|
||||
saved := maps.Clone(mr.Data)
|
||||
return func() { mr.Data = saved }
|
||||
}
|
||||
phase = createPhaseMissingTracks(ctx, state, rerunDS)
|
||||
})
|
||||
|
||||
It("keeps the moved track", func() {
|
||||
missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
|
||||
matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
|
||||
_ = ds.MediaFile(ctx).Put(&missingTrack)
|
||||
_ = ds.MediaFile(ctx).Put(&matchedTrack)
|
||||
|
||||
_, err := phase.processMissingTracks(&missingTracks{
|
||||
missing: []model.MediaFile{missingTrack},
|
||||
matched: []model.MediaFile{matchedTrack},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
movedTrack, err := ds.MediaFile(ctx).Get("1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(movedTrack.Path).To(Equal(matchedTrack.Path))
|
||||
})
|
||||
|
||||
It("reassigns the album annotations in the attempt that commits", func() {
|
||||
albumRepo := tests.CreateMockAlbumRepo()
|
||||
rerunDS.MockedAlbum = albumRepo
|
||||
restoreTracks := rerunDS.snapshot
|
||||
rerunDS.snapshot = func() func() {
|
||||
restore := restoreTracks()
|
||||
return func() {
|
||||
restore()
|
||||
albumRepo.ReassignAnnotationCalls = nil
|
||||
}
|
||||
}
|
||||
missingTrack := model.MediaFile{ID: "1", PID: "A", AlbumID: "old-album", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
|
||||
matchedTrack := model.MediaFile{ID: "2", PID: "A", AlbumID: "new-album", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
|
||||
_ = ds.MediaFile(ctx).Put(&missingTrack)
|
||||
_ = ds.MediaFile(ctx).Put(&matchedTrack)
|
||||
|
||||
_, err := phase.processMissingTracks(&missingTracks{
|
||||
missing: []model.MediaFile{missingTrack},
|
||||
matched: []model.MediaFile{matchedTrack},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue("old-album", "new-album"))
|
||||
})
|
||||
})
|
||||
|
||||
It("should move the matched track when the missing track has the same tags and filename", func() {
|
||||
missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
|
||||
matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "path1.flac", Tags: model.Tags{"title": []string{"title1"}}, Size: 200}
|
||||
|
|
@ -957,3 +1040,34 @@ var _ = Describe("phaseMissingTracks", func() {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
// rerunTxDS runs every WithTxRetry block twice, as a retry after a rolled-back busy attempt would.
|
||||
// The mock is not transactional, so snapshot returns the function that plays the rollback.
|
||||
type rerunTxDS struct {
|
||||
*tests.MockDataStore
|
||||
snapshot func() (rollback func())
|
||||
}
|
||||
|
||||
func (d *rerunTxDS) WithTxRetry(ctx context.Context, block func(context.Context, model.DataStore) error, _ ...string) error {
|
||||
rollback := d.snapshot()
|
||||
_ = block(ctx, d.MockDataStore)
|
||||
rollback()
|
||||
return block(ctx, d.MockDataStore)
|
||||
}
|
||||
|
||||
// probeTxDS runs a hook inside each WithTxRetry block, and can fail the transaction after it.
|
||||
type probeTxDS struct {
|
||||
*tests.MockDataStore
|
||||
during func()
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *probeTxDS) WithTxRetry(ctx context.Context, block func(context.Context, model.DataStore) error, _ ...string) error {
|
||||
if err := block(ctx, d.MockDataStore); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.during != nil {
|
||||
d.during()
|
||||
}
|
||||
return d.err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,9 @@ func (p *phaseRefreshAlbums) refreshAlbum(album *model.Album) (*model.Album, err
|
|||
return nil, nil
|
||||
}
|
||||
start := time.Now()
|
||||
err := p.ds.Album(p.ctx).Put(album)
|
||||
err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||
return tx.Album(ctx).Put(album)
|
||||
}, "scanner: refresh album")
|
||||
log.Debug(p.ctx, "Scanner: refreshing album", "album_id", album.ID, "name", album.Name, "songCount", album.SongCount, "elapsed", time.Since(start), err)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refreshing album %s: %w", album.ID, err)
|
||||
|
|
@ -130,7 +132,12 @@ func (p *phaseRefreshAlbums) finalize(err error) error {
|
|||
}
|
||||
// Refresh album annotations
|
||||
start := time.Now()
|
||||
cnt, err := p.ds.Album(p.ctx).RefreshPlayCounts()
|
||||
var cnt int64
|
||||
err = p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||
var txErr error
|
||||
cnt, txErr = tx.Album(ctx).RefreshPlayCounts()
|
||||
return txErr
|
||||
}, "scanner: refresh album play counts")
|
||||
if err != nil {
|
||||
return fmt.Errorf("refreshing album annotations: %w", err)
|
||||
}
|
||||
|
|
@ -138,7 +145,11 @@ func (p *phaseRefreshAlbums) finalize(err error) error {
|
|||
|
||||
// Refresh artist annotations
|
||||
start = time.Now()
|
||||
cnt, err = p.ds.Artist(p.ctx).RefreshPlayCounts()
|
||||
err = p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||
var txErr error
|
||||
cnt, txErr = tx.Artist(ctx).RefreshPlayCounts()
|
||||
return txErr
|
||||
}, "scanner: refresh artist play counts")
|
||||
if err != nil {
|
||||
return fmt.Errorf("refreshing artist annotations: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,10 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error {
|
|||
// import the playlists, and returns an error if the flag can't be persisted (so
|
||||
// the scan does not complete as successful without recording the recovery).
|
||||
func (p *phasePlaylists) deferImport() error {
|
||||
if err := p.ds.Property(p.ctx).Put(consts.PlaylistsImportPendingFlagKey, "1"); err != nil {
|
||||
err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||
return tx.Property(ctx).Put(consts.PlaylistsImportPendingFlagKey, "1")
|
||||
}, "scanner: defer playlist import")
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording pending playlist import: %w", err)
|
||||
}
|
||||
log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet. "+
|
||||
|
|
|
|||
|
|
@ -217,7 +217,9 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt
|
|||
for _, lib := range state.libraries {
|
||||
if lib.LastScanStartedAt.IsZero() {
|
||||
// This is a new scan - mark it as started
|
||||
err := s.ds.Library(ctx).ScanBegin(lib.ID, state.fullScan)
|
||||
err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||
return tx.Library(ctx).ScanBegin(lib.ID, state.fullScan)
|
||||
}, "scanner: begin library scan")
|
||||
if err != nil {
|
||||
log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err)
|
||||
state.sendWarning(err.Error())
|
||||
|
|
@ -253,7 +255,7 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt
|
|||
func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error {
|
||||
return func() error {
|
||||
state.sendProgress(&ProgressInfo{ForceUpdate: true})
|
||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
||||
return s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||
if state.changesDetected.Load() {
|
||||
start := time.Now()
|
||||
|
||||
|
|
@ -264,9 +266,7 @@ func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error
|
|||
log.Debug(ctx, "Scanner: Running selective GC", "libraryIDs", libraryIDs)
|
||||
}
|
||||
|
||||
err := tx.GC(ctx, libraryIDs...)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Scanner: Error running GC", err)
|
||||
if err := tx.GC(ctx, libraryIDs...); err != nil {
|
||||
return fmt.Errorf("running GC: %w", err)
|
||||
}
|
||||
log.Debug(ctx, "Scanner: GC completed", "elapsed", time.Since(start))
|
||||
|
|
@ -286,10 +286,14 @@ func (s *scannerImpl) runEnqueueMissingArtwork(ctx context.Context, state *scanS
|
|||
return nil
|
||||
}
|
||||
start := time.Now()
|
||||
queue := s.ds.ArtworkQueue(ctx)
|
||||
var total int64
|
||||
for _, kind := range []model.Kind{model.KindAlbumArtwork, model.KindArtistArtwork} {
|
||||
n, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityScan)
|
||||
var n int64
|
||||
err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||
var err error
|
||||
n, err = tx.ArtworkQueue(ctx).EnqueueAllMissing(kind, model.ArtworkPriorityScan)
|
||||
return err
|
||||
}, "scanner: enqueue missing artwork")
|
||||
if err != nil {
|
||||
log.Error(ctx, "Scanner: Error enqueueing missing artwork", "kind", kind, err)
|
||||
return fmt.Errorf("enqueueing missing artwork: %w", err)
|
||||
|
|
@ -316,7 +320,9 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun
|
|||
log.Debug(ctx, "Scanner: Refreshed artist stats", "stats", stats, "elapsed", time.Since(start))
|
||||
|
||||
start = time.Now()
|
||||
err = s.ds.Tag(ctx).UpdateCounts()
|
||||
err = s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||
return tx.Tag(ctx).UpdateCounts()
|
||||
}, "scanner: update tag counts")
|
||||
if err != nil {
|
||||
log.Error(ctx, "Scanner: Error updating tag counts", err)
|
||||
return fmt.Errorf("updating tag counts: %w", err)
|
||||
|
|
@ -329,28 +335,21 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun
|
|||
func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error {
|
||||
return func() error {
|
||||
start := time.Now()
|
||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
||||
return s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||
for _, lib := range state.libraries {
|
||||
err := tx.Library(ctx).ScanEnd(lib.ID)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Scanner: Error updating last scan completed", "lib", lib.Name, err)
|
||||
return fmt.Errorf("updating last scan completed: %w", err)
|
||||
if err := tx.Library(ctx).ScanEnd(lib.ID); err != nil {
|
||||
return fmt.Errorf("updating last scan completed for %s: %w", lib.Name, err)
|
||||
}
|
||||
err = tx.Property(ctx).Put(consts.PIDTrackKey, conf.Server.PID.Track)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Scanner: Error updating track PID conf", err)
|
||||
if err := tx.Property(ctx).Put(consts.PIDTrackKey, conf.Server.PID.Track); err != nil {
|
||||
return fmt.Errorf("updating track PID conf: %w", err)
|
||||
}
|
||||
err = tx.Property(ctx).Put(consts.PIDAlbumKey, conf.Server.PID.Album)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Scanner: Error updating album PID conf", err)
|
||||
if err := tx.Property(ctx).Put(consts.PIDAlbumKey, conf.Server.PID.Album); err != nil {
|
||||
return fmt.Errorf("updating album PID conf: %w", err)
|
||||
}
|
||||
if state.changesDetected.Load() {
|
||||
log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name)
|
||||
if err := tx.Library(ctx).RefreshStats(lib.ID); err != nil {
|
||||
log.Error(ctx, "Scanner: Error refreshing library stats", "lib", lib.Name, err)
|
||||
return fmt.Errorf("refreshing library stats: %w", err)
|
||||
return fmt.Errorf("refreshing library stats for %s: %w", lib.Name, err)
|
||||
}
|
||||
} else {
|
||||
log.Debug(ctx, "Scanner: No changes detected, skipping library stats refresh", "lib", lib.Name)
|
||||
|
|
|
|||
|
|
@ -4,12 +4,16 @@ import (
|
|||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
|
@ -52,7 +56,10 @@ var _ = Describe("Scanner", Ordered, func() {
|
|||
|
||||
BeforeAll(func() {
|
||||
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
// The DB stays open until the suite ends, and Windows can't delete an open file
|
||||
tmpDir, err := os.MkdirTemp("", "scanner-test")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL")
|
||||
log.Warn("Using DB at " + conf.Server.DbPath)
|
||||
//conf.Server.DbPath = ":memory:"
|
||||
|
|
@ -1240,8 +1247,55 @@ var _ = Describe("Scanner", Ordered, func() {
|
|||
Expect(albumArtistStats.SongCount).To(Equal(3)) // 3 songs
|
||||
})
|
||||
})
|
||||
|
||||
Context("when the database is busy", func() {
|
||||
var busyDS *busyPersistDS
|
||||
BeforeEach(func() {
|
||||
// One album across many folders: the suite's single DB connection deadlocks phase 3 on many albums
|
||||
album := template(_t{"albumartist": "Artist", "album": "Album"})
|
||||
files := fstest.MapFS{}
|
||||
for i := range 30 {
|
||||
files[fmt.Sprintf("Artist/Part %02d/%02d - Song.mp3", i, i+1)] = album(track(i+1, fmt.Sprintf("Song %02d", i+1)))
|
||||
}
|
||||
createFS(files)
|
||||
busyDS = &busyPersistDS{MockDataStore: ds}
|
||||
s = scanner.New(ctx, busyDS, events.NoopBroker(),
|
||||
playlists.NewPlaylists(busyDS, artwork.NewUploader(busyDS)), metrics.NewNoopInstance())
|
||||
})
|
||||
|
||||
It("gives up and stops walking the library when the database stays busy", func() {
|
||||
busyDS.failures.Store(1000)
|
||||
|
||||
Expect(runScanner(ctx, true)).To(MatchError(ContainSubstring("database is locked")))
|
||||
|
||||
Expect(mfRepo.cursorCalls.Load()).To(BeNumerically("<", 30))
|
||||
})
|
||||
|
||||
It("does not mark unvisited folders missing when the scan gives up", func() {
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
busyDS.failures.Store(1000)
|
||||
|
||||
Expect(runScanner(ctx, true)).ToNot(Succeed())
|
||||
|
||||
Expect(ds.Folder(ctx).CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}})).To(BeZero())
|
||||
Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}})).To(BeZero())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// busyPersistDS fails the scanner's folder saves with SQLITE_BUSY, as if WithTxRetry ran out of retries.
|
||||
type busyPersistDS struct {
|
||||
*tests.MockDataStore
|
||||
failures atomic.Int32
|
||||
}
|
||||
|
||||
func (b *busyPersistDS) WithTxRetry(ctx context.Context, block func(context.Context, model.DataStore) error, label ...string) error {
|
||||
if len(label) > 0 && label[0] == "scanner: persist changes" && b.failures.Add(-1) >= 0 {
|
||||
return sqlite3.Error{Code: sqlite3.ErrBusy}
|
||||
}
|
||||
return b.MockDataStore.WithTxRetry(ctx, block, label...)
|
||||
}
|
||||
|
||||
func createFindByPath(ctx context.Context, ds model.DataStore) func(string) (*model.MediaFile, error) {
|
||||
return func(path string) (*model.MediaFile, error) {
|
||||
list, err := ds.MediaFile(ctx).FindByPaths([]string{path})
|
||||
|
|
@ -1258,6 +1312,12 @@ func createFindByPath(ctx context.Context, ds model.DataStore) func(string) (*mo
|
|||
type mockMediaFileRepo struct {
|
||||
model.MediaFileRepository
|
||||
GetMissingAndMatchingError error
|
||||
cursorCalls atomic.Int32
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepo) GetCursor(options ...model.QueryOptions) (model.MediaFileCursor, error) {
|
||||
m.cursorCalls.Add(1)
|
||||
return m.MediaFileRepository.GetCursor(options...)
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-
|
|||
|
||||
// Recursively walk this folder and all its children
|
||||
err = walkFolder(ctx, job, folderPath, checker, results)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Scanner: Error walking target folder", "path", folderPath, err)
|
||||
continue
|
||||
|
|
@ -87,9 +90,12 @@ func walkFolder(ctx context.Context, job *scanJob, currentFolder string, checker
|
|||
folder.path = dir
|
||||
folder.elapsed.Start()
|
||||
|
||||
results <- folder
|
||||
|
||||
return nil
|
||||
select {
|
||||
case results <- folder:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreChecker) (folder *folderEntry, children []string, err error) {
|
||||
|
|
@ -291,6 +297,7 @@ var ignoredDirs = []string{
|
|||
"$RECYCLE.BIN",
|
||||
"#snapshot",
|
||||
"@Recycle",
|
||||
"@eaDir",
|
||||
"@Recently-Snapshot",
|
||||
".git",
|
||||
".streams",
|
||||
|
|
|
|||
|
|
@ -564,6 +564,7 @@ var _ = Describe("walk_dir_tree", func() {
|
|||
Entry("dir starting with ellipsis", "...unhidden_folder", false),
|
||||
Entry("recycle bin", "$Recycle.Bin", true),
|
||||
Entry("snapshot dir", "#snapshot", true),
|
||||
Entry("synology metadata dir", "@eaDir", true),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password
|
|||
}
|
||||
err := ds.User(ctx).Put(&initialUser)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Could not create initial user", "user", initialUser, err)
|
||||
log.Error(ctx, "Could not create initial user", "user", initialUser.UserName, err)
|
||||
return fmt.Errorf("creating initial user: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
|
@ -218,14 +218,14 @@ func UsernameFromExtAuthHeader(r *http.Request) string {
|
|||
log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.")
|
||||
return ""
|
||||
}
|
||||
if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
|
||||
log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
|
||||
return ""
|
||||
}
|
||||
username := r.Header.Get(conf.Server.ExtAuth.UserHeader)
|
||||
if username == "" {
|
||||
return ""
|
||||
}
|
||||
if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
|
||||
log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
|
||||
return ""
|
||||
}
|
||||
log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username)
|
||||
return username
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,12 +16,15 @@ import (
|
|||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
)
|
||||
|
||||
var _ = Describe("Auth", func() {
|
||||
|
|
@ -73,6 +76,18 @@ var _ = Describe("Auth", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("createAdmin when the user cannot be stored", func() {
|
||||
It("responds 500 rather than falling through to login", func() {
|
||||
failing := dsWithFailingPut(errors.New("db is down"))
|
||||
req = httptest.NewRequest("POST", "/createAdmin", strings.NewReader(`{"username":"johndoe", "password":"secret"}`))
|
||||
resp = httptest.NewRecorder()
|
||||
|
||||
createAdmin(failing)(resp, req)
|
||||
|
||||
Expect(resp.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Login from HTTP headers", func() {
|
||||
const (
|
||||
trustedIpv4 = "192.168.0.42"
|
||||
|
|
@ -234,6 +249,56 @@ var _ = Describe("Auth", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("UsernameFromExtAuthHeader", func() {
|
||||
var hook *test.Hook
|
||||
var r *http.Request
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16"
|
||||
prevLevel := log.CurrentLevel()
|
||||
l, h := test.NewNullLogger()
|
||||
hook = h
|
||||
prevLogger := log.SetDefaultLogger(l)
|
||||
log.SetLevel(log.LevelWarn)
|
||||
DeferCleanup(func() {
|
||||
log.SetDefaultLogger(prevLogger)
|
||||
log.SetLevel(prevLevel)
|
||||
})
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
})
|
||||
|
||||
warnings := func() []*logrus.Entry {
|
||||
var ws []*logrus.Entry
|
||||
for _, e := range hook.AllEntries() {
|
||||
if e.Level == logrus.WarnLevel {
|
||||
ws = append(ws, e)
|
||||
}
|
||||
}
|
||||
return ws
|
||||
}
|
||||
|
||||
It("returns the username from a trusted source", func() {
|
||||
r.Header.Set("Remote-User", "janedoe")
|
||||
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.0.42"))
|
||||
Expect(UsernameFromExtAuthHeader(r)).To(Equal("janedoe"))
|
||||
Expect(warnings()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not warn when an untrusted source sends no user header", func() {
|
||||
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "8.8.8.8"))
|
||||
Expect(UsernameFromExtAuthHeader(r)).To(BeEmpty())
|
||||
Expect(warnings()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("warns when an untrusted source sends the user header", func() {
|
||||
r.Header.Set("Remote-User", "janedoe")
|
||||
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "8.8.8.8"))
|
||||
Expect(UsernameFromExtAuthHeader(r)).To(BeEmpty())
|
||||
Expect(warnings()).To(HaveLen(1))
|
||||
Expect(warnings()[0].Message).To(Equal("IP is not whitelisted for external authentication"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("tokenFromHeader", func() {
|
||||
It("returns the token when the Authorization header is set correctly", func() {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import (
|
|||
|
||||
func initialSetup(ds model.DataStore) {
|
||||
ctx := context.TODO()
|
||||
_ = ds.WithTx(func(tx model.DataStore) error {
|
||||
err := ds.WithTx(func(tx model.DataStore) error {
|
||||
if err := tx.Library(ctx).StoreMusicFolder(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -36,6 +36,9 @@ func initialSetup(ds model.DataStore) {
|
|||
err = properties.Put(consts.InitialSetupFlagKey, time.Now().String())
|
||||
return err
|
||||
}, "initial setup")
|
||||
if err != nil {
|
||||
log.Fatal("Error running initial setup", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If the Dev Admin user is not present, create it
|
||||
|
|
@ -43,7 +46,7 @@ func createInitialAdminUser(ds model.DataStore, initialPassword string) error {
|
|||
users := ds.User(context.TODO())
|
||||
c, err := users.CountAll(model.QueryOptions{Filters: squirrel.Eq{"user_name": consts.DevInitialUserName}})
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Could not access User table: %s", err))
|
||||
return fmt.Errorf("could not access User table: %w", err)
|
||||
}
|
||||
if c == 0 {
|
||||
newID := id.NewRandom()
|
||||
|
|
@ -57,12 +60,11 @@ func createInitialAdminUser(ds model.DataStore, initialPassword string) error {
|
|||
NewPassword: initialPassword,
|
||||
IsAdmin: true,
|
||||
}
|
||||
err := users.Put(&initialUser)
|
||||
if err != nil {
|
||||
log.Error("Could not create initial admin user", "user", initialUser, err)
|
||||
if err := users.Put(&initialUser); err != nil {
|
||||
return fmt.Errorf("could not create initial admin user: %w", err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkFFmpegInstallation() {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package server
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
|
@ -9,6 +10,17 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type failingPutUserRepo struct {
|
||||
model.UserRepository
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *failingPutUserRepo) Put(*model.User) error { return r.err }
|
||||
|
||||
func dsWithFailingPut(err error) model.DataStore {
|
||||
return &tests.MockDataStore{MockedUser: &failingPutUserRepo{UserRepository: tests.CreateMockUserRepo(), err: err}}
|
||||
}
|
||||
|
||||
var _ = Describe("initial_setup", func() {
|
||||
var ds model.DataStore
|
||||
|
||||
|
|
@ -32,5 +44,16 @@ var _ = Describe("initial_setup", func() {
|
|||
Expect(createInitialAdminUser(ds, "second")).To(BeNil())
|
||||
Expect(ur.CountAll()).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
It("returns the error when the user cannot be stored", func() {
|
||||
boom := errors.New("db is down")
|
||||
Expect(createInitialAdminUser(dsWithFailingPut(boom), "pass123")).To(MatchError(boom))
|
||||
})
|
||||
|
||||
It("returns the error when the user table cannot be read", func() {
|
||||
boom := errors.New("db is down")
|
||||
ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: boom}}
|
||||
Expect(createInitialAdminUser(ds, "pass123")).To(MatchError(boom))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -67,9 +67,10 @@ Discovery answers on all IPv4 interfaces, but the advertised address follows `Ba
|
|||
`Port`. If `Address` is a loopback or a single interface IP and `BaseURL` has no host, clients on other
|
||||
networks get an address they cannot reach. Set `BaseURL` to the address clients should use.
|
||||
|
||||
Docker: publish the port (`-p 7359:7359/udp`). In bridge mode the server only sees its container
|
||||
IP, so also set `ND_BASEURL` to the LAN address (for example `http://192.168.1.10:4533`), or use
|
||||
host networking. Keep UDP 7359 on the LAN: never forward it from the internet.
|
||||
Docker: use host networking (`network_mode: host` / `--network host`). On Linux, bridge mode does not
|
||||
deliver broadcasts to the container, even with `-p 7359:7359/udp`, so clients never find the server.
|
||||
With host networking the server sees the host's IP, so `BaseURL` is not needed for discovery. If host
|
||||
networking is not an option, leave discovery off. Keep UDP 7359 on the LAN: never forward it from the internet.
|
||||
|
||||
## Authentication
|
||||
|
||||
|
|
|
|||
|
|
@ -222,8 +222,14 @@ func createPlaylistAs(user model.User, name string, encodedIds ...string) string
|
|||
}
|
||||
body, err := json.Marshal(map[string]any{"Name": name, "Ids": encodedIds})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return createPlaylistBodyAs(user, string(body))
|
||||
}
|
||||
|
||||
// createPlaylistBodyAs posts a raw create body, for tests that need fields the helpers above don't
|
||||
// build, and returns the new playlist's decoded id.
|
||||
func createPlaylistBodyAs(user model.User, body string) string {
|
||||
var res map[string]string
|
||||
parseInto(postAs(user, "/Playlists", string(body)), &res)
|
||||
parseInto(postAs(user, "/Playlists", body), &res)
|
||||
Expect(res["Id"]).ToNot(BeEmpty())
|
||||
id, ok := dto.DecodeID(res["Id"])
|
||||
Expect(ok).To(BeTrue())
|
||||
|
|
|
|||
|
|
@ -21,12 +21,19 @@ var _ = Describe("Playlists", func() {
|
|||
}
|
||||
order := func(plID string) []string { return names(playlistItems(plID).Items) }
|
||||
|
||||
createWith := func(body string) string { return createPlaylistBodyAs(adminUser, body) }
|
||||
openAccess := func(plID string) bool {
|
||||
var info dto.PlaylistInfo
|
||||
parseInto(get("/Playlists/"+enc(plID)), &info)
|
||||
return info.OpenAccess
|
||||
}
|
||||
|
||||
Describe("create", func() {
|
||||
It("creates an empty playlist", func() {
|
||||
plID := createPlaylist("Empty", nil)
|
||||
var info dto.PlaylistInfo
|
||||
parseInto(get("/Playlists/"+enc(plID)), &info)
|
||||
Expect(info.OpenAccess).To(BeFalse())
|
||||
Expect(info.OpenAccess).To(BeFalse(), "a playlist created without IsPublic stays private")
|
||||
Expect(info.Shares).To(BeEmpty())
|
||||
Expect(info.ItemIds).To(BeEmpty())
|
||||
})
|
||||
|
|
@ -48,6 +55,14 @@ var _ = Describe("Playlists", func() {
|
|||
Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // Abbey Road (2) + Help! (1)
|
||||
})
|
||||
|
||||
It("creates a public playlist when the client sends IsPublic true", func() {
|
||||
Expect(openAccess(createWith(`{"Name":"Public","Ids":[],"IsPublic":true}`))).To(BeTrue())
|
||||
})
|
||||
|
||||
It("creates a private playlist when the client sends IsPublic false", func() {
|
||||
Expect(openAccess(createWith(`{"Name":"Private","Ids":[],"IsPublic":false}`))).To(BeFalse())
|
||||
})
|
||||
|
||||
// dto.DecodeIDs is all-or-nothing: a malformed entry must 404 the whole request, not get
|
||||
// dropped while the well-formed entries are still used to create a playlist.
|
||||
It("404s when one of the Ids is malformed, without creating a playlist", func() {
|
||||
|
|
@ -355,10 +370,7 @@ var _ = Describe("Playlists", func() {
|
|||
It("makes a playlist public", func() {
|
||||
plID := createPlaylist("Make Public", nil)
|
||||
Expect(post("/Playlists/"+enc(plID), `{"Name":"Make Public","IsPublic":true}`).Code).To(Equal(http.StatusNoContent))
|
||||
|
||||
var info dto.PlaylistInfo
|
||||
parseInto(get("/Playlists/"+enc(plID)), &info)
|
||||
Expect(info.OpenAccess).To(BeTrue())
|
||||
Expect(openAccess(plID)).To(BeTrue())
|
||||
// Now visible to other users.
|
||||
Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ type createPlaylistRequest struct {
|
|||
Name string `json:"Name"`
|
||||
Ids []string `json:"Ids"`
|
||||
MediaType string `json:"MediaType"`
|
||||
IsPublic *bool `json:"IsPublic"`
|
||||
}
|
||||
|
||||
// createPlaylist always creates a new playlist (playlistId "" tells core/playlists.Create not to
|
||||
|
|
@ -69,6 +70,13 @@ func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) {
|
|||
api.internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
// Create takes no visibility, so a requested one costs a second write.
|
||||
if body.IsPublic != nil {
|
||||
if err := api.playlists.Update(r.Context(), id, nil, nil, body.IsPublic, nil, nil); err != nil {
|
||||
api.playlistError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
api.ok(w, r, map[string]string{"Id": dto.EncodeID(id)})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,16 @@ type fakePlaylists struct {
|
|||
|
||||
deletePlaylistID string
|
||||
deleteErr error
|
||||
|
||||
updatePlaylistID string
|
||||
updatePublic *bool
|
||||
updateErr error
|
||||
}
|
||||
|
||||
func (f *fakePlaylists) Update(_ context.Context, playlistID string, _ *string, _ *string, public *bool, _ []string, _ []int) error {
|
||||
f.updatePlaylistID = playlistID
|
||||
f.updatePublic = public
|
||||
return f.updateErr
|
||||
}
|
||||
|
||||
func (f *fakePlaylists) Delete(_ context.Context, id string) error {
|
||||
|
|
@ -184,6 +194,31 @@ var _ = Describe("Playlists", func() {
|
|||
invoke(api.createPlaylist, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
|
||||
createReq := func(body string) *http.Request {
|
||||
return httptest.NewRequest("POST", "/Playlists", strings.NewReader(body)).
|
||||
WithContext(GinkgoT().Context())
|
||||
}
|
||||
|
||||
DescribeTable("visibility",
|
||||
func(body string, wantPublic *bool, wantUpdatedID string) {
|
||||
w := httptest.NewRecorder()
|
||||
invoke(api.createPlaylist, w, createReq(body))
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(fp.updatePublic).To(Equal(wantPublic))
|
||||
Expect(fp.updatePlaylistID).To(Equal(wantUpdatedID))
|
||||
},
|
||||
Entry("applies IsPublic true", `{"Name":"Mix","IsPublic":true}`, new(true), testID("pl-new")),
|
||||
Entry("applies an explicit IsPublic false", `{"Name":"Mix","IsPublic":false}`, new(false), testID("pl-new")),
|
||||
Entry("leaves visibility alone when IsPublic is omitted", `{"Name":"Mix"}`, nil, ""),
|
||||
)
|
||||
|
||||
It("returns 500 when the visibility update fails", func() {
|
||||
fp.updateErr = errors.New("boom")
|
||||
w := httptest.NewRecorder()
|
||||
invoke(api.createPlaylist, w, createReq(`{"Name":"Mix","IsPublic":true}`))
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("getPlaylistItems", func() {
|
||||
|
|
|
|||
|
|
@ -232,15 +232,20 @@ func trustedProxyPrefixes(list string) []string {
|
|||
return prefixes
|
||||
}
|
||||
|
||||
// ClientIPRateLimiter returns a rate limiter keyed by the client IP resolved by realIPMiddleware,
|
||||
// so spoofed forwarding headers cannot be rotated for a fresh bucket. It falls back to the peer
|
||||
// address, so that a missing middleware degrades to per-peer limiting rather than one shared bucket.
|
||||
// ClientIPRateLimiter returns a rate limiter keyed by ClientIP, so spoofed forwarding headers
|
||||
// cannot be rotated for a fresh bucket.
|
||||
func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler {
|
||||
return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) {
|
||||
return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))), nil
|
||||
return ClientIP(r), nil
|
||||
})
|
||||
}
|
||||
|
||||
// ClientIP returns the canonical client IP resolved by realIPMiddleware, for keying rate limits. The
|
||||
// peer address fallback degrades a missing middleware to per-peer limiting, not one shared bucket.
|
||||
func ClientIP(r *http.Request) string {
|
||||
return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r)))
|
||||
}
|
||||
|
||||
// reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key
|
||||
// can only be set once.
|
||||
func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.Handler {
|
||||
|
|
|
|||
49
server/nativeapi/delete_many_response_test.go
Normal file
49
server/nativeapi/delete_many_response_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("writeDeleteManyResponse", func() {
|
||||
var w *httptest.ResponseRecorder
|
||||
|
||||
write := func(ids ...string) map[string]any {
|
||||
w = httptest.NewRecorder()
|
||||
writeDeleteManyResponse(w, httptest.NewRequest("DELETE", "/missing", nil), ids)
|
||||
|
||||
var body map[string]any
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed(), "response body must be valid JSON: %s", w.Body.String())
|
||||
return body
|
||||
}
|
||||
|
||||
It("returns a single id as an object", func() {
|
||||
Expect(write("abc123")).To(HaveKeyWithValue("id", "abc123"))
|
||||
})
|
||||
|
||||
It("returns multiple ids as a list", func() {
|
||||
Expect(write("a", "b")).To(HaveKeyWithValue("ids", ConsistOf("a", "b")))
|
||||
})
|
||||
|
||||
It("stays valid JSON when the id contains a backslash", func() {
|
||||
Expect(write(`a\`)).To(HaveKeyWithValue("id", `a\`))
|
||||
})
|
||||
|
||||
It("stays valid JSON when the id contains a quote", func() {
|
||||
Expect(write(`a"b`)).To(HaveKeyWithValue("id", `a"b`))
|
||||
})
|
||||
|
||||
It("does not HTML-escape the id into entities", func() {
|
||||
Expect(write("a&b")).To(HaveKeyWithValue("id", "a&b"))
|
||||
})
|
||||
|
||||
It("responds 200 with a JSON content type", func() {
|
||||
write("abc123")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
})
|
||||
})
|
||||
|
|
@ -2,8 +2,6 @@ package nativeapi
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
|
@ -206,22 +204,18 @@ func (api *Router) addMissingFilesRoute(r chi.Router) {
|
|||
}
|
||||
|
||||
func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []string) {
|
||||
var resp []byte
|
||||
var err error
|
||||
var payload any
|
||||
if len(ids) == 1 {
|
||||
resp = []byte(`{"id":"` + html.EscapeString(ids[0]) + `"}`)
|
||||
payload = struct {
|
||||
ID string `json:"id"`
|
||||
}{ID: ids[0]}
|
||||
} else {
|
||||
resp, err = json.Marshal(&struct {
|
||||
payload = struct {
|
||||
Ids []string `json:"ids"`
|
||||
}{Ids: ids})
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error marshaling response", "ids", ids, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}{Ids: ids}
|
||||
}
|
||||
_, err = w.Write(resp) //nolint:gosec
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
if err := rest.RespondWithJSON(w, http.StatusOK, payload); err != nil {
|
||||
log.Error(r.Context(), "Error writing response", "ids", ids, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
|
||||
|
|
@ -101,8 +102,7 @@ func handleExportPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
|||
|
||||
log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", playlist.Name)
|
||||
w.Header().Set("Content-Type", "audio/x-mpegurl")
|
||||
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", playlist.Name)
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(playlist.Name+".m3u"))
|
||||
|
||||
_, err = w.Write([]byte(playlist.ToM3U8())) //nolint:gosec
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
|
@ -183,6 +184,37 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
|
|||
})
|
||||
})
|
||||
|
||||
var _ = Describe("handleExportPlaylist", func() {
|
||||
export := func(name string) *httptest.ResponseRecorder {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/playlist/{playlistId}", handleExportPlaylist(&mockPlaylistsService{
|
||||
playlist: &model.Playlist{ID: "pls-1", Name: name},
|
||||
}))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/playlist/pls-1", nil))
|
||||
return w
|
||||
}
|
||||
|
||||
It("names the download after the playlist", func() {
|
||||
w := export("Road Trip")
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="Road Trip.m3u"`))
|
||||
})
|
||||
|
||||
It("does not let the playlist name inject a second filename parameter", func() {
|
||||
w := export(`party"; filename="evil.html`)
|
||||
|
||||
Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="party_; filename=_evil.html.m3u"`))
|
||||
})
|
||||
|
||||
It("keeps non-ASCII names in filename*", func() {
|
||||
w := export("Кино")
|
||||
|
||||
Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="download.m3u"; filename*=utf-8''%D0%9A%D0%B8%D0%BD%D0%BE.m3u`))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("writePlaylistError", func() {
|
||||
DescribeTable("maps a service error to an HTTP status",
|
||||
func(err error, expected int) {
|
||||
|
|
@ -231,6 +263,7 @@ func (m *mockPlaylistTrackRepo) Read(id string) (any, error) {
|
|||
type mockPlaylistsService struct {
|
||||
playlists.Playlists
|
||||
tracksRepo rest.Repository
|
||||
playlist *model.Playlist
|
||||
removeImageFn func(ctx context.Context, id string) error
|
||||
setImageFn func(ctx context.Context, id string, reader io.Reader, ext string) error
|
||||
}
|
||||
|
|
@ -249,6 +282,13 @@ func (m *mockPlaylistsService) SetImage(ctx context.Context, id string, reader i
|
|||
return model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockPlaylistsService) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) {
|
||||
if m.playlist == nil {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return m.playlist, nil
|
||||
}
|
||||
|
||||
func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository {
|
||||
return m.tracksRepo
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ package public
|
|||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
|
|
@ -31,9 +29,8 @@ func (pub *Router) handleDownloads(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
name := str.SanitizeFilename(cmp.Or(s.Description, s.ID))
|
||||
name = strings.ReplaceAll(name, ",", "_")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name+".zip"))
|
||||
name := cmp.Or(s.Description, s.ID)
|
||||
w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(name+".zip"))
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
|
||||
err = pub.archiver.ZipShare(ctx, s, w)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
|
@ -95,6 +96,26 @@ var _ = Describe("handleDownloads", func() {
|
|||
Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="AC_DC_ Live_ 1979.zip"`))
|
||||
})
|
||||
|
||||
It("sanitizes the UTF-8 filename* as well", func() {
|
||||
shareIs(&model.Share{ID: "abc123", Description: "Sigur Rós/Live", Downloadable: true})
|
||||
|
||||
w := makeRequest("abc123")
|
||||
|
||||
Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="Sigur Ros_Live.zip"; filename*=utf-8''Sigur%20R%C3%B3s_Live.zip`))
|
||||
})
|
||||
|
||||
It("does not let the share description inject a second filename parameter", func() {
|
||||
shareIs(&model.Share{ID: "abc123", Description: `mix"; filename="evil.html`, Downloadable: true})
|
||||
|
||||
w := makeRequest("abc123")
|
||||
|
||||
disposition := w.Header().Get("Content-Disposition")
|
||||
Expect(disposition).ToNot(ContainSubstring(`filename="evil.html`))
|
||||
_, params, err := mime.ParseMediaType(disposition)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params["filename"]).To(Equal(`mix_; filename=_evil.html.zip`))
|
||||
})
|
||||
|
||||
It("returns 403 without invoking the archiver when the share is not downloadable", func() {
|
||||
shareIs(&model.Share{ID: "abc123", Description: "No Download", Downloadable: false})
|
||||
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ func (pub *Router) handleM3U(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
s = pub.mapShareToM3U(r, *s)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-Type", "audio/x-mpegurl")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(s.ToM3U8())) //nolint:gosec
|
||||
}
|
||||
|
||||
|
|
|
|||
52
server/public/handle_shares_test.go
Normal file
52
server/public/handle_shares_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package public
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("handleM3U", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var shareRepo *tests.MockShareRepo
|
||||
var pub *Router
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
shareRepo = &tests.MockShareRepo{}
|
||||
ds.MockedShare = shareRepo
|
||||
pub = &Router{ds: ds, share: core.NewShare(ds)}
|
||||
})
|
||||
|
||||
makeRequest := func(id string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", "/public/"+id+"/m3u?%3Aid="+id, nil)
|
||||
w := httptest.NewRecorder()
|
||||
pub.handleM3U(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
It("sets the M3U content type", func() {
|
||||
share := &model.Share{ID: "abc123", Tracks: model.MediaFiles{{ID: "t1", Title: "Track 1"}}}
|
||||
shareRepo.ID = share.ID
|
||||
shareRepo.Entity = share
|
||||
|
||||
w := makeRequest("abc123")
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
// Result() has the headers sent at WriteHeader time, unlike w.Header()
|
||||
Expect(w.Result().Header.Get("Content-Type")).To(Equal("audio/x-mpegurl"))
|
||||
Expect(w.Body.String()).To(HavePrefix("#EXTM3U"))
|
||||
})
|
||||
|
||||
It("returns 404 when the share does not exist", func() {
|
||||
shareRepo.ID = "other"
|
||||
shareRepo.Entity = &model.Share{ID: "other"}
|
||||
|
||||
Expect(makeRequest("missing").Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
134
server/subsonic/auth_limiter.go
Normal file
134
server/subsonic/auth_limiter.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package subsonic
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"hash/maphash"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
)
|
||||
|
||||
// authLimiter caps failed Subsonic logins per key. Checks run at most `limit` at a time and failures
|
||||
// are recorded afterwards, so a window admits up to 2*limit-1 guesses and valid requests only wait.
|
||||
type authLimiter struct {
|
||||
limit int
|
||||
window time.Duration
|
||||
seed maphash.Seed
|
||||
mu sync.Mutex
|
||||
keys map[uint64]*authAttempts // hashed, so attacker-chosen usernames cannot bloat memory
|
||||
lastSweep time.Time
|
||||
}
|
||||
|
||||
type authAttempts struct {
|
||||
failures int
|
||||
start time.Time
|
||||
slots chan struct{}
|
||||
refs int
|
||||
}
|
||||
|
||||
// authSlot is a reserved credential check. A nil slot releases nothing, which is what a disabled
|
||||
// limiter hands back.
|
||||
type authSlot struct {
|
||||
limiter *authLimiter
|
||||
entry *authAttempts
|
||||
}
|
||||
|
||||
// newAuthLimiter returns nil when limit is not positive. A nil limiter allows everything.
|
||||
func newAuthLimiter(limit int, window time.Duration) *authLimiter {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &authLimiter{
|
||||
limit: limit,
|
||||
window: cmp.Or(window, consts.DefaultAuthWindowLength),
|
||||
seed: maphash.MakeSeed(),
|
||||
keys: map[uint64]*authAttempts{},
|
||||
}
|
||||
}
|
||||
|
||||
// acquire reserves a credential check for key, waiting while other checks for the same key are in
|
||||
// flight. It only fails when the key already reached `limit` failures in the current window.
|
||||
func (l *authLimiter) acquire(ctx context.Context, key string) (*authSlot, bool) {
|
||||
if l == nil {
|
||||
return nil, true
|
||||
}
|
||||
a, ok := l.reserve(key)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
select {
|
||||
case a.slots <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
l.unref(a)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
blocked := a.failures >= l.limit
|
||||
if blocked {
|
||||
a.refs--
|
||||
}
|
||||
l.mu.Unlock()
|
||||
if blocked {
|
||||
<-a.slots
|
||||
return nil, false
|
||||
}
|
||||
return &authSlot{limiter: l, entry: a}, true
|
||||
}
|
||||
|
||||
func (l *authLimiter) reserve(key string) (*authAttempts, bool) {
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.sweep(now)
|
||||
|
||||
h := maphash.String(l.seed, key)
|
||||
a := l.keys[h]
|
||||
switch {
|
||||
case a == nil:
|
||||
a = &authAttempts{start: now, slots: make(chan struct{}, l.limit)}
|
||||
l.keys[h] = a
|
||||
case now.Sub(a.start) >= l.window:
|
||||
a.failures, a.start = 0, now
|
||||
}
|
||||
if a.failures >= l.limit {
|
||||
return nil, false
|
||||
}
|
||||
a.refs++
|
||||
return a, true
|
||||
}
|
||||
|
||||
func (l *authLimiter) unref(a *authAttempts) {
|
||||
l.mu.Lock()
|
||||
a.refs--
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *authSlot) release(failed bool) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.limiter.mu.Lock()
|
||||
if failed {
|
||||
s.entry.failures++
|
||||
}
|
||||
s.entry.refs--
|
||||
s.limiter.mu.Unlock()
|
||||
<-s.entry.slots
|
||||
}
|
||||
|
||||
// sweep drops idle expired keys once per window, so memory is bounded by recent attempts.
|
||||
func (l *authLimiter) sweep(now time.Time) {
|
||||
if now.Sub(l.lastSweep) < l.window {
|
||||
return
|
||||
}
|
||||
for h, a := range l.keys {
|
||||
if a.refs == 0 && now.Sub(a.start) >= l.window {
|
||||
delete(l.keys, h)
|
||||
}
|
||||
}
|
||||
l.lastSweep = now
|
||||
}
|
||||
143
server/subsonic/auth_limiter_test.go
Normal file
143
server/subsonic/auth_limiter_test.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("authLimiter", func() {
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
acquire := func(l *authLimiter, key string) (*authSlot, bool) {
|
||||
GinkgoHelper()
|
||||
return l.acquire(ctx, key)
|
||||
}
|
||||
|
||||
It("blocks a key after the configured number of failures", func() {
|
||||
l := newAuthLimiter(2, time.Minute)
|
||||
for range 2 {
|
||||
slot, ok := acquire(l, "k")
|
||||
Expect(ok).To(BeTrue())
|
||||
slot.release(true)
|
||||
}
|
||||
|
||||
_, ok := acquire(l, "k")
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
|
||||
It("never counts successful checks", func() {
|
||||
l := newAuthLimiter(2, time.Minute)
|
||||
for range 50 {
|
||||
slot, ok := acquire(l, "k")
|
||||
Expect(ok).To(BeTrue())
|
||||
slot.release(false)
|
||||
}
|
||||
})
|
||||
|
||||
It("keeps keys independent", func() {
|
||||
l := newAuthLimiter(1, time.Minute)
|
||||
slot, _ := acquire(l, "a")
|
||||
slot.release(true)
|
||||
_, ok := acquire(l, "a")
|
||||
Expect(ok).To(BeFalse())
|
||||
|
||||
_, ok = acquire(l, "b")
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("waits for an in-flight check instead of failing the request", func() {
|
||||
l := newAuthLimiter(1, time.Minute)
|
||||
held, ok := acquire(l, "k")
|
||||
Expect(ok).To(BeTrue())
|
||||
|
||||
waiting := make(chan bool, 1)
|
||||
go func() {
|
||||
slot, ok := l.acquire(ctx, "k")
|
||||
slot.release(false)
|
||||
waiting <- ok
|
||||
}()
|
||||
Consistently(waiting, 50*time.Millisecond).ShouldNot(Receive())
|
||||
|
||||
held.release(false)
|
||||
Eventually(waiting).Should(Receive(BeTrue()))
|
||||
})
|
||||
|
||||
It("stops waiting when the request is canceled", func() {
|
||||
l := newAuthLimiter(1, time.Minute)
|
||||
held, _ := acquire(l, "k")
|
||||
DeferCleanup(func() { held.release(false) })
|
||||
|
||||
canceled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, ok := l.acquire(canceled, "k")
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
|
||||
It("does not let concurrent guesses overshoot the limit", func() {
|
||||
l := newAuthLimiter(5, time.Minute)
|
||||
hold := make(chan struct{})
|
||||
var checks atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for range 50 {
|
||||
wg.Go(func() {
|
||||
slot, ok := l.acquire(ctx, "k")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
checks.Add(1)
|
||||
<-hold
|
||||
slot.release(true)
|
||||
})
|
||||
}
|
||||
|
||||
Eventually(checks.Load).Should(Equal(int32(5)))
|
||||
Consistently(checks.Load, 100*time.Millisecond).Should(Equal(int32(5)))
|
||||
close(hold)
|
||||
wg.Wait()
|
||||
|
||||
_, ok := acquire(l, "k")
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
|
||||
It("allows everything when the limit is disabled", func() {
|
||||
l := newAuthLimiter(0, time.Minute)
|
||||
for range 10 {
|
||||
slot, ok := acquire(l, "k")
|
||||
Expect(ok).To(BeTrue())
|
||||
slot.release(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give.
|
||||
func TestAuthLimiterWindow(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
ctx := context.Background()
|
||||
l := newAuthLimiter(1, 20*time.Second)
|
||||
for _, key := range []string{"a", "b"} {
|
||||
slot, ok := l.acquire(ctx, key)
|
||||
g.Expect(ok).To(BeTrue())
|
||||
slot.release(true)
|
||||
}
|
||||
_, ok := l.acquire(ctx, "a")
|
||||
g.Expect(ok).To(BeFalse())
|
||||
|
||||
time.Sleep(20 * time.Second)
|
||||
slot, ok := l.acquire(ctx, "a")
|
||||
g.Expect(ok).To(BeTrue())
|
||||
slot.release(false)
|
||||
g.Expect(l.keys).To(HaveLen(1), "expired keys must be dropped")
|
||||
})
|
||||
}
|
||||
|
|
@ -47,6 +47,14 @@ func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error)
|
|||
position := p.Int64Or("position", 0)
|
||||
|
||||
repo := api.ds.MediaFile(r.Context())
|
||||
ok, err := repo.Exists(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, newError(responses.ErrorDataNotFound, "Song not found")
|
||||
}
|
||||
|
||||
err = repo.AddBookmark(id, comment, position)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
48
server/subsonic/bookmarks_test.go
Normal file
48
server/subsonic/bookmarks_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Bookmarks", func() {
|
||||
var router *Router
|
||||
var ds *tests.MockDataStore
|
||||
var mfRepo *tests.MockMediaFileRepo
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
ctx = request.WithUser(context.Background(), model.User{ID: "u1", UserName: "u1"})
|
||||
mfRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "visible"}})
|
||||
})
|
||||
|
||||
Describe("CreateBookmark", func() {
|
||||
It("rejects an id the user cannot read", func() {
|
||||
r := newGetRequest("id=hidden", "position=1").WithContext(ctx)
|
||||
|
||||
_, err := router.CreateBookmark(r)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(mapToSubsonicError(err).code).To(Equal(responses.ErrorDataNotFound))
|
||||
Expect(mfRepo.BookmarksAdded).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("accepts an id the user can read", func() {
|
||||
r := newGetRequest("id=visible", "position=1").WithContext(ctx)
|
||||
|
||||
_, err := router.CreateBookmark(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfRepo.BookmarksAdded).To(ConsistOf("visible"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue