From caa2f8a0c012328efb9d5685d67b8dc45d24a635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 6 Oct 2026 06:50:11 -0700 Subject: [PATCH 01/11] fix(ui): make playlist toggle switches visible in all themes (#6277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- ui/src/playlist/PlaylistList.jsx | 1 + ui/src/playlist/PlaylistList.test.jsx | 27 ++++++++++++++++++++++++++ ui/src/themes/dracula.js | 10 ---------- ui/src/themes/gruvboxDark.js | 10 ---------- ui/src/themes/tokyoNight.js | 10 ---------- ui/src/themes/tokyoNightLight.js | 10 ---------- ui/src/themes/useCurrentTheme.js | 2 ++ ui/src/themes/useCurrentTheme.test.jsx | 27 ++++++++++++++++++++++++++ 8 files changed, 57 insertions(+), 40 deletions(-) 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/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') + }, + ) + }) }) From 52135913d4747c8f8ddcfd8e7004202b8f53b594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 6 Oct 2026 07:57:07 -0700 Subject: [PATCH 02/11] 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 --- core/artwork/resolve.go | 7 ++++-- core/artwork/resolve_test.go | 10 +++++++++ core/artwork/worker.go | 18 +++++++++++++++- core/artwork/worker_test.go | 42 ++++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) 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"}}) From b78c963931563f23379e4762667a7b067fab864e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 8 Oct 2026 14:34:42 -0700 Subject: [PATCH 03/11] 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. --- core/storage/local/local.go | 11 +++++++ scanner/walk_dir_tree.go | 19 +++++++++++ scanner/walk_dir_tree_test.go | 62 ++++++++++++++++++++++++++++++----- 3 files changed, 84 insertions(+), 8 deletions(-) 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/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 From aea60c6e99220bd8d2076219e222f1b0c1bbfa71 Mon Sep 17 00:00:00 2001 From: raphmim <27868414+raphmim@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:06:16 +0000 Subject: [PATCH 04/11] Badges for new cloud providers --- README.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4bc85e6a6..12d819de2 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 have partners 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. [![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome) +
+[![Deploy with Zenith](https://cdn.zenith.hosting/buttons/deploy-with-zenith.svg)](https://zenith.hosting/host/navidrome?ref=navidrome) +
+ Deploy on ElfHosted + + + + + ## Features From 22ce85e5d55e403a62ecd6bd6604a9e63d5d4fc1 Mon Sep 17 00:00:00 2001 From: raphmim <27868414+raphmim@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:20:51 -0700 Subject: [PATCH 05/11] Minor fixes based on AI suggestions --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 12d819de2..a235333bd 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta ## Cloud Hosting -Several cloud hosting providers have partners 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. +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. [![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome)
From b4d2c3e4af276982facb25e4b7d085e0061070d1 Mon Sep 17 00:00:00 2001 From: deluan Date: Thu, 8 Oct 2026 21:59:52 -0400 Subject: [PATCH 06/11] docs: render hosting providers as proportionate deployment badges --- README.md | 6 +-- resources/hosting/elfhosted.svg | 77 +++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 resources/hosting/elfhosted.svg diff --git a/README.md b/README.md index a235333bd..b86cea1fa 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,11 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta 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. -[![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome) +Run on PikaPods
-[![Deploy with Zenith](https://cdn.zenith.hosting/buttons/deploy-with-zenith.svg)](https://zenith.hosting/host/navidrome?ref=navidrome) +Deploy with Zenith
- Deploy on ElfHosted +Deploy on ElfHosted 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 @@ +Deploy on ElfHosted + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Deploy on ElfHosted From d5a2e1e81355ad20ee3b6ff99479c4be904665ff Mon Sep 17 00:00:00 2001 From: deluan Date: Thu, 8 Oct 2026 22:02:28 -0400 Subject: [PATCH 07/11] docs: match README deployment badges to native PikaPods height --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b86cea1fa..f6aa97274 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,11 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta 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. -Run on PikaPods +[![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome)
-Deploy with Zenith +Deploy with Zenith
-Deploy on ElfHosted +Deploy on ElfHosted From b70ba3a49efec9d3476a8f6dd29726404cfcc843 Mon Sep 17 00:00:00 2001 From: deluan Date: Thu, 8 Oct 2026 22:19:34 -0400 Subject: [PATCH 08/11] docs: use equal-size local hosting badges without stretching artwork --- README.md | 6 +++--- resources/hosting/pikapods.svg | 25 +++++++++++++++++++++++++ resources/hosting/zenith.svg | 16 ++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 resources/hosting/pikapods.svg create mode 100644 resources/hosting/zenith.svg diff --git a/README.md b/README.md index f6aa97274..e1a2955a1 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,11 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta 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. -[![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome) +Run on PikaPods
-Deploy with Zenith +Deploy with Zenith
-Deploy on ElfHosted +Deploy on ElfHosted 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 + + + + + + + + + + + + + + + From 74e96a2bb75bad470f4a4f2d5573640e75f784fc Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 10 Oct 2026 11:28:58 -0400 Subject: [PATCH 09/11] fix(playlists): drop unknown track ids when adding to a playlist Adding tracks to a playlist skipped the media_file lookup for admins and for users who can see every library, so ids matching no track were stored as orphan playlist_tracks rows and reported as added. The native API returned an inflated "added" count, and the hidden rows left gaps in the positions, so Subsonic songIndexToRemove could delete a hidden row instead of the song at that index. keepAccessible now always checks the ids against media_file. applyLibraryFilter is already a no-op for those users, so the check only drops unknown ids. Rows stored before this fix are still removed by the existing GC removeOrphans step on the next full scan, or on any scan that finds changes. Fixes #6310 --- persistence/persistence_suite_test.go | 9 +++++ persistence/playlist_repository.go | 7 ++-- persistence/playlist_repository_test.go | 26 ++++++++++++++ persistence/playlist_track_repository_test.go | 34 +++++++++++++++++++ 4 files changed, 71 insertions(+), 5 deletions(-) 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..c7559de18 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -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..d0f1f7f44 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -469,6 +469,32 @@ 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"} + pls.AddMediaFilesByID([]string{"1001", "1002"}) + Expect(repo.Put(ctx, &pls)).To(Succeed()) + DeferCleanup(func() { Expect(repo.Delete(ctx, pls.ID)).To(Succeed()) }) + // Rows an older version stored for unknown ids, interleaved with real tracks + for _, q := range []string{"-(id * 2)", "-id"} { + _, err := GetDBXBuilder().NewQuery(`UPDATE playlist_tracks SET id = ` + q + ` WHERE playlist_id = {:id}`). + Bind(dbx.Params{"id": pls.ID}).Execute() + Expect(err).ToNot(HaveOccurred()) + } + _, err := GetDBXBuilder().NewQuery(`INSERT INTO playlist_tracks (playlist_id, media_file_id, id) + VALUES ({:id}, 'no-such-track', 1), ({:id}, '!!not-an-id!!', 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_test.go b/persistence/playlist_track_repository_test.go index 119c57116..aba4fdd95 100644 --- a/persistence/playlist_track_repository_test.go +++ b/persistence/playlist_track_repository_test.go @@ -123,6 +123,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(tracks.(*playlistTrackRepository).playlistId)).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() { From 803b2da8ee37c80f36c2c93ba42ef0496ffaef08 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 10 Oct 2026 11:35:43 -0400 Subject: [PATCH 10/11] refactor(playlists): simplify #6310 tests and update check comments Builds the removeOrphans fixture with a single INSERT instead of shifting positions in two UPDATE passes, and has the Insert test keep the playlist id instead of reading a private repository field. Two comments now say that addTracks owns the existence check as well as the library check, and that Insert closes holes left by any id keepAccessible drops. No behavior change. --- persistence/playlist_repository.go | 2 +- persistence/playlist_repository_test.go | 10 ++-------- persistence/playlist_track_repository.go | 2 +- persistence/playlist_track_repository_test.go | 4 +++- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index c7559de18..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 { diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index d0f1f7f44..b7be0588b 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -472,17 +472,11 @@ 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"} - pls.AddMediaFilesByID([]string{"1001", "1002"}) Expect(repo.Put(ctx, &pls)).To(Succeed()) DeferCleanup(func() { Expect(repo.Delete(ctx, pls.ID)).To(Succeed()) }) - // Rows an older version stored for unknown ids, interleaved with real tracks - for _, q := range []string{"-(id * 2)", "-id"} { - _, err := GetDBXBuilder().NewQuery(`UPDATE playlist_tracks SET id = ` + q + ` WHERE playlist_id = {:id}`). - Bind(dbx.Params{"id": pls.ID}).Execute() - Expect(err).ToNot(HaveOccurred()) - } + // 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}, 'no-such-track', 1), ({:id}, '!!not-an-id!!', 3)`). + VALUES ({:id}, '1001', 1), ({:id}, 'no-such-track', 2), ({:id}, '1002', 3)`). Bind(dbx.Params{"id": pls.ID}).Execute() Expect(err).ToNot(HaveOccurred()) 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 aba4fdd95..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)) @@ -127,7 +129,7 @@ var _ = Describe("PlaylistTrackRepository", func() { 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(tracks.(*playlistTrackRepository).playlistId)).To(Equal([]string{ + Expect(storedRows(plsID)).To(Equal([]string{ "1:" + songComeTogether.ID, "2:" + songDayInALife.ID, "3:" + songRadioactivity.ID, })) }) From 8e306b1f6172b0031133c42044830d9c87802d3b Mon Sep 17 00:00:00 2001 From: Rucolastico Date: Sun, 11 Oct 2026 11:33:34 -0600 Subject: [PATCH 11/11] feat(ui): add Solarized Light and Dark themes (#6280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add Solarized Light and Dark themes Add Light and Dark variants of Ethan Schoonover's Solarized palette, built on the Tokyo Night theme structure so they cover the app bar, sidebar, tables, album grid, album/playlist details, login and the player. Text colors are one step stronger than canonical Solarized body text (base01 on light, base1 on dark) so table text keeps WCAG AA contrast. The Dark variant uses cyan instead of violet as the secondary accent, since violet is too dark on base03. Co-Authored-By: Claude Opus 5.5 Signed-off-by: rucolastico * fix(ui): improve Solarized contrast and table row hover Address review feedback on the Solarized themes: - Use text-specific shades for links and the login system name, keeping them above 4.5:1 on table surfaces and hover rows (darkened blue on Light, lightened cyan on Dark). Canonical blue stays for controls. - Darken the Light theme's violet accent, which is also used as text (album artist), to keep it above 4.5:1. - Give secondary buttons readable text: base3 on violet (Light) and base03 on cyan (Dark), instead of the body text color. - Apply the row hover color to table cells, whose opaque background was hiding the row hover. Co-Authored-By: Claude Opus 5.5 Signed-off-by: rucolastico * fix(ui): keep Solarized app bar title and icons readable The app bar uses the secondary color, whose contrastText matched the app bar background (1:1 in Light, 1.15:1 in Dark). Override the app bar's colorSecondary text with the theme foreground (4.99:1 Light, 4.86:1 Dark), keeping secondary.contrastText for buttons on the accent color. Co-Authored-By: Claude Opus 5.5 Signed-off-by: rucolastico --------- Signed-off-by: rucolastico Co-authored-by: Claude Opus 5.5 Co-authored-by: Deluan Quintão --- ui/src/themes/index.js | 4 + ui/src/themes/solarizedDark.css.js | 148 +++++++++++ ui/src/themes/solarizedDark.js | 380 ++++++++++++++++++++++++++++ ui/src/themes/solarizedLight.css.js | 123 +++++++++ ui/src/themes/solarizedLight.js | 380 ++++++++++++++++++++++++++++ 5 files changed, 1035 insertions(+) create mode 100644 ui/src/themes/solarizedDark.css.js create mode 100644 ui/src/themes/solarizedDark.js create mode 100644 ui/src/themes/solarizedLight.css.js create mode 100644 ui/src/themes/solarizedLight.js 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, + }, +}