mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-10 11:27:11 +02:00
Compare commits
9 commits
t3code/per
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
497dfe2586 |
||
|
|
b70ba3a49e | ||
|
|
d5a2e1e813 | ||
|
|
b4d2c3e4af | ||
|
|
22ce85e5d5 | ||
|
|
aea60c6e99 | ||
|
|
b78c963931 |
||
|
|
52135913d4 |
||
|
|
caa2f8a0c0 |
19 changed files with 344 additions and 55 deletions
15
README.md
15
README.md
|
|
@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[](https://www.pikapods.com/pods?run=navidrome)
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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{}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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"}})
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
77
resources/hosting/elfhosted.svg
Normal file
77
resources/hosting/elfhosted.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 35 KiB |
25
resources/hosting/pikapods.svg
Normal file
25
resources/hosting/pikapods.svg
Normal 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 |
16
resources/hosting/zenith.svg
Normal file
16
resources/hosting/zenith.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue