Compare commits

...

9 commits

Author SHA1 Message Date
Deluan Quintão
497dfe2586
Merge pull request #6300 from navidrome/docs/hosting-badges-6258
docs: render managed hosting links as proportionate badges
2026-10-09 05:45:55 -07:00
deluan
b70ba3a49e docs: use equal-size local hosting badges without stretching artwork 2026-10-08 22:19:34 -04:00
deluan
d5a2e1e813 docs: match README deployment badges to native PikaPods height 2026-10-08 22:02:28 -04:00
deluan
b4d2c3e4af docs: render hosting providers as proportionate deployment badges 2026-10-08 21:59:52 -04:00
raphmim
22ce85e5d5 Minor fixes based on AI suggestions 2026-10-08 21:59:04 -04:00
raphmim
aea60c6e99 Badges for new cloud providers 2026-10-08 21:59:04 -04:00
Deluan Quintão
b78c963931
fix(scanner): respect FollowSymlinks in watcher-triggered scans (#6293)
* fix(scanner): respect FollowSymlinks in watcher-triggered scans

With FollowSymlinks disabled, creating a folder symlink inside the library
made the watcher schedule a selective scan with the link itself as the
target. walkDirTree only checked FollowSymlinks for child entries, so it
walked the link and imported its files as duplicates (or, for links that
point outside the library, files that should never be scanned).

walkDirTree now skips any target folder whose path, or any parent folder,
is a symlink when FollowSymlinks is disabled. The skipped target stays in
lastUpdates, so rows previously imported through it are marked missing,
matching what a full scan does. localFS now implements fs.ReadLinkFS so
fs.Lstat can see symlinks instead of following them.

Fixes #6292

* test(scanner): run the #6292 symlinked target tests on Windows

Remove the SkipOnWindows guard from the symlinked target folder tests, so
the go-windows CI job covers the FollowSymlinks fix for selective scans.
2026-10-08 17:34:42 -04:00
Deluan Quintão
52135913d4
fix(artwork): don't crash the server when a playlist's tracks can't be loaded (#6267)
Playlist().Tracks returns nil when its internal Get fails (for example when the
context is canceled at shutdown), and resolvePlaylist called GetAlbumIDs on it,
panicking with a nil pointer dereference. The artwork drain runs on a bare
goroutine, so the panic killed the whole server.

resolvePlaylist now returns an error when Tracks is nil, and the worker recovers
panics per item: it logs the panic with the item details and stack, and marks
the item as a failed attempt so the rest of the batch still runs.

Fixes #6266
2026-10-06 07:57:07 -07:00
Deluan Quintão
caa2f8a0c0
fix(ui): make playlist toggle switches visible in all themes (#6277)
* fix(ui): make playlist toggle switches visible in all themes

The Public and Auto-import switches in the playlist list did not set a
color, so Material-UI used the theme's secondary color. Many themes use
secondary as a surface color close to the table background, which made
checked switches nearly invisible (Catppuccin, Rosé Pine, Monokai,
Moonbase and others).

Set color="primary" on the playlist switch, like every other switch in
the app, and make primary the default MuiSwitch color in useCurrentTheme
so future switches cannot regress. Fixes #6272.

* refactor(ui): drop secondary switch overrides from themes

Dracula, Gruvbox Dark, Tokyo Night and Tokyo Night Light styled checked
MuiSwitch colorSecondary to work around the same invisible-switch problem
(Gruvbox in #5064). With primary as the default switch color and every
switch in the app using it, no switch renders with colorSecondary anymore,
so these overrides are dead code.
2026-10-06 09:50:11 -04:00
19 changed files with 344 additions and 55 deletions

View file

@ -35,11 +35,18 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
## Cloud Hosting ## Cloud Hosting
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an Several cloud hosting providers partner with us to offer [officially supported, cloud-hosted solutions](https://www.navidrome.org/docs/installation/managed). If you sign up with any of these providers, a share of the revenue funds the development of Navidrome at no additional cost for you.
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
A share of the revenue helps fund the development of Navidrome at no additional cost for you. <a href="https://www.pikapods.com/pods?run=navidrome"><img src="resources/hosting/pikapods.svg" alt="Run on PikaPods" width="200" height="40"></a>
<br>
<a href="https://zenith.hosting/host/navidrome?ref=navidrome"><img src="resources/hosting/zenith.svg" alt="Deploy with Zenith" width="200" height="40"></a>
<br>
<a href="https://store.elfhosted.com/product/navidrome/"><img src="resources/hosting/elfhosted.svg" alt="Deploy on ElfHosted" width="200" height="40"></a>
[![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome)
## Features ## Features

View file

@ -374,8 +374,11 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
} }
} }
albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false). tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false)
GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"}) if tracks == nil {
return resolution{}, fmt.Errorf("resolvePlaylist: could not load tracks for playlist %s", pl.ID)
}
albumIDs, err := tracks.GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
if err != nil { if err != nil {
return resolution{}, err return resolution{}, err
} }

