diff --git a/README.md b/README.md
index 4bc85e6a6..e1a2955a1 100644
--- a/README.md
+++ b/README.md
@@ -35,11 +35,18 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
## Cloud Hosting
-[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
-[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.
+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.
+
+
+
+
+
+
+
+
+
+
-[](https://www.pikapods.com/pods?run=navidrome)
## Features
diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go
index 40baa2495..fb07332fe 100644
--- a/core/artwork/resolve.go
+++ b/core/artwork/resolve.go
@@ -374,8 +374,11 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
}
}
- albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false).
- GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
+ tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false)
+ 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 {
return resolution{}, err
}
diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go
index da144d8e2..2a36531bb 100644
--- a/core/artwork/resolve_test.go
+++ b/core/artwork/resolve_test.go
@@ -707,6 +707,16 @@ var _ = Describe("resolveItem", func() {
Expect(err).To(HaveOccurred())
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{}))
+ })
})
})
diff --git a/core/artwork/worker.go b/core/artwork/worker.go
index 28e51958c..4be99f92e 100644
--- a/core/artwork/worker.go
+++ b/core/artwork/worker.go
@@ -4,9 +4,11 @@ import (
"bytes"
"cmp"
"context"
+ "fmt"
"io"
"math"
"math/rand/v2"
+ "runtime/debug"
"sync"
"time"
@@ -244,7 +246,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
trace := &ChainTrace{}
ctx = withTrace(ctx, trace)
- out, got, retryIn := w.proc.acquire(ctx, item)
+ out, got, retryIn := w.safeAcquire(ctx, item)
queue := w.proc.ds.ArtworkQueue()
switch out {
@@ -286,6 +288,20 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
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
// 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) {
diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go
index a6c07b763..80ca68bc3 100644
--- a/core/artwork/worker_test.go
+++ b/core/artwork/worker_test.go
@@ -142,6 +142,18 @@ func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Pla
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 {
repo := tests.CreateMockUserRepo()
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")
})
+ 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() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
diff --git a/core/storage/local/local.go b/core/storage/local/local.go
index 686838565..e2ce00a1b 100644
--- a/core/storage/local/local.go
+++ b/core/storage/local/local.go
@@ -76,6 +76,17 @@ func (lfs *localFS) ResolveSymlink(name string) (string, error) {
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) {
res, err := lfs.extractor.Parse(path...)
if err != nil {
diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go
index ee2794454..84fdffa4d 100644
--- a/persistence/persistence_suite_test.go
+++ b/persistence/persistence_suite_test.go
@@ -352,6 +352,15 @@ func GetDBXBuilder() *dbx.DB {
return dbx.NewFromDB(db.Db(), db.Dialect)
}
+// storedRows reads playlist_tracks without joining media_file, so it also shows rows pointing to no track.
+func storedRows(playlistID string) []string {
+ GinkgoHelper()
+ var rows []string
+ Expect(GetDBXBuilder().NewQuery("SELECT id || ':' || media_file_id FROM playlist_tracks WHERE playlist_id = {:id} ORDER BY id").
+ Bind(dbx.Params{"id": playlistID}).Column(&rows)).To(Succeed())
+ return rows
+}
+
// collectCursor takes the cursor's underlying func type so the named cursor types
// (model.AlbumCursor, ...) infer T.
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {
diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go
index 41b75266c..1a1b05a5c 100644
--- a/persistence/playlist_repository.go
+++ b/persistence/playlist_repository.go
@@ -281,7 +281,7 @@ func (r *playlistRepository) updatePlaylist(ctx context.Context, playlistId stri
}
// 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.
+// the library and existence check: every caller, including a full replace through Put, goes through it.
func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, startingPos int, mediaFileIds []string) (int, error) {
mediaFileIds, err := r.keepAccessible(ctx, mediaFileIds)
if err != nil {
@@ -305,12 +305,9 @@ func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, s
return len(mediaFileIds), r.refreshCounters(ctx, &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.
+// keepAccessible drops ids the caller cannot read or that match no track, preserving order and duplicates.
+// Chunked because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER.
func (r *playlistRepository) keepAccessible(ctx context.Context, mediaFileIds []string) ([]string, error) {
- if visible, err := r.visibleLibraryIDs(ctx); err == nil && r.userSeesAllLibraries(ctx, visible) {
- return mediaFileIds, nil
- }
accessible := make(map[string]struct{}, len(mediaFileIds))
for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) {
sq := r.applyLibraryFilter(ctx, Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file")
diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go
index 93d37c928..b7be0588b 100644
--- a/persistence/playlist_repository_test.go
+++ b/persistence/playlist_repository_test.go
@@ -469,6 +469,26 @@ var _ = Describe("PlaylistRepository", func() {
})
})
+ Describe("removeOrphans", func() {
+ It("deletes rows pointing to no track and renumbers the rest", func() {
+ pls := model.Playlist{Name: "Orphans", OwnerID: "userid"}
+ Expect(repo.Put(ctx, &pls)).To(Succeed())
+ DeferCleanup(func() { Expect(repo.Delete(ctx, pls.ID)).To(Succeed()) })
+ // Rows as an older version stored them, with an unknown id between real tracks
+ _, err := GetDBXBuilder().NewQuery(`INSERT INTO playlist_tracks (playlist_id, media_file_id, id)
+ VALUES ({:id}, '1001', 1), ({:id}, 'no-such-track', 2), ({:id}, '1002', 3)`).
+ Bind(dbx.Params{"id": pls.ID}).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(repo.(*playlistRepository).removeOrphans(ctx)).To(Succeed())
+
+ Expect(storedRows(pls.ID)).To(Equal([]string{"1:1001", "2:1002"}))
+ got, err := repo.Get(ctx, pls.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.SongCount).To(Equal(2))
+ })
+ })
+
// Exists is ctx-sensitive through userFilter, so callers that only want "does it still exist"
// -- the public image route serving a share -- must elevate, or a private playlist looks gone.
Describe("Exists visibility", func() {
diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go
index 392446cef..587410305 100644
--- a/persistence/playlist_track_repository.go
+++ b/persistence/playlist_track_repository.go
@@ -213,7 +213,7 @@ func (r *playlistTrackRepository) Insert(ctx context.Context, mediaFileIds []str
if err != nil || inserted == n {
return inserted, err
}
- // The shift above reserved a slot per requested id, so ids dropped by the library filter
+ // The shift above reserved a slot per requested id, so ids dropped by keepAccessible
// leave a hole. Close it.
return inserted, r.playlistRepo.renumber(ctx, r.playlistId)
}
diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go
index 119c57116..29911ebd4 100644
--- a/persistence/playlist_track_repository_test.go
+++ b/persistence/playlist_track_repository_test.go
@@ -83,12 +83,14 @@ var _ = Describe("PlaylistTrackRepository", func() {
Describe("Insert", func() {
var tracks model.PlaylistTrackRepository
+ var plsID string
BeforeEach(func() {
plsRepo := NewPlaylistRepository(GetDBXBuilder())
pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
+ plsID = pls.ID
tracks = plsRepo.Tracks(ctx, pls.ID, false)
Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2))
@@ -123,6 +125,40 @@ var _ = Describe("PlaylistTrackRepository", func() {
Expect(err).ToNot(HaveOccurred())
Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"}))
})
+
+ It("drops ids that match no track, keeping positions contiguous", func() {
+ Expect(tracks.Insert(ctx, []string{"no-such-track", songComeTogether.ID}, 1)).To(Equal(1))
+
+ Expect(storedRows(plsID)).To(Equal([]string{
+ "1:" + songComeTogether.ID, "2:" + songDayInALife.ID, "3:" + songRadioactivity.ID,
+ }))
+ })
+ })
+
+ Describe("Add", func() {
+ var tracks model.PlaylistTrackRepository
+ var plsID string
+
+ BeforeEach(func() {
+ plsRepo := NewPlaylistRepository(GetDBXBuilder())
+ pls := model.Playlist{Name: "Add", OwnerID: "userid", OwnerName: "userid"}
+ Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
+ DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
+ plsID = pls.ID
+ tracks = plsRepo.Tracks(ctx, pls.ID, false)
+ })
+
+ It("drops ids that match no track and counts only the tracks added", func() {
+ Expect(tracks.Add(ctx, []string{songDayInALife.ID, "no-such-track", songComeTogether.ID, "!!not-an-id!!"})).To(Equal(2))
+
+ Expect(storedRows(plsID)).To(Equal([]string{"1:" + songDayInALife.ID, "2:" + songComeTogether.ID}))
+ })
+
+ It("adds nothing when no id matches a track", func() {
+ Expect(tracks.Add(ctx, []string{"no-such-track", "!!not-an-id!!"})).To(BeZero())
+
+ Expect(storedRows(plsID)).To(BeEmpty())
+ })
})
Describe("Reorder", func() {
diff --git a/resources/hosting/elfhosted.svg b/resources/hosting/elfhosted.svg
new file mode 100644
index 000000000..86bc86df9
--- /dev/null
+++ b/resources/hosting/elfhosted.svg
@@ -0,0 +1,77 @@
+
diff --git a/resources/hosting/pikapods.svg b/resources/hosting/pikapods.svg
new file mode 100644
index 000000000..a3865feea
--- /dev/null
+++ b/resources/hosting/pikapods.svg
@@ -0,0 +1,25 @@
+Run on PikaPods
+
+Run on PikaPods Button
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/hosting/zenith.svg b/resources/hosting/zenith.svg
new file mode 100644
index 000000000..0b5b11539
--- /dev/null
+++ b/resources/hosting/zenith.svg
@@ -0,0 +1,16 @@
+Deploy with Zenith
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go
index 1864a6a44..d39cad5f6 100644
--- a/scanner/walk_dir_tree.go
+++ b/scanner/walk_dir_tree.go
@@ -43,6 +43,13 @@ func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-
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
checker := newIgnoreChecker(job.fs)
err = checker.PushAllParents(ctx, folderPath)
@@ -225,6 +232,18 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool,
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
// resolveEntryName returns the name to classify the entry by, and whether to
diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go
index 43939e5c2..0c2aba3e0 100644
--- a/scanner/walk_dir_tree_test.go
+++ b/scanner/walk_dir_tree_test.go
@@ -4,8 +4,10 @@ import (
"context"
"fmt"
"io/fs"
+ "maps"
"os"
"path/filepath"
+ "slices"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
@@ -260,6 +262,44 @@ var _ = Describe("walk_dir_tree", func() {
// Folders not in targets should remain in lastUpdates
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.
- // It wraps os.DirFS behind the fs.FS interface, so fs.ReadLink-based
- // resolution is not available and full OS-level resolution is required.
+ // fs.ReadLink-based resolution can't follow targets outside the library
+ // root, so full OS-level resolution is required.
Context("production local storage FS", func() {
var libRoot string
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, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed())
- 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())
+ musicFS = newLocalMusicFS(libRoot)
})
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))
}
+// 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
type mockMusicFS struct {
storage.MusicFS
diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx
index 14d819a4e..e1695e980 100644
--- a/ui/src/playlist/PlaylistList.jsx
+++ b/ui/src/playlist/PlaylistList.jsx
@@ -95,6 +95,7 @@ export const ToggleField = ({ resource, source }) => {
return (
diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx
index 6c714b827..c05833166 100644
--- a/ui/src/playlist/PlaylistList.test.jsx
+++ b/ui/src/playlist/PlaylistList.test.jsx
@@ -2,6 +2,7 @@ import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { TestContext } from 'ra-test'
+import { RecordContextProvider } from 'react-admin'
import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList'
vi.mock('../config', () => ({
@@ -14,6 +15,7 @@ vi.mock('../common', () => ({
{record?.starred ? 'starred' : 'not-starred'}
),
+ isWritable: (ownerId) => ownerId === 'me',
}))
describe('', () => {
@@ -55,3 +57,28 @@ describe('playlist toggles without a record', () => {
expect(container.innerHTML).toBe('')
})
})
+
+// Secondary is a surface color in many themes, so these toggles must use primary
+describe('', () => {
+ const renderToggle = (record) =>
+ render(
+
+
+
+
+ ,
+ )
+
+ 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')
+ })
+})
diff --git a/ui/src/themes/dracula.js b/ui/src/themes/dracula.js
index 2e4ae38e5..45559c3af 100644
--- a/ui/src/themes/dracula.js
+++ b/ui/src/themes/dracula.js
@@ -185,16 +185,6 @@ export default {
color: `${foreground} !important`,
},
},
- MuiSwitch: {
- colorSecondary: {
- '&$checked': {
- color: green,
- },
- '&$checked + $track': {
- backgroundColor: green,
- },
- },
- },
NDAlbumGridView: {
albumName: {
marginTop: '0.5rem',
diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js
index 0f4cbd7c4..3e2955dcd 100644
--- a/ui/src/themes/gruvboxDark.js
+++ b/ui/src/themes/gruvboxDark.js
@@ -121,16 +121,6 @@ export default {
boxShadow: '3px 3px 5px #3c3836',
},
},
- MuiSwitch: {
- colorSecondary: {
- '&$checked': {
- color: '#458588',
- },
- '&$checked + $track': {
- backgroundColor: '#458588',
- },
- },
- },
NDMobileArtistDetails: {
bgContainer: {
background:
diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js
index 391a97f2c..285e579ae 100644
--- a/ui/src/themes/index.js
+++ b/ui/src/themes/index.js
@@ -25,6 +25,8 @@ import MoonbaseAlphaTheme from './moonbaseAlpha'
import MoonbaseBravoTheme from './moonbaseBravo'
import TokyoNightLightTheme from './tokyoNightLight'
import TokyoNightTheme from './tokyoNight'
+import SolarizedDarkTheme from './solarizedDark'
+import SolarizedLightTheme from './solarizedLight'
export default {
// Classic default themes
@@ -53,6 +55,8 @@ export default {
RosePineDawnTheme,
RosePineMoonTheme,
RosePineTheme,
+ SolarizedDarkTheme,
+ SolarizedLightTheme,
SpotifyTheme,
SquiddiesGlassTheme,
TokyoNightLightTheme,
diff --git a/ui/src/themes/solarizedDark.css.js b/ui/src/themes/solarizedDark.css.js
new file mode 100644
index 000000000..b357b8a0b
--- /dev/null
+++ b/ui/src/themes/solarizedDark.css.js
@@ -0,0 +1,148 @@
+const stylesheet = `
+
+.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
+ color: #268bd2
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track {
+ background-color: #268bd2
+}
+
+.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
+ background-color: #268bd2;
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
+ box-shadow: 0 0 2px #268bd2
+}
+
+.react-jinke-music-player-main .audio-item.playing svg {
+ color: #268bd2
+}
+
+.react-jinke-music-player-main .audio-item.playing .player-singer {
+ color: #268bd2 !important
+}
+
+.react-jinke-music-player-main .loading svg {
+ color: #268bd2 !important
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
+ border: hidden;
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+}
+
+.rc-slider-rail, .rc-slider-track {
+ height: 6px;
+}
+
+.react-jinke-music-player-main .rc-slider-rail,
+.react-jinke-music-player-mobile-progress .rc-slider-rail {
+ background-color: rgba(147, 161, 161, .2) !important
+}
+
+.rc-slider {
+ padding: 3px 0;
+}
+
+.sound-operation > div:nth-child(4) {
+ transform: translateX(-50%) translateY(5%) !important;
+}
+
+.sound-operation {
+ padding: 4px 0;
+}
+
+.react-jinke-music-player-main .music-player-panel {
+ background-color: #073642;
+ color: #93a1a1;
+ box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
+}
+
+.audio-lists-panel {
+ background-color: #073642;
+ bottom: 6.25rem;
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+}
+
+.audio-lists-panel-content .audio-item.playing {
+ background-color: rgba(0, 0, 0, 0);
+}
+
+.audio-lists-panel-content .audio-item:nth-child(2n+1) {
+ background-color: rgba(0, 0, 0, 0);
+}
+
+.audio-lists-panel-content .audio-item:active,
+.audio-lists-panel-content .audio-item:hover {
+ background-color: #0d4251;
+}
+
+.audio-lists-panel-header {
+ border-bottom: 1px solid rgba(0, 0, 0, 0.25);
+ box-shadow: none;
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn {
+ background-color: rgba(0, 0, 0, 0);
+ box-shadow: 0 0 0 0;
+}
+
+.audio-lists-panel-content .audio-item {
+ line-height: 32px;
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .img-content {
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+}
+
+.react-jinke-music-player-main .music-player-lyric {
+ color: #93a1a1;
+ -webkit-text-stroke: 0.5px #002b36;
+ font-weight: bolder;
+}
+
+.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg {
+ color: #268bd2 !important;
+}
+
+.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
+ color: #268bd2
+}
+
+.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg {
+ color: #268bd2
+}
+
+.audio-lists-panel-content .audio-item .player-icons {
+ scale: 75%;
+}
+
+/* Mobile */
+
+.react-jinke-music-player-mobile-cover {
+ border: none;
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+}
+
+.react-jinke-music-player .music-player-controller {
+ border: none;
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+ color: #268bd2;
+}
+
+.react-jinke-music-player .music-player-controller .music-player-controller-setting {
+ color: rgba(38, 139, 210, .3);
+}
+
+.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track {
+ background-color: #268bd2;
+}
+
+.react-jinke-music-player-mobile-progress .rc-slider-handle {
+ border: none;
+}
+`
+
+export default stylesheet
diff --git a/ui/src/themes/solarizedDark.js b/ui/src/themes/solarizedDark.js
new file mode 100644
index 000000000..1e1fcdff4
--- /dev/null
+++ b/ui/src/themes/solarizedDark.js
@@ -0,0 +1,380 @@
+import stylesheet from './solarizedDark.css.js'
+
+// Solarized palette by Ethan Schoonover: https://ethanschoonover.com/solarized/
+const background = '#002b36' // base03
+const surface = '#073642' // base02
+const currentLine = '#0d4251' // base02, slightly lighter for hover and headers
+const foreground = '#93a1a1' // base1
+const comment = '#839496' // base0
+const blue = '#268bd2'
+const link = '#57b5ae' // cyan, lightened to keep text links above 4.5:1
+const accent = '#2aa198' // cyan, since violet is too dark on base03
+const red = '#dc322f'
+
+// For Album, Playlist play button
+const musicListActions = {
+ alignItems: 'center',
+ '@global': {
+ 'button:first-child:not(:only-child)': {
+ '@media screen and (max-width: 720px)': {
+ transform: 'scale(1.5)',
+ margin: '1rem',
+ '&:hover': {
+ transform: 'scale(1.6) !important',
+ },
+ },
+ transform: 'scale(2)',
+ margin: '1.5rem',
+ minWidth: 0,
+ padding: 5,
+ transition: 'transform .3s ease',
+ backgroundColor: `${blue} !important`,
+ color: background,
+ borderRadius: 500,
+ border: 0,
+ '&:hover': {
+ transform: 'scale(2.1)',
+ backgroundColor: `${blue} !important`,
+ border: 0,
+ },
+ },
+ 'button:only-child': {
+ margin: '1.5rem',
+ },
+ 'button:first-child>span:first-child': {
+ padding: 0,
+ },
+ 'button:first-child>span:first-child>span': {
+ display: 'none',
+ },
+ 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
+ {
+ color: foreground,
+ },
+ },
+}
+
+export default {
+ themeName: 'Solarized Dark',
+ palette: {
+ primary: {
+ main: blue,
+ },
+ secondary: {
+ main: accent,
+ contrastText: background,
+ },
+ error: {
+ main: red,
+ },
+ type: 'dark',
+ background: {
+ default: background,
+ paper: surface,
+ },
+ },
+ overrides: {
+ MuiPaper: {
+ root: {
+ color: foreground,
+ backgroundColor: surface,
+ },
+ },
+ MuiAppBar: {
+ positionFixed: {
+ backgroundColor: `${surface} !important`,
+ boxShadow:
+ 'rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px',
+ },
+ colorSecondary: {
+ color: foreground,
+ },
+ },
+ MuiDrawer: {
+ root: {
+ background: background,
+ },
+ },
+ MuiButton: {
+ textPrimary: {
+ color: blue,
+ },
+ textSecondary: {
+ color: foreground,
+ },
+ },
+ MuiIconButton: {
+ root: {
+ color: foreground,
+ },
+ },
+ MuiChip: {
+ root: {
+ backgroundColor: currentLine,
+ },
+ },
+ MuiFormGroup: {
+ root: {
+ color: foreground,
+ },
+ },
+ MuiFormLabel: {
+ root: {
+ color: comment,
+ '&$focused': {
+ color: blue,
+ },
+ },
+ },
+ MuiFormHelperText: {
+ error: {
+ color: red,
+ },
+ },
+ MuiToolbar: {
+ root: {
+ backgroundColor: `${surface} !important`,
+ },
+ },
+ MuiOutlinedInput: {
+ root: {
+ '& $notchedOutline': {
+ borderColor: currentLine,
+ },
+ '&:hover $notchedOutline': {
+ borderColor: comment,
+ },
+ '&$focused $notchedOutline': {
+ borderColor: blue,
+ },
+ },
+ },
+ MuiFilledInput: {
+ root: {
+ backgroundColor: currentLine,
+ '&:hover': {
+ backgroundColor: comment,
+ },
+ '&$focused': {
+ backgroundColor: currentLine,
+ },
+ },
+ },
+ MuiTableRow: {
+ root: {
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: `${currentLine} !important`,
+ },
+ // Cells have an opaque background, so the hover must be applied to them too
+ '&:hover > .MuiTableCell-root': {
+ background: `${currentLine} !important`,
+ },
+ },
+ },
+ MuiTableHead: {
+ root: {
+ color: foreground,
+ background: surface,
+ },
+ },
+ MuiTableCell: {
+ root: {
+ color: foreground,
+ background: `${surface} !important`,
+ borderBottom: `1px solid ${currentLine}`,
+ },
+ head: {
+ color: `${foreground} !important`,
+ background: `${currentLine} !important`,
+ },
+ body: {
+ color: `${foreground} !important`,
+ },
+ },
+ NDAlbumGridView: {
+ albumName: {
+ marginTop: '0.5rem',
+ fontWeight: 700,
+ color: foreground,
+ },
+ albumSubtitle: {
+ color: comment,
+ },
+ albumContainer: {
+ backgroundColor: surface,
+ borderRadius: '8px',
+ padding: '.75rem',
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: currentLine,
+ },
+ },
+ albumPlayButton: {
+ backgroundColor: blue,
+ borderRadius: '50%',
+ boxShadow: '0 8px 8px rgb(0 0 0 / 30%)',
+ padding: '0.35rem',
+ transition: 'padding .3s ease',
+ '&:hover': {
+ background: `${blue} !important`,
+ padding: '0.45rem',
+ },
+ },
+ },
+ NDPlaylistDetails: {
+ container: {
+ background: `linear-gradient(${currentLine}, transparent)`,
+ borderRadius: 0,
+ paddingTop: '2.5rem !important',
+ boxShadow: 'none',
+ },
+ title: {
+ fontWeight: 700,
+ color: foreground,
+ },
+ details: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDAlbumDetails: {
+ root: {
+ background: `linear-gradient(${currentLine}, transparent)`,
+ borderRadius: 0,
+ boxShadow: 'none',
+ },
+ cardContents: {
+ alignItems: 'center',
+ paddingTop: '1.5rem',
+ },
+ recordName: {
+ fontWeight: 700,
+ color: foreground,
+ },
+ recordArtist: {
+ fontSize: '.875rem',
+ fontWeight: 700,
+ color: accent,
+ },
+ recordMeta: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDCollapsibleComment: {
+ commentBlock: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDAlbumShow: {
+ albumActions: musicListActions,
+ },
+ NDPlaylistShow: {
+ playlistActions: musicListActions,
+ },
+ NDAudioPlayer: {
+ audioTitle: {
+ color: foreground,
+ fontSize: '0.875rem',
+ },
+ songTitle: {
+ fontWeight: 400,
+ },
+ songInfo: {
+ fontSize: '0.675rem',
+ color: comment,
+ },
+ },
+ NDLogin: {
+ systemNameLink: {
+ color: link,
+ },
+ welcome: {
+ color: foreground,
+ },
+ card: {
+ minWidth: 300,
+ background: surface,
+ },
+ button: {
+ boxShadow: '3px 3px 5px #00212b',
+ },
+ },
+ NDMobileArtistDetails: {
+ bgContainer: {
+ background: `linear-gradient(to bottom, rgba(0 43 54 / 72%), ${background})!important`,
+ },
+ },
+ RaLayout: {
+ content: {
+ padding: '0 !important',
+ background: background,
+ },
+ root: {
+ backgroundColor: background,
+ },
+ },
+ RaList: {
+ content: {
+ backgroundColor: background,
+ },
+ },
+ RaListToolbar: {
+ toolbar: {
+ backgroundColor: background,
+ padding: '0 .55rem !important',
+ },
+ },
+ RaSidebar: {
+ fixed: {
+ backgroundColor: background,
+ },
+ drawerPaper: {
+ backgroundColor: `${background} !important`,
+ },
+ },
+ RaMenuItemLink: {
+ root: {
+ color: foreground,
+ '&[aria-current="page"]': {
+ color: `${blue} !important`,
+ },
+ '&[aria-current="page"] .MuiListItemIcon-root': {
+ color: `${blue} !important`,
+ },
+ },
+ active: {
+ color: `${blue} !important`,
+ '& .MuiListItemIcon-root': {
+ color: `${blue} !important`,
+ },
+ },
+ },
+ RaLink: {
+ link: {
+ color: link,
+ },
+ },
+ RaButton: {
+ button: {
+ margin: '0 5px 0 5px',
+ },
+ },
+ RaPaginationActions: {
+ currentPageButton: {
+ border: `2px solid ${blue}`,
+ },
+ button: {
+ backgroundColor: currentLine,
+ minWidth: 48,
+ margin: '0 4px',
+ },
+ },
+ },
+ player: {
+ theme: 'dark',
+ stylesheet,
+ },
+}
diff --git a/ui/src/themes/solarizedLight.css.js b/ui/src/themes/solarizedLight.css.js
new file mode 100644
index 000000000..c6a599562
--- /dev/null
+++ b/ui/src/themes/solarizedLight.css.js
@@ -0,0 +1,123 @@
+const stylesheet = `
+.react-jinke-music-player-main.light-theme .loading svg {
+ color: #268bd2;
+ font-size: 24px
+}
+
+.react-jinke-music-player-mobile-play-model-tip {
+ background-color: #268bd2;
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track {
+ background-color: #268bd2
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
+ box-shadow: 0 0 2px #268bd2
+}
+
+.react-jinke-music-player-main.light-theme .audio-item.playing svg {
+ color: #268bd2
+}
+
+.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer {
+ color: #268bd2 !important
+}
+
+.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
+ color: #268bd2
+}
+
+.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg {
+ color: #268bd2
+}
+
+.react-jinke-music-player-main.light-theme ::-webkit-scrollbar-thumb {
+ background-color: #268bd2;
+}
+
+.react-jinke-music-player-main.light-theme svg {
+ color: #586e75
+}
+
+.react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover {
+ color: #268bd2
+}
+
+.react-jinke-music-player-main.light-theme .rc-slider-rail {
+ background-color: rgba(88, 110, 117, .12) !important
+}
+
+.react-jinke-music-player-main.light-theme .music-player-controller {
+ background-color: #fdf6e3;
+ border-color: #fdf6e3
+}
+
+.react-jinke-music-player-main.light-theme .music-player-panel {
+ background-color: #fdf6e3;
+ box-shadow: 0 1px 2px 0 rgba(0, 43, 54, .05);
+ color: #586e75
+}
+
+.react-jinke-music-player-main.light-theme .music-player-panel .img-content {
+ box-shadow: 0 0 10px #e4ddc6
+}
+
+.react-jinke-music-player-main.light-theme .music-player-panel .progress-load-bar {
+ background-color: rgba(88, 110, 117, .08) !important
+}
+
+.react-jinke-music-player-main.light-theme .rc-switch {
+ color: #fdf6e3
+}
+
+.react-jinke-music-player-main.light-theme .rc-switch:after {
+ background-color: #fdf6e3
+}
+
+.react-jinke-music-player-main.light-theme .rc-switch-checked {
+ background-color: #268bd2 !important;
+ border: 1px solid #268bd2
+}
+
+.react-jinke-music-player-main.light-theme .rc-switch-inner {
+ color: #fdf6e3
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-btn {
+ background-color: #eee8d5 !important
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-btn:active, .react-jinke-music-player-main.light-theme .audio-lists-btn:hover {
+ background-color: #e4ddc6;
+ color: #586e75
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover, .react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover > svg {
+ color: #268bd2
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel {
+ background-color: #fdf6e3;
+ box-shadow: 0 0 2px #e4ddc6;
+ color: #586e75
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item {
+ background-color: #fdf6e3
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item:nth-child(odd) {
+ background-color: #f5efdc !important
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing {
+ background-color: #e4ddc6 !important
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing svg {
+ color: #268bd2 !important
+}
+`
+
+export default stylesheet
diff --git a/ui/src/themes/solarizedLight.js b/ui/src/themes/solarizedLight.js
new file mode 100644
index 000000000..65e0241e9
--- /dev/null
+++ b/ui/src/themes/solarizedLight.js
@@ -0,0 +1,380 @@
+import stylesheet from './solarizedLight.css.js'
+
+// Solarized palette by Ethan Schoonover: https://ethanschoonover.com/solarized/
+const background = '#eee8d5' // base2
+const surface = '#fdf6e3' // base3
+const currentLine = '#e4ddc6' // base2, slightly darker for hover and headers
+const foreground = '#586e75' // base01
+const comment = '#657b83' // base00
+const blue = '#268bd2'
+const link = '#1c6599' // blue, darkened to keep text links above 4.5:1
+const accent = '#5d61a9' // violet, darkened to keep text above 4.5:1
+const red = '#dc322f'
+
+// For Album, Playlist play button
+const musicListActions = {
+ alignItems: 'center',
+ '@global': {
+ 'button:first-child:not(:only-child)': {
+ '@media screen and (max-width: 720px)': {
+ transform: 'scale(1.5)',
+ margin: '1rem',
+ '&:hover': {
+ transform: 'scale(1.6) !important',
+ },
+ },
+ transform: 'scale(2)',
+ margin: '1.5rem',
+ minWidth: 0,
+ padding: 5,
+ transition: 'transform .3s ease',
+ backgroundColor: `${blue} !important`,
+ color: background,
+ borderRadius: 500,
+ border: 0,
+ '&:hover': {
+ transform: 'scale(2.1)',
+ backgroundColor: `${blue} !important`,
+ border: 0,
+ },
+ },
+ 'button:only-child': {
+ margin: '1.5rem',
+ },
+ 'button:first-child>span:first-child': {
+ padding: 0,
+ },
+ 'button:first-child>span:first-child>span': {
+ display: 'none',
+ },
+ 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
+ {
+ color: foreground,
+ },
+ },
+}
+
+export default {
+ themeName: 'Solarized Light',
+ palette: {
+ primary: {
+ main: blue,
+ },
+ secondary: {
+ main: accent,
+ contrastText: surface,
+ },
+ error: {
+ main: red,
+ },
+ type: 'light',
+ background: {
+ default: background,
+ paper: surface,
+ },
+ },
+ overrides: {
+ MuiPaper: {
+ root: {
+ color: foreground,
+ backgroundColor: surface,
+ },
+ },
+ MuiAppBar: {
+ positionFixed: {
+ backgroundColor: `${surface} !important`,
+ boxShadow:
+ 'rgba(0, 43, 54, 0.12) 0px 4px 6px, rgba(0, 43, 54, 0.06) 0px 5px 7px',
+ },
+ colorSecondary: {
+ color: foreground,
+ },
+ },
+ MuiDrawer: {
+ root: {
+ background: background,
+ },
+ },
+ MuiButton: {
+ textPrimary: {
+ color: blue,
+ },
+ textSecondary: {
+ color: foreground,
+ },
+ },
+ MuiIconButton: {
+ root: {
+ color: foreground,
+ },
+ },
+ MuiChip: {
+ root: {
+ backgroundColor: currentLine,
+ },
+ },
+ MuiFormGroup: {
+ root: {
+ color: foreground,
+ },
+ },
+ MuiFormLabel: {
+ root: {
+ color: comment,
+ '&$focused': {
+ color: blue,
+ },
+ },
+ },
+ MuiFormHelperText: {
+ error: {
+ color: red,
+ },
+ },
+ MuiToolbar: {
+ root: {
+ backgroundColor: `${surface} !important`,
+ },
+ },
+ MuiOutlinedInput: {
+ root: {
+ '& $notchedOutline': {
+ borderColor: currentLine,
+ },
+ '&:hover $notchedOutline': {
+ borderColor: comment,
+ },
+ '&$focused $notchedOutline': {
+ borderColor: blue,
+ },
+ },
+ },
+ MuiFilledInput: {
+ root: {
+ backgroundColor: currentLine,
+ '&:hover': {
+ backgroundColor: comment,
+ },
+ '&$focused': {
+ backgroundColor: currentLine,
+ },
+ },
+ },
+ MuiTableRow: {
+ root: {
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: `${currentLine} !important`,
+ },
+ // Cells have an opaque background, so the hover must be applied to them too
+ '&:hover > .MuiTableCell-root': {
+ background: `${currentLine} !important`,
+ },
+ },
+ },
+ MuiTableHead: {
+ root: {
+ color: foreground,
+ background: surface,
+ },
+ },
+ MuiTableCell: {
+ root: {
+ color: foreground,
+ background: `${surface} !important`,
+ borderBottom: `1px solid ${currentLine}`,
+ },
+ head: {
+ color: `${foreground} !important`,
+ background: `${currentLine} !important`,
+ },
+ body: {
+ color: `${foreground} !important`,
+ },
+ },
+ NDAlbumGridView: {
+ albumName: {
+ marginTop: '0.5rem',
+ fontWeight: 700,
+ color: foreground,
+ },
+ albumSubtitle: {
+ color: comment,
+ },
+ albumContainer: {
+ backgroundColor: surface,
+ borderRadius: '8px',
+ padding: '.75rem',
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: currentLine,
+ },
+ },
+ albumPlayButton: {
+ backgroundColor: blue,
+ borderRadius: '50%',
+ boxShadow: '0 8px 8px rgb(0 0 0 / 20%)',
+ padding: '0.35rem',
+ transition: 'padding .3s ease',
+ '&:hover': {
+ background: `${blue} !important`,
+ padding: '0.45rem',
+ },
+ },
+ },
+ NDPlaylistDetails: {
+ container: {
+ background: `linear-gradient(${currentLine}, transparent)`,
+ borderRadius: 0,
+ paddingTop: '2.5rem !important',
+ boxShadow: 'none',
+ },
+ title: {
+ fontWeight: 700,
+ color: foreground,
+ },
+ details: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDAlbumDetails: {
+ root: {
+ background: `linear-gradient(${currentLine}, transparent)`,
+ borderRadius: 0,
+ boxShadow: 'none',
+ },
+ cardContents: {
+ alignItems: 'center',
+ paddingTop: '1.5rem',
+ },
+ recordName: {
+ fontWeight: 700,
+ color: foreground,
+ },
+ recordArtist: {
+ fontSize: '.875rem',
+ fontWeight: 700,
+ color: accent,
+ },
+ recordMeta: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDCollapsibleComment: {
+ commentBlock: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDAlbumShow: {
+ albumActions: musicListActions,
+ },
+ NDPlaylistShow: {
+ playlistActions: musicListActions,
+ },
+ NDAudioPlayer: {
+ audioTitle: {
+ color: foreground,
+ fontSize: '0.875rem',
+ },
+ songTitle: {
+ fontWeight: 400,
+ },
+ songInfo: {
+ fontSize: '0.675rem',
+ color: comment,
+ },
+ },
+ NDLogin: {
+ systemNameLink: {
+ color: link,
+ },
+ welcome: {
+ color: foreground,
+ },
+ card: {
+ minWidth: 300,
+ background: surface,
+ },
+ button: {
+ boxShadow: '3px 3px 5px #93a1a1',
+ },
+ },
+ NDMobileArtistDetails: {
+ bgContainer: {
+ background: `linear-gradient(to bottom, rgba(238 232 213 / 72%), ${background})!important`,
+ },
+ },
+ RaLayout: {
+ content: {
+ padding: '0 !important',
+ background: background,
+ },
+ root: {
+ backgroundColor: background,
+ },
+ },
+ RaList: {
+ content: {
+ backgroundColor: background,
+ },
+ },
+ RaListToolbar: {
+ toolbar: {
+ backgroundColor: background,
+ padding: '0 .55rem !important',
+ },
+ },
+ RaSidebar: {
+ fixed: {
+ backgroundColor: background,
+ },
+ drawerPaper: {
+ backgroundColor: `${background} !important`,
+ },
+ },
+ RaMenuItemLink: {
+ root: {
+ color: foreground,
+ '&[aria-current="page"]': {
+ color: `${blue} !important`,
+ },
+ '&[aria-current="page"] .MuiListItemIcon-root': {
+ color: `${blue} !important`,
+ },
+ },
+ active: {
+ color: `${blue} !important`,
+ '& .MuiListItemIcon-root': {
+ color: `${blue} !important`,
+ },
+ },
+ },
+ RaLink: {
+ link: {
+ color: link,
+ },
+ },
+ RaButton: {
+ button: {
+ margin: '0 5px 0 5px',
+ },
+ },
+ RaPaginationActions: {
+ currentPageButton: {
+ border: `2px solid ${blue}`,
+ },
+ button: {
+ backgroundColor: currentLine,
+ minWidth: 48,
+ margin: '0 4px',
+ },
+ },
+ },
+ player: {
+ theme: 'light',
+ stylesheet,
+ },
+}
diff --git a/ui/src/themes/tokyoNight.js b/ui/src/themes/tokyoNight.js
index 07d372a6b..9f6424b77 100644
--- a/ui/src/themes/tokyoNight.js
+++ b/ui/src/themes/tokyoNight.js
@@ -184,16 +184,6 @@ export default {
color: `${foreground} !important`,
},
},
- MuiSwitch: {
- colorSecondary: {
- '&$checked': {
- color: blue,
- },
- '&$checked + $track': {
- backgroundColor: blue,
- },
- },
- },
NDAlbumGridView: {
albumName: {
marginTop: '0.5rem',
diff --git a/ui/src/themes/tokyoNightLight.js b/ui/src/themes/tokyoNightLight.js
index f84cd0be9..a61c0fe87 100644
--- a/ui/src/themes/tokyoNightLight.js
+++ b/ui/src/themes/tokyoNightLight.js
@@ -184,16 +184,6 @@ export default {
color: `${foreground} !important`,
},
},
- MuiSwitch: {
- colorSecondary: {
- '&$checked': {
- color: blue,
- },
- '&$checked + $track': {
- backgroundColor: blue,
- },
- },
- },
NDAlbumGridView: {
albumName: {
marginTop: '0.5rem',
diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js
index 4ccefe820..fbb5e9bc8 100644
--- a/ui/src/themes/useCurrentTheme.js
+++ b/ui/src/themes/useCurrentTheme.js
@@ -63,6 +63,8 @@ const useCurrentTheme = () => {
...theme.props,
MuiUseMediaQuery: { noSsr: true },
MuiPopover: { disableScrollLock: true },
+ // MUI defaults to secondary, which many themes use as a surface color
+ MuiSwitch: { color: 'primary' },
},
}),
[theme],
diff --git a/ui/src/themes/useCurrentTheme.test.jsx b/ui/src/themes/useCurrentTheme.test.jsx
index 65c3be8c6..6553d9866 100644
--- a/ui/src/themes/useCurrentTheme.test.jsx
+++ b/ui/src/themes/useCurrentTheme.test.jsx
@@ -3,6 +3,10 @@ import { Provider } from 'react-redux'
import { createStore } from 'redux'
import mediaQuery from 'css-mediaquery'
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 { themeReducer } from '../reducers/themeReducer'
import { AUTO_THEME_ID } from '../consts'
@@ -161,4 +165,27 @@ describe('useCurrentTheme', () => {
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 }) => (
+
+ {children}
+
+ ),
+ })
+ render(
+
+ {}} />
+ ,
+ )
+ const switchBase = screen
+ .getByRole('checkbox')
+ .closest('.MuiSwitch-switchBase')
+ expect(switchBase.classList).toContain('MuiSwitch-colorPrimary')
+ },
+ )
+ })
})