View file

@ -707,6 +707,16 @@ var _ = Describe("resolveItem", func() {
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(res).To(Equal(resolution{})) Expect(res).To(Equal(resolution{}))
}) })
It("returns an error when the playlist tracks cannot be loaded", func() {
plRepo := tests.CreateMockPlaylistRepo()
plRepo.SetData(model.Playlists{{ID: "pl4", Name: "Playlist"}})
ds.MockedPlaylist = plRepo
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl4"})
Expect(err).To(HaveOccurred())
Expect(res).To(Equal(resolution{}))
})
}) })
}) })

View file

@ -4,9 +4,11 @@ import (
"bytes" "bytes"
"cmp" "cmp"
"context" "context"
"fmt"
"io" "io"
"math" "math"
"math/rand/v2" "math/rand/v2"
"runtime/debug"
"sync" "sync"
"time" "time"
@ -244,7 +246,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
trace := &ChainTrace{} trace := &ChainTrace{}
ctx = withTrace(ctx, trace) ctx = withTrace(ctx, trace)
out, got, retryIn := w.proc.acquire(ctx, item) out, got, retryIn := w.safeAcquire(ctx, item)
queue := w.proc.ds.ArtworkQueue() queue := w.proc.ds.ArtworkQueue()
switch out { switch out {
@ -286,6 +288,20 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
return out, got return out, got
} }
// safeAcquire turns a panic into a failed attempt: the drain runs on a bare goroutine, so an
// unrecovered panic would crash the server, and the still-queued row would crash it again on restart.
func (w *Worker) safeAcquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
defer func() {
if r := recover(); r != nil {
log.Error(ctx, "Artwork: Panic while processing item", "kind", item.ItemKind, "id", item.ItemID,
"imageType", item.ImageType, "attempts", item.Attempts, "panic", r, "stack", string(debug.Stack()))
traceStage(ctx, "panic", fmt.Errorf("%v", r))
out, got, retryIn = outcomeFailed, nil, 0
}
}()
return w.proc.acquire(ctx, item)
}
// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item // recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item
// that never resolved has no row to update, and creating one would settle it absent. // that never resolved has no row to update, and creating one would settle it absent.
func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) { func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) {

View file

@ -142,6 +142,18 @@ func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Pla
return v.MockPlaylistRepo.Get(ctx, id) return v.MockPlaylistRepo.Get(ctx, id)
} }
type panickingAlbumRepo struct {
*tests.MockAlbumRepo
panicID string
}
func (r *panickingAlbumRepo) Get(ctx context.Context, id string) (*model.Album, error) {
if id == r.panicID {
panic("boom")
}
return r.MockAlbumRepo.Get(ctx, id)
}
func adminUserRepo() *tests.MockedUserRepo { func adminUserRepo() *tests.MockedUserRepo {
repo := tests.CreateMockUserRepo() repo := tests.CreateMockUserRepo()
Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
@ -278,6 +290,36 @@ var _ = Describe("Worker", func() {
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
}) })
It("fails an item that panics, without stopping the rest of the batch", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"},
}}
albums := tests.CreateMockAlbumRepo()
albums.SetData(model.Albums{
{ID: "alboom", Name: "Album", FolderIDs: []string{"f1"}},
{ID: "alok", Name: "Album", FolderIDs: []string{"f1"}},
})
ds.MockedAlbum = &panickingAlbumRepo{MockAlbumRepo: albums, panicID: "alboom"}
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alboom"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alok"})).To(Succeed())
n, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(2))
it := findQueued(queueRepo, "al", "alboom")
Expect(it).ToNot(BeNil(), "a panicking item must be rescheduled, not dropped")
Expect(it.Attempts).To(Equal(1))
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
Expect(it.Trace).To(ContainSubstring("boom"))
Expect(findQueued(queueRepo, "al", "alok")).To(BeNil())
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alok", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
})
It("reschedules past the provider's requested delay when it exceeds the backoff", func() { It("reschedules past the provider's requested delay when it exceeds the backoff", func() {
conf.Server.CoverArtPriority = "external" conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})

View file

@ -76,6 +76,17 @@ func (lfs *localFS) ResolveSymlink(name string) (string, error) {
return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name))) return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name)))
} }
// ReadLink and Lstat implement fs.ReadLinkFS, so callers can detect symlinks without following them.
var _ fs.ReadLinkFS = (*localFS)(nil)
func (lfs *localFS) ReadLink(name string) (string, error) {
return fs.ReadLink(lfs.FS, name)
}
func (lfs *localFS) Lstat(name string) (fs.FileInfo, error) {
return fs.Lstat(lfs.FS, name)
}
func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) {
res, err := lfs.extractor.Parse(path...) res, err := lfs.extractor.Parse(path...)
if err != nil { if err != nil {

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:ns1="http://www.bohemiancoding.com/sketch/ns" width="200" height="40" viewBox="0 0 200 40" role="img" aria-labelledby="badge-title"><!-- Artwork source: https://www.pikapods.com/static/run-button.svg; preserved and centered without distortion. --><title id="badge-title">Run on PikaPods</title><rect width="200" height="40" rx="6" fill="#43A047" /><svg viewBox="0 0 147 32" style="enable-background:new 0 0 147 32;" xml:space="preserve" x="8.12500000" y="0" width="183.75000000" height="40" preserveAspectRatio="xMidYMid meet">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#43A047;}
.st1{fill:#FFFFFF;}
.st2{opacity:0.6;fill:#FFFFFF;}
</style>
<title>Run on PikaPods Button</title>
<path id="Background" ns1:type="MSShapeGroup" class="st0" d="M4,0h139c2.2,0,4,1.8,4,4v24c0,2.2-1.8,4-4,4H4c-2.2,0-4-1.8-4-4V4 C0,1.8,1.8,0,4,0z" />
<path class="st1" d="M44.3,23.3c-0.3,0.6-0.8,0.8-1.4,0.8c-10,0-20,0-30,0c-2.6,0-4.8-1.7-5.4-4.3c-0.7-3,1.4-6,4.4-6.5 c0.3,0,0.5-0.1,0.8-0.1c0.7,0,1.2-0.5,1.2-1.1c0-0.6-0.5-1.1-1.2-1.1c-1.4,0-2.7,0-4.1,0c-0.7,0-1.2-0.4-1.2-1.1 C7.4,9.5,7.9,9,8.6,9c1.4,0,2.8,0,4.3,0c1.8,0,3.2,1.4,3.2,3.2c0,1.8-1.4,3.3-3.2,3.3c-1.8,0-3.1,1.2-3.3,2.9 c-0.2,1.5,0.8,3.4,2.9,3.5c1.2,0.1,2.3,0,3.5,0c0.5-4.5,2.5-8,6.3-10.6c2.2-1.5,4.6-2.2,7.1-2.4c-0.1,0.5-0.3,1-0.3,1.5 c-0.4,2.9,1.8,5.6,4.7,6c0.3,0,0.5,0.1,0.8,0c0.6-0.1,1-0.6,1-1.1c0-0.6-0.5-1-1.1-1c-1.3-0.1-2.3-0.7-2.9-1.8 c-0.6-1.2-0.4-2.6,0.6-3.7C33,8,34.3,7.6,35.5,8c1.2,0.4,2.1,1.6,2.2,2.9c0,0.1,0.1,0.3,0.2,0.4c3.2,2.2,5.2,5.2,6,9 c0.1,0.5,0.2,1.1,0.3,1.6C44.3,22.4,44.3,22.9,44.3,23.3z M38.8,17.7c0-0.6-0.5-1.1-1.1-1.1c-0.6,0-1.1,0.5-1.1,1.1 c0,0.6,0.5,1.1,1.1,1.1C38.3,18.7,38.8,18.2,38.8,17.7z" />
<g>
<path class="st1" d="M49.7,20.2v-8.6h3.7c0.9,0,1.6,0.1,2,0.2s0.7,0.4,1,0.8s0.4,0.8,0.4,1.4c0,0.6-0.2,1.2-0.6,1.6 s-0.9,0.7-1.7,0.8c0.4,0.2,0.7,0.5,0.9,0.7s0.6,0.7,1,1.4l1,1.7h-2.1L54,18.4c-0.4-0.7-0.8-1.1-0.9-1.3s-0.3-0.3-0.5-0.4 s-0.5-0.1-0.9-0.1h-0.4v3.6H49.7z M51.4,15.3h1.3c0.8,0,1.4,0,1.6-0.1s0.4-0.2,0.5-0.4s0.2-0.4,0.2-0.6c0-0.3-0.1-0.5-0.2-0.7 s-0.4-0.3-0.7-0.3c-0.1,0-0.6,0-1.3,0h-1.4V15.3z" />
<path class="st1" d="M62.4,20.2v-0.9c-0.2,0.3-0.5,0.6-0.9,0.8s-0.8,0.3-1.2,0.3c-0.4,0-0.8-0.1-1.1-0.3s-0.6-0.4-0.7-0.8 s-0.2-0.8-0.2-1.4V14h1.6v2.9c0,0.9,0,1.4,0.1,1.6s0.2,0.4,0.3,0.5s0.4,0.2,0.6,0.2c0.3,0,0.5-0.1,0.8-0.2s0.4-0.3,0.5-0.6 c0.1-0.2,0.1-0.8,0.1-1.7V14h1.6v6.2H62.4z" />
<path class="st1" d="M71.3,20.2h-1.6V17c0-0.7,0-1.1-0.1-1.3s-0.2-0.4-0.3-0.5s-0.3-0.2-0.6-0.2c-0.3,0-0.5,0.1-0.8,0.2 s-0.4,0.4-0.5,0.6s-0.1,0.7-0.1,1.4v2.8h-1.6V14h1.5v0.9c0.5-0.7,1.2-1.1,2.1-1.1c0.4,0,0.7,0.1,1,0.2s0.5,0.3,0.7,0.5 s0.3,0.4,0.3,0.7s0.1,0.6,0.1,1.1V20.2z" />
<path class="st2" d="M75.8,17.1c0-1.2,0.3-2,1-2.6c0.5-0.5,1.2-0.7,2-0.7c0.9,0,1.6,0.3,2.1,0.8s0.8,1.3,0.8,2.3 c0,0.8-0.1,1.4-0.4,1.9s-0.6,0.8-1,1.1s-1,0.4-1.5,0.4c-0.9,0-1.6-0.3-2.1-0.8S75.8,18.2,75.8,17.1z M76.9,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.8,0.6,1.3,0.6c0.5,0,1-0.2,1.3-0.6s0.5-1,0.5-1.8c0-0.8-0.2-1.4-0.5-1.7s-0.8-0.6-1.3-0.6 c-0.5,0-1,0.2-1.3,0.6S76.9,16.3,76.9,17.1z" />
<path class="st2" d="M82.9,20.2V14h0.9v0.9c0.5-0.7,1.1-1,2-1c0.4,0,0.7,0.1,1,0.2s0.5,0.3,0.7,0.5s0.3,0.5,0.3,0.8 c0,0.2,0.1,0.5,0.1,1v3.8h-1.1v-3.8c0-0.4,0-0.8-0.1-1s-0.2-0.4-0.4-0.5s-0.5-0.2-0.7-0.2c-0.4,0-0.8,0.1-1.2,0.4S84,16,84,16.8 v3.4H82.9z" />
<path class="st2" d="M93,20.2v-8.6h3.2c0.6,0,1,0,1.3,0.1c0.4,0.1,0.8,0.2,1.1,0.4s0.5,0.5,0.7,0.8s0.3,0.7,0.3,1.2 c0,0.7-0.2,1.3-0.7,1.9s-1.3,0.8-2.5,0.8h-2.2v3.5H93z M94.2,15.7h2.2c0.7,0,1.3-0.1,1.6-0.4s0.5-0.7,0.5-1.2 c0-0.4-0.1-0.7-0.3-0.9s-0.4-0.4-0.7-0.5c-0.2-0.1-0.5-0.1-1.1-0.1h-2.2V15.7z" />
<path class="st2" d="M100.9,12.8v-1.2h1.1v1.2H100.9z M100.9,20.2V14h1.1v6.2H100.9z" />
<path class="st2" d="M103.6,20.2v-8.6h1.1v4.9l2.5-2.5h1.4l-2.4,2.3l2.6,3.9h-1.3l-2.1-3.2l-0.7,0.7v2.5H103.6z" />
<path class="st2" d="M113.6,19.5c-0.4,0.3-0.8,0.6-1.1,0.7s-0.7,0.2-1.2,0.2c-0.7,0-1.2-0.2-1.6-0.5s-0.6-0.8-0.6-1.3 c0-0.3,0.1-0.6,0.2-0.8s0.3-0.5,0.5-0.6s0.5-0.3,0.8-0.3c0.2-0.1,0.5-0.1,0.9-0.2c0.9-0.1,1.5-0.2,1.9-0.4c0-0.1,0-0.2,0-0.3 c0-0.4-0.1-0.7-0.3-0.9c-0.3-0.2-0.7-0.4-1.2-0.4c-0.5,0-0.9,0.1-1.1,0.3s-0.4,0.5-0.5,0.9l-1-0.1c0.1-0.4,0.2-0.8,0.5-1.1 s0.5-0.5,0.9-0.6s0.9-0.2,1.4-0.2c0.5,0,1,0.1,1.3,0.2s0.6,0.3,0.7,0.5s0.3,0.4,0.3,0.7c0,0.2,0.1,0.5,0.1,1v1.4c0,1,0,1.6,0.1,1.9 s0.1,0.5,0.3,0.7h-1.1C113.7,20,113.7,19.8,113.6,19.5z M113.6,17.1c-0.4,0.2-1,0.3-1.7,0.4c-0.4,0.1-0.7,0.1-0.9,0.2 s-0.3,0.2-0.4,0.3s-0.1,0.3-0.1,0.5c0,0.3,0.1,0.5,0.3,0.7s0.5,0.3,0.9,0.3c0.4,0,0.8-0.1,1.1-0.3s0.5-0.4,0.7-0.7 c0.1-0.2,0.2-0.6,0.2-1.1V17.1z" />
<path class="st2" d="M116.4,20.2v-8.6h3.2c0.6,0,1,0,1.3,0.1c0.4,0.1,0.8,0.2,1.1,0.4s0.5,0.5,0.7,0.8s0.3,0.7,0.3,1.2 c0,0.7-0.2,1.3-0.7,1.9s-1.3,0.8-2.5,0.8h-2.2v3.5H116.4z M117.5,15.7h2.2c0.7,0,1.3-0.1,1.6-0.4s0.5-0.7,0.5-1.2 c0-0.4-0.1-0.7-0.3-0.9s-0.4-0.4-0.7-0.5c-0.2-0.1-0.5-0.1-1.1-0.1h-2.2V15.7z" />
<path class="st2" d="M123.9,17.1c0-1.2,0.3-2,1-2.6c0.5-0.5,1.2-0.7,2-0.7c0.9,0,1.6,0.3,2.1,0.8s0.8,1.3,0.8,2.3 c0,0.8-0.1,1.4-0.4,1.9s-0.6,0.8-1,1.1s-1,0.4-1.5,0.4c-0.9,0-1.6-0.3-2.1-0.8S123.9,18.2,123.9,17.1z M124.9,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.8,0.6,1.3,0.6c0.5,0,1-0.2,1.3-0.6s0.5-1,0.5-1.8c0-0.8-0.2-1.4-0.5-1.7s-0.8-0.6-1.3-0.6 c-0.5,0-1,0.2-1.3,0.6S124.9,16.3,124.9,17.1z" />
<path class="st2" d="M135,20.2v-0.8c-0.4,0.6-1,0.9-1.7,0.9c-0.5,0-1-0.1-1.4-0.4s-0.7-0.7-1-1.1s-0.3-1.1-0.3-1.7 c0-0.6,0.1-1.2,0.3-1.7s0.5-0.9,0.9-1.2s0.9-0.4,1.4-0.4c0.4,0,0.7,0.1,1,0.2s0.5,0.4,0.7,0.6v-3.1h1v8.6H135z M131.6,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.7,0.6,1.2,0.6c0.5,0,0.9-0.2,1.2-0.6s0.5-1,0.5-1.7c0-0.9-0.2-1.5-0.5-1.9s-0.7-0.6-1.2-0.6 c-0.5,0-0.9,0.2-1.2,0.6S131.6,16.3,131.6,17.1z" />
<path class="st2" d="M137.2,18.4l1-0.2c0.1,0.4,0.2,0.7,0.5,1s0.6,0.3,1.1,0.3c0.5,0,0.8-0.1,1.1-0.3s0.4-0.4,0.4-0.7 c0-0.2-0.1-0.4-0.3-0.6c-0.1-0.1-0.5-0.2-1.1-0.4c-0.8-0.2-1.3-0.4-1.6-0.5s-0.5-0.3-0.7-0.6s-0.2-0.5-0.2-0.8 c0-0.3,0.1-0.5,0.2-0.8s0.3-0.4,0.5-0.6c0.2-0.1,0.4-0.2,0.7-0.3s0.6-0.1,0.9-0.1c0.5,0,0.9,0.1,1.3,0.2s0.6,0.3,0.8,0.6 s0.3,0.6,0.4,1l-1,0.1c0-0.3-0.2-0.6-0.4-0.8s-0.5-0.3-1-0.3c-0.5,0-0.8,0.1-1,0.2s-0.3,0.3-0.3,0.6c0,0.1,0,0.3,0.1,0.4 c0.1,0.1,0.2,0.2,0.4,0.3c0.1,0,0.4,0.1,0.9,0.3c0.7,0.2,1.3,0.4,1.6,0.5s0.5,0.3,0.7,0.6s0.3,0.5,0.3,0.9c0,0.4-0.1,0.7-0.3,1 s-0.5,0.6-0.9,0.7s-0.8,0.3-1.3,0.3c-0.8,0-1.4-0.2-1.8-0.5S137.3,19,137.2,18.4z" />
</g>
</svg></svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -43,6 +43,13 @@ func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-
continue continue
} }
// A full walk never descends into symlinked folders when following is disabled, so a
// target reached through one (e.g. a watcher event for a new link) is skipped too.
if !conf.Server.Scanner.FollowSymlinks && isSymlinkedPath(job.fs, folderPath) {
log.Debug(ctx, "Scanner: Skipping symlinked target folder, following is disabled", "path", folderPath)
continue
}
// Create checker and push patterns from root to this folder // Create checker and push patterns from root to this folder
checker := newIgnoreChecker(job.fs) checker := newIgnoreChecker(job.fs)
err = checker.PushAllParents(ctx, folderPath) err = checker.PushAllParents(ctx, folderPath)
@ -225,6 +232,18 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool,
return fileInfo.IsDir(), nil return fileInfo.IsDir(), nil
} }
// isSymlinkedPath returns true if folderPath, or any of its parent folders, is a symbolic link.
// It needs fsys to implement fs.ReadLinkFS, otherwise links are followed and never detected.
func isSymlinkedPath(fsys fs.FS, folderPath string) bool {
for p := path.Clean(folderPath); p != "." && p != "/"; p = path.Dir(p) {
info, err := fs.Lstat(fsys, p)
if err == nil && info.Mode()&fs.ModeSymlink != 0 {
return true
}
}
return false
}
const maxSymlinkHops = 40 const maxSymlinkHops = 40
// resolveEntryName returns the name to classify the entry by, and whether to // resolveEntryName returns the name to classify the entry by, and whether to

View file

@ -4,8 +4,10 @@ import (
"context" "context"
"fmt" "fmt"
"io/fs" "io/fs"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"testing/fstest" "testing/fstest"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
@ -260,6 +262,44 @@ var _ = Describe("walk_dir_tree", func() {
// Folders not in targets should remain in lastUpdates // Folders not in targets should remain in lastUpdates
Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3"))) Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3")))
}) })
// #6292: a watcher event for a new folder symlink makes the link itself a scan target
Context("symlinked target folders (production local storage FS)", func() {
BeforeEach(func() {
libRoot := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(libRoot, "Mozart", "Album1"), 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libRoot, "Mozart", "Album1", "track.mp3"), []byte("AUDIO"), 0600)).To(Succeed())
Expect(os.Symlink("Mozart", filepath.Join(libRoot, "Wolfgang Amadeus Mozart"))).To(Succeed())
job = &scanJob{fs: newLocalMusicFS(libRoot), lib: model.Library{Path: libRoot}}
})
walkTargets := func(targets ...string) map[string]*folderEntry {
results, err := walkDirTree(ctx, job, targets...)
Expect(err).ToNot(HaveOccurred())
folders := map[string]*folderEntry{}
for folder := range results {
folders[folder.path] = folder
}
return folders
}
DescribeTable("with FollowSymlinks disabled",
func(target string, expected ...string) {
conf.Server.Scanner.FollowSymlinks = false
Expect(slices.Collect(maps.Keys(walkTargets(target)))).To(ConsistOf(expected))
},
Entry("skips a target that is a symlink", "Wolfgang Amadeus Mozart"),
Entry("skips a target under a symlinked folder", "Wolfgang Amadeus Mozart/Album1"),
Entry("walks a regular target", "Mozart", "Mozart", "Mozart/Album1"),
)
It("walks a symlinked target when FollowSymlinks is enabled", func() {
conf.Server.Scanner.FollowSymlinks = true
folders := walkTargets("Wolfgang Amadeus Mozart")
Expect(folders).To(HaveKey("Wolfgang Amadeus Mozart/Album1"))
Expect(folders["Wolfgang Amadeus Mozart/Album1"].audioFiles).To(HaveKey("track.mp3"))
})
})
}) })
}) })
@ -433,8 +473,8 @@ var _ = Describe("walk_dir_tree", func() {
}) })
// Regression for #5752: the production localFS must resolve file symlinks. // Regression for #5752: the production localFS must resolve file symlinks.
// It wraps os.DirFS behind the fs.FS interface, so fs.ReadLink-based // fs.ReadLink-based resolution can't follow targets outside the library
// resolution is not available and full OS-level resolution is required. // root, so full OS-level resolution is required.
Context("production local storage FS", func() { Context("production local storage FS", func() {
var libRoot string var libRoot string
var musicFS storage.MusicFS var musicFS storage.MusicFS
@ -460,12 +500,7 @@ var _ = Describe("walk_dir_tree", func() {
Expect(os.Symlink(filepath.Join(pool, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed()) Expect(os.Symlink(filepath.Join(pool, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed())
Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed()) Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed())
u, err := storage.LocalPathToURL(libRoot) musicFS = newLocalMusicFS(libRoot)
Expect(err).ToNot(HaveOccurred())
s, err := storage.For(u.String())
Expect(err).ToNot(HaveOccurred())
musicFS, err = s.FS()
Expect(err).ToNot(HaveOccurred())
}) })
walkRoot := func() *folderEntry { walkRoot := func() *folderEntry {
@ -700,6 +735,17 @@ func getDirEntry(baseDir, name string) os.DirEntry {
panic(fmt.Sprintf("Could not find %s in %s", name, baseDir)) panic(fmt.Sprintf("Could not find %s in %s", name, baseDir))
} }
// newLocalMusicFS returns the production local storage MusicFS rooted at libRoot
func newLocalMusicFS(libRoot string) storage.MusicFS {
u, err := storage.LocalPathToURL(libRoot)
Expect(err).ToNot(HaveOccurred())
s, err := storage.For(u.String())
Expect(err).ToNot(HaveOccurred())
musicFS, err := s.FS()
Expect(err).ToNot(HaveOccurred())
return musicFS
}
// mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks // mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks
type mockMusicFS struct { type mockMusicFS struct {
storage.MusicFS storage.MusicFS

View file

@ -95,6 +95,7 @@ export const ToggleField = ({ resource, source }) => {
return ( return (
<Switch <Switch
checked={record[source]} checked={record[source]}
color="primary"
onClick={handleClick} onClick={handleClick}
disabled={!isWritable(record.ownerId)} disabled={!isWritable(record.ownerId)}
/> />

View file

@ -2,6 +2,7 @@ import React from 'react'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { TestContext } from 'ra-test' import { TestContext } from 'ra-test'
import { RecordContextProvider } from 'react-admin'
import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList' import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList'
vi.mock('../config', () => ({ vi.mock('../config', () => ({
@ -14,6 +15,7 @@ vi.mock('../common', () => ({
{record?.starred ? 'starred' : 'not-starred'} {record?.starred ? 'starred' : 'not-starred'}
</button> </button>
), ),
isWritable: (ownerId) => ownerId === 'me',
})) }))
describe('<PlaylistLove />', () => { describe('<PlaylistLove />', () => {
@ -55,3 +57,28 @@ describe('playlist toggles without a record', () => {
expect(container.innerHTML).toBe('') expect(container.innerHTML).toBe('')
}) })
}) })
// Secondary is a surface color in many themes, so these toggles must use primary
describe('<ToggleField />', () => {
const renderToggle = (record) =>
render(
<TestContext>
<RecordContextProvider value={record}>
<ToggleField resource="playlist" source="public" />
</RecordContextProvider>
</TestContext>,
)
it.each([
['owner', 'me', false],
['non-owner', 'someone-else', true],
])('renders a primary-colored switch for the %s', (_, ownerId, disabled) => {
renderToggle({ id: 'pl-1', public: true, ownerId })
const input = screen.getByRole('checkbox')
const switchBase = input.closest('.MuiSwitch-switchBase')
expect(input.checked).toBe(true)
expect(input.disabled).toBe(disabled)
expect(switchBase.classList).toContain('MuiSwitch-colorPrimary')
expect(switchBase.classList).not.toContain('MuiSwitch-colorSecondary')
})
})

View file

@ -185,16 +185,6 @@ export default {
color: `${foreground} !important`, color: `${foreground} !important`,
}, },
}, },
MuiSwitch: {
colorSecondary: {
'&$checked': {
color: green,
},
'&$checked + $track': {
backgroundColor: green,
},
},
},
NDAlbumGridView: { NDAlbumGridView: {
albumName: { albumName: {
marginTop: '0.5rem', marginTop: '0.5rem',

View file

@ -121,16 +121,6 @@ export default {
boxShadow: '3px 3px 5px #3c3836', boxShadow: '3px 3px 5px #3c3836',
}, },
}, },
MuiSwitch: {
colorSecondary: {
'&$checked': {
color: '#458588',
},
'&$checked + $track': {
backgroundColor: '#458588',
},
},
},
NDMobileArtistDetails: { NDMobileArtistDetails: {
bgContainer: { bgContainer: {
background: background:

View file

@ -184,16 +184,6 @@ export default {
color: `${foreground} !important`, color: `${foreground} !important`,
}, },
}, },
MuiSwitch: {
colorSecondary: {
'&$checked': {
color: blue,
},
'&$checked + $track': {
backgroundColor: blue,
},
},
},
NDAlbumGridView: { NDAlbumGridView: {
albumName: { albumName: {
marginTop: '0.5rem', marginTop: '0.5rem',

View file

@ -184,16 +184,6 @@ export default {
color: `${foreground} !important`, color: `${foreground} !important`,
}, },
}, },
MuiSwitch: {
colorSecondary: {
'&$checked': {
color: blue,
},
'&$checked + $track': {
backgroundColor: blue,
},
},
},
NDAlbumGridView: { NDAlbumGridView: {
albumName: { albumName: {
marginTop: '0.5rem', marginTop: '0.5rem',

View file

@ -63,6 +63,8 @@ const useCurrentTheme = () => {
...theme.props, ...theme.props,
MuiUseMediaQuery: { noSsr: true }, MuiUseMediaQuery: { noSsr: true },
MuiPopover: { disableScrollLock: true }, MuiPopover: { disableScrollLock: true },
// MUI defaults to secondary, which many themes use as a surface color
MuiSwitch: { color: 'primary' },
}, },
}), }),
[theme], [theme],

View file

@ -3,6 +3,10 @@ import { Provider } from 'react-redux'
import { createStore } from 'redux' import { createStore } from 'redux'
import mediaQuery from 'css-mediaquery' import mediaQuery from 'css-mediaquery'
import { renderHook } from '@testing-library/react-hooks' import { renderHook } from '@testing-library/react-hooks'
import { render, screen } from '@testing-library/react'
import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import themes from './index'
import useCurrentTheme from './useCurrentTheme' import useCurrentTheme from './useCurrentTheme'
import { themeReducer } from '../reducers/themeReducer' import { themeReducer } from '../reducers/themeReducer'
import { AUTO_THEME_ID } from '../consts' import { AUTO_THEME_ID } from '../consts'
@ -161,4 +165,27 @@ describe('useCurrentTheme', () => {
expect(document.body.style.backgroundColor).toBe('rgb(18, 18, 18)') expect(document.body.style.backgroundColor).toBe('rgb(18, 18, 18)')
}) })
}) })
describe('switch color', () => {
it.each(Object.keys(themes))(
'renders switches with the primary color in %s',
(theme) => {
const { result } = renderHook(() => useCurrentTheme(), {
wrapper: ({ children }) => (
<Provider store={createStore(themeReducer, { theme })}>
{children}
</Provider>
),
})
render(
<ThemeProvider theme={createMuiTheme(result.current)}>
<Switch checked onChange={() => {}} />
</ThemeProvider>,
)
const switchBase = screen
.getByRole('checkbox')
.closest('.MuiSwitch-switchBase')
expect(switchBase.classList).toContain('MuiSwitch-colorPrimary')
},
)
})
}) })