mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-14 05:17:13 +02:00
Compare commits
14 commits
t3code/per
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30663f7d4f |
||
|
|
8e306b1f61 |
||
|
|
4ae97ab59e |
||
|
|
803b2da8ee | ||
|
|
74e96a2bb7 | ||
|
|
497dfe2586 |
||
|
|
b70ba3a49e | ||
|
|
d5a2e1e813 | ||
|
|
b4d2c3e4af | ||
|
|
22ce85e5d5 | ||
|
|
aea60c6e99 | ||
|
|
b78c963931 |
||
|
|
52135913d4 |
||
|
|
caa2f8a0c0 |
52 changed files with 2047 additions and 230 deletions
15
README.md
15
README.md
|
|
@ -35,11 +35,18 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
|
|||
|
||||
## Cloud Hosting
|
||||
|
||||
[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.
|
||||
|
||||
<a href="https://www.pikapods.com/pods?run=navidrome"><img src="resources/hosting/pikapods.svg" alt="Run on PikaPods" width="200" height="40"></a>
|
||||
<br>
|
||||
<a href="https://zenith.hosting/host/navidrome?ref=navidrome"><img src="resources/hosting/zenith.svg" alt="Deploy with Zenith" width="200" height="40"></a>
|
||||
<br>
|
||||
<a href="https://store.elfhosted.com/product/navidrome/"><img src="resources/hosting/elfhosted.svg" alt="Deploy on ElfHosted" width="200" height="40"></a>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[](https://www.pikapods.com/pods?run=navidrome)
|
||||
|
||||
## Features
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"}})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
77
resources/hosting/elfhosted.svg
Normal file
77
resources/hosting/elfhosted.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 35 KiB |
25
resources/hosting/pikapods.svg
Normal file
25
resources/hosting/pikapods.svg
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:ns1="http://www.bohemiancoding.com/sketch/ns" width="200" height="40" viewBox="0 0 200 40" role="img" aria-labelledby="badge-title"><!-- Artwork source: https://www.pikapods.com/static/run-button.svg; preserved and centered without distortion. --><title id="badge-title">Run on PikaPods</title><rect width="200" height="40" rx="6" fill="#43A047" /><svg viewBox="0 0 147 32" style="enable-background:new 0 0 147 32;" xml:space="preserve" x="8.12500000" y="0" width="183.75000000" height="40" preserveAspectRatio="xMidYMid meet">
|
||||
<style type="text/css">
|
||||
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#43A047;}
|
||||
.st1{fill:#FFFFFF;}
|
||||
.st2{opacity:0.6;fill:#FFFFFF;}
|
||||
</style>
|
||||
<title>Run on PikaPods Button</title>
|
||||
<path id="Background" ns1:type="MSShapeGroup" class="st0" d="M4,0h139c2.2,0,4,1.8,4,4v24c0,2.2-1.8,4-4,4H4c-2.2,0-4-1.8-4-4V4 C0,1.8,1.8,0,4,0z" />
|
||||
<path class="st1" d="M44.3,23.3c-0.3,0.6-0.8,0.8-1.4,0.8c-10,0-20,0-30,0c-2.6,0-4.8-1.7-5.4-4.3c-0.7-3,1.4-6,4.4-6.5 c0.3,0,0.5-0.1,0.8-0.1c0.7,0,1.2-0.5,1.2-1.1c0-0.6-0.5-1.1-1.2-1.1c-1.4,0-2.7,0-4.1,0c-0.7,0-1.2-0.4-1.2-1.1 C7.4,9.5,7.9,9,8.6,9c1.4,0,2.8,0,4.3,0c1.8,0,3.2,1.4,3.2,3.2c0,1.8-1.4,3.3-3.2,3.3c-1.8,0-3.1,1.2-3.3,2.9 c-0.2,1.5,0.8,3.4,2.9,3.5c1.2,0.1,2.3,0,3.5,0c0.5-4.5,2.5-8,6.3-10.6c2.2-1.5,4.6-2.2,7.1-2.4c-0.1,0.5-0.3,1-0.3,1.5 c-0.4,2.9,1.8,5.6,4.7,6c0.3,0,0.5,0.1,0.8,0c0.6-0.1,1-0.6,1-1.1c0-0.6-0.5-1-1.1-1c-1.3-0.1-2.3-0.7-2.9-1.8 c-0.6-1.2-0.4-2.6,0.6-3.7C33,8,34.3,7.6,35.5,8c1.2,0.4,2.1,1.6,2.2,2.9c0,0.1,0.1,0.3,0.2,0.4c3.2,2.2,5.2,5.2,6,9 c0.1,0.5,0.2,1.1,0.3,1.6C44.3,22.4,44.3,22.9,44.3,23.3z M38.8,17.7c0-0.6-0.5-1.1-1.1-1.1c-0.6,0-1.1,0.5-1.1,1.1 c0,0.6,0.5,1.1,1.1,1.1C38.3,18.7,38.8,18.2,38.8,17.7z" />
|
||||
<g>
|
||||
<path class="st1" d="M49.7,20.2v-8.6h3.7c0.9,0,1.6,0.1,2,0.2s0.7,0.4,1,0.8s0.4,0.8,0.4,1.4c0,0.6-0.2,1.2-0.6,1.6 s-0.9,0.7-1.7,0.8c0.4,0.2,0.7,0.5,0.9,0.7s0.6,0.7,1,1.4l1,1.7h-2.1L54,18.4c-0.4-0.7-0.8-1.1-0.9-1.3s-0.3-0.3-0.5-0.4 s-0.5-0.1-0.9-0.1h-0.4v3.6H49.7z M51.4,15.3h1.3c0.8,0,1.4,0,1.6-0.1s0.4-0.2,0.5-0.4s0.2-0.4,0.2-0.6c0-0.3-0.1-0.5-0.2-0.7 s-0.4-0.3-0.7-0.3c-0.1,0-0.6,0-1.3,0h-1.4V15.3z" />
|
||||
<path class="st1" d="M62.4,20.2v-0.9c-0.2,0.3-0.5,0.6-0.9,0.8s-0.8,0.3-1.2,0.3c-0.4,0-0.8-0.1-1.1-0.3s-0.6-0.4-0.7-0.8 s-0.2-0.8-0.2-1.4V14h1.6v2.9c0,0.9,0,1.4,0.1,1.6s0.2,0.4,0.3,0.5s0.4,0.2,0.6,0.2c0.3,0,0.5-0.1,0.8-0.2s0.4-0.3,0.5-0.6 c0.1-0.2,0.1-0.8,0.1-1.7V14h1.6v6.2H62.4z" />
|
||||
<path class="st1" d="M71.3,20.2h-1.6V17c0-0.7,0-1.1-0.1-1.3s-0.2-0.4-0.3-0.5s-0.3-0.2-0.6-0.2c-0.3,0-0.5,0.1-0.8,0.2 s-0.4,0.4-0.5,0.6s-0.1,0.7-0.1,1.4v2.8h-1.6V14h1.5v0.9c0.5-0.7,1.2-1.1,2.1-1.1c0.4,0,0.7,0.1,1,0.2s0.5,0.3,0.7,0.5 s0.3,0.4,0.3,0.7s0.1,0.6,0.1,1.1V20.2z" />
|
||||
<path class="st2" d="M75.8,17.1c0-1.2,0.3-2,1-2.6c0.5-0.5,1.2-0.7,2-0.7c0.9,0,1.6,0.3,2.1,0.8s0.8,1.3,0.8,2.3 c0,0.8-0.1,1.4-0.4,1.9s-0.6,0.8-1,1.1s-1,0.4-1.5,0.4c-0.9,0-1.6-0.3-2.1-0.8S75.8,18.2,75.8,17.1z M76.9,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.8,0.6,1.3,0.6c0.5,0,1-0.2,1.3-0.6s0.5-1,0.5-1.8c0-0.8-0.2-1.4-0.5-1.7s-0.8-0.6-1.3-0.6 c-0.5,0-1,0.2-1.3,0.6S76.9,16.3,76.9,17.1z" />
|
||||
<path class="st2" d="M82.9,20.2V14h0.9v0.9c0.5-0.7,1.1-1,2-1c0.4,0,0.7,0.1,1,0.2s0.5,0.3,0.7,0.5s0.3,0.5,0.3,0.8 c0,0.2,0.1,0.5,0.1,1v3.8h-1.1v-3.8c0-0.4,0-0.8-0.1-1s-0.2-0.4-0.4-0.5s-0.5-0.2-0.7-0.2c-0.4,0-0.8,0.1-1.2,0.4S84,16,84,16.8 v3.4H82.9z" />
|
||||
<path class="st2" d="M93,20.2v-8.6h3.2c0.6,0,1,0,1.3,0.1c0.4,0.1,0.8,0.2,1.1,0.4s0.5,0.5,0.7,0.8s0.3,0.7,0.3,1.2 c0,0.7-0.2,1.3-0.7,1.9s-1.3,0.8-2.5,0.8h-2.2v3.5H93z M94.2,15.7h2.2c0.7,0,1.3-0.1,1.6-0.4s0.5-0.7,0.5-1.2 c0-0.4-0.1-0.7-0.3-0.9s-0.4-0.4-0.7-0.5c-0.2-0.1-0.5-0.1-1.1-0.1h-2.2V15.7z" />
|
||||
<path class="st2" d="M100.9,12.8v-1.2h1.1v1.2H100.9z M100.9,20.2V14h1.1v6.2H100.9z" />
|
||||
<path class="st2" d="M103.6,20.2v-8.6h1.1v4.9l2.5-2.5h1.4l-2.4,2.3l2.6,3.9h-1.3l-2.1-3.2l-0.7,0.7v2.5H103.6z" />
|
||||
<path class="st2" d="M113.6,19.5c-0.4,0.3-0.8,0.6-1.1,0.7s-0.7,0.2-1.2,0.2c-0.7,0-1.2-0.2-1.6-0.5s-0.6-0.8-0.6-1.3 c0-0.3,0.1-0.6,0.2-0.8s0.3-0.5,0.5-0.6s0.5-0.3,0.8-0.3c0.2-0.1,0.5-0.1,0.9-0.2c0.9-0.1,1.5-0.2,1.9-0.4c0-0.1,0-0.2,0-0.3 c0-0.4-0.1-0.7-0.3-0.9c-0.3-0.2-0.7-0.4-1.2-0.4c-0.5,0-0.9,0.1-1.1,0.3s-0.4,0.5-0.5,0.9l-1-0.1c0.1-0.4,0.2-0.8,0.5-1.1 s0.5-0.5,0.9-0.6s0.9-0.2,1.4-0.2c0.5,0,1,0.1,1.3,0.2s0.6,0.3,0.7,0.5s0.3,0.4,0.3,0.7c0,0.2,0.1,0.5,0.1,1v1.4c0,1,0,1.6,0.1,1.9 s0.1,0.5,0.3,0.7h-1.1C113.7,20,113.7,19.8,113.6,19.5z M113.6,17.1c-0.4,0.2-1,0.3-1.7,0.4c-0.4,0.1-0.7,0.1-0.9,0.2 s-0.3,0.2-0.4,0.3s-0.1,0.3-0.1,0.5c0,0.3,0.1,0.5,0.3,0.7s0.5,0.3,0.9,0.3c0.4,0,0.8-0.1,1.1-0.3s0.5-0.4,0.7-0.7 c0.1-0.2,0.2-0.6,0.2-1.1V17.1z" />
|
||||
<path class="st2" d="M116.4,20.2v-8.6h3.2c0.6,0,1,0,1.3,0.1c0.4,0.1,0.8,0.2,1.1,0.4s0.5,0.5,0.7,0.8s0.3,0.7,0.3,1.2 c0,0.7-0.2,1.3-0.7,1.9s-1.3,0.8-2.5,0.8h-2.2v3.5H116.4z M117.5,15.7h2.2c0.7,0,1.3-0.1,1.6-0.4s0.5-0.7,0.5-1.2 c0-0.4-0.1-0.7-0.3-0.9s-0.4-0.4-0.7-0.5c-0.2-0.1-0.5-0.1-1.1-0.1h-2.2V15.7z" />
|
||||
<path class="st2" d="M123.9,17.1c0-1.2,0.3-2,1-2.6c0.5-0.5,1.2-0.7,2-0.7c0.9,0,1.6,0.3,2.1,0.8s0.8,1.3,0.8,2.3 c0,0.8-0.1,1.4-0.4,1.9s-0.6,0.8-1,1.1s-1,0.4-1.5,0.4c-0.9,0-1.6-0.3-2.1-0.8S123.9,18.2,123.9,17.1z M124.9,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.8,0.6,1.3,0.6c0.5,0,1-0.2,1.3-0.6s0.5-1,0.5-1.8c0-0.8-0.2-1.4-0.5-1.7s-0.8-0.6-1.3-0.6 c-0.5,0-1,0.2-1.3,0.6S124.9,16.3,124.9,17.1z" />
|
||||
<path class="st2" d="M135,20.2v-0.8c-0.4,0.6-1,0.9-1.7,0.9c-0.5,0-1-0.1-1.4-0.4s-0.7-0.7-1-1.1s-0.3-1.1-0.3-1.7 c0-0.6,0.1-1.2,0.3-1.7s0.5-0.9,0.9-1.2s0.9-0.4,1.4-0.4c0.4,0,0.7,0.1,1,0.2s0.5,0.4,0.7,0.6v-3.1h1v8.6H135z M131.6,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.7,0.6,1.2,0.6c0.5,0,0.9-0.2,1.2-0.6s0.5-1,0.5-1.7c0-0.9-0.2-1.5-0.5-1.9s-0.7-0.6-1.2-0.6 c-0.5,0-0.9,0.2-1.2,0.6S131.6,16.3,131.6,17.1z" />
|
||||
<path class="st2" d="M137.2,18.4l1-0.2c0.1,0.4,0.2,0.7,0.5,1s0.6,0.3,1.1,0.3c0.5,0,0.8-0.1,1.1-0.3s0.4-0.4,0.4-0.7 c0-0.2-0.1-0.4-0.3-0.6c-0.1-0.1-0.5-0.2-1.1-0.4c-0.8-0.2-1.3-0.4-1.6-0.5s-0.5-0.3-0.7-0.6s-0.2-0.5-0.2-0.8 c0-0.3,0.1-0.5,0.2-0.8s0.3-0.4,0.5-0.6c0.2-0.1,0.4-0.2,0.7-0.3s0.6-0.1,0.9-0.1c0.5,0,0.9,0.1,1.3,0.2s0.6,0.3,0.8,0.6 s0.3,0.6,0.4,1l-1,0.1c0-0.3-0.2-0.6-0.4-0.8s-0.5-0.3-1-0.3c-0.5,0-0.8,0.1-1,0.2s-0.3,0.3-0.3,0.6c0,0.1,0,0.3,0.1,0.4 c0.1,0.1,0.2,0.2,0.4,0.3c0.1,0,0.4,0.1,0.9,0.3c0.7,0.2,1.3,0.4,1.6,0.5s0.5,0.3,0.7,0.6s0.3,0.5,0.3,0.9c0,0.4-0.1,0.7-0.3,1 s-0.5,0.6-0.9,0.7s-0.8,0.3-1.3,0.3c-0.8,0-1.4-0.2-1.8-0.5S137.3,19,137.2,18.4z" />
|
||||
</g>
|
||||
</svg></svg>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
16
resources/hosting/zenith.svg
Normal file
16
resources/hosting/zenith.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -660,11 +660,6 @@
|
|||
"options": {
|
||||
"theme": "Tema",
|
||||
"language": "Língua",
|
||||
"dateFormat": "Formato de data",
|
||||
"dateFormats": {
|
||||
"language": "Igual à língua",
|
||||
"browser": "Igual ao navegador"
|
||||
},
|
||||
"defaultView": "Tela inicial",
|
||||
"desktop_notifications": "Notificações",
|
||||
"lastfmScrobbling": "Enviar scrobbles para Last.fm",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -158,8 +158,8 @@ and `MusicArtist` queries accept it too, matching artists credited on an album o
|
|||
`Years`; `StudioIds` (record labels, as listed by `GET Studios`); `SearchTerm`;
|
||||
`Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone
|
||||
`isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as
|
||||
in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` have no Navidrome
|
||||
equivalent and are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties;
|
||||
in Jellyfin; `Likes` and `Dislikes` are not supported, and `IsFolder`, `IsNotFolder` and
|
||||
`IsResumable` have no Navidrome equivalent, so all five are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties;
|
||||
unrecognized keys are skipped, and `Random` always sorts alone);
|
||||
`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId`
|
||||
returns direct children only (no tracks — no track is a library's direct child).
|
||||
|
|
@ -176,7 +176,7 @@ returns direct children only (no tracks — no track is a library's direct child
|
|||
| Artists / genres / labels | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres`, `GET Studios`, `GET Items/Filters` |
|
||||
| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Albums/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` |
|
||||
| Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) |
|
||||
| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` |
|
||||
| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE UserItems/{itemId}/Rating`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET`/`POST UserItems/{itemId}/UserData`, `GET`/`POST Users/{userId}/Items/{itemId}/UserData` |
|
||||
| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) |
|
||||
| Lyrics | `GET Audio/{itemId}/Lyrics` |
|
||||
| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Playing/Ping` (no-op), `POST Sessions/Capabilities[/Full]` |
|
||||
|
|
@ -402,12 +402,20 @@ make test PKG=./server/jellyfin/...
|
|||
in the playlist, so ids change when entries are inserted or removed; re-read the list after an
|
||||
edit. Real Jellyfin uses the song id. Clients that echo `PlaylistItemId` back (Finamp) work;
|
||||
clients that send the song id as `EntryIds` or to `Move` (Jellify) get `404`.
|
||||
- **Rating uses the `rating` param.** `POST Users/{userId}/Items/{itemId}/Rating` reads a `rating`
|
||||
(0-10) and stores it as 0-5 stars. Jellyfin's `likes` boolean is not read, so a client that sends
|
||||
only `likes` clears the rating.
|
||||
- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed), `Search/Hints` and the non-legacy
|
||||
`UserItems/{itemId}/Rating` are not implemented. Play counts only change through playback
|
||||
reporting.
|
||||
- **Ratings are stored as 0-5 stars.** Jellyfin's 0-10 `Rating` is rounded to the nearest star
|
||||
(any nonzero value keeps at least one), so it reads back as an even number. `likes=true` stores
|
||||
5 stars and `likes=false` 1 star, like Jellyfin's 10 and 1. A rating of 0 clears the rating.
|
||||
- **`POST .../Rating` needs a value.** Jellyfin clears the rating when `likes` is missing; Navidrome
|
||||
answers `400` and keeps it, as it does for a malformed or out-of-range value. Use
|
||||
`DELETE .../Rating` to clear. A numeric `rating` param (0-10) is also accepted when `likes` is
|
||||
absent.
|
||||
- **`POST .../UserData` writes only `Rating`, `Likes` and `IsFavorite`.** `Played`, `PlayCount`,
|
||||
`LastPlayedDate` and `PlaybackPositionTicks` are ignored. A body that is not a single JSON
|
||||
object, or a `Rating` outside 0-10, is a `400` and nothing is written.
|
||||
- **Annotation writes can't target another user.** Rating and UserData writes whose `userId` names
|
||||
a different user get `403`, even for admins; Jellyfin lets admins edit other users' data.
|
||||
- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed) and `Search/Hints` are not
|
||||
implemented. Play counts only change through playback reporting.
|
||||
- **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are
|
||||
accepted but never returned.
|
||||
- **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork
|
||||
|
|
|
|||
|
|
@ -1,47 +1,52 @@
|
|||
package jellyfin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
// resolveAnnotated finds which annotated repo owns id, returning the resource name used in
|
||||
// refreshResource events. Albums and songs 404 when the user can't access their library; artists
|
||||
// span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time
|
||||
// scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has
|
||||
// already been written, so callers must return without writing the annotation.
|
||||
func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, resource string) {
|
||||
type annotatedItem struct {
|
||||
repo model.AnnotatedRepository
|
||||
resource string // name used in refreshResource events
|
||||
ann model.Annotations
|
||||
}
|
||||
|
||||
// resolveAnnotated finds id's repo and the caller's annotations. Only albums and songs are library-gated
|
||||
// (artists span libraries; playlists gate in Get). When !ok, the response has already been written.
|
||||
func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (item annotatedItem, ok bool) {
|
||||
ctx := r.Context()
|
||||
entity, err := model.GetEntityByID(ctx, api.ds, id)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
api.internalError(w, r, err)
|
||||
return nil, ""
|
||||
return annotatedItem{}, false
|
||||
}
|
||||
u, _ := request.UserFrom(ctx)
|
||||
switch e := entity.(type) {
|
||||
case *model.Album:
|
||||
if u.HasLibraryAccess(e.LibraryID) {
|
||||
return api.ds.Album(), "album"
|
||||
return annotatedItem{api.ds.Album(), "album", e.Annotations}, true
|
||||
}
|
||||
case *model.Artist:
|
||||
return api.ds.Artist(), "artist"
|
||||
return annotatedItem{api.ds.Artist(), "artist", e.Annotations}, true
|
||||
case *model.MediaFile:
|
||||
if u.HasLibraryAccess(e.LibraryID) {
|
||||
return api.ds.MediaFile(), "song"
|
||||
return annotatedItem{api.ds.MediaFile(), "song", e.Annotations}, true
|
||||
}
|
||||
case *model.Playlist:
|
||||
return api.ds.Playlist(), "playlist"
|
||||
return annotatedItem{api.ds.Playlist(), "playlist", e.Annotations}, true
|
||||
}
|
||||
// Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent.
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return nil, ""
|
||||
return annotatedItem{}, false
|
||||
}
|
||||
|
||||
// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify
|
||||
|
|
@ -65,65 +70,135 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) {
|
|||
api.ok(w, r, data)
|
||||
}
|
||||
|
||||
func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) {
|
||||
// userDataChange holds already-validated writes; a nil field leaves that annotation untouched.
|
||||
type userDataChange struct {
|
||||
rating *int // Navidrome stars; 0 clears
|
||||
favorite *bool
|
||||
}
|
||||
|
||||
// applyUserData writes change for the itemId path param and answers with the item's resulting
|
||||
// UserData, as Jellyfin does.
|
||||
func (api *Router) applyUserData(w http.ResponseWriter, r *http.Request, change userDataChange) {
|
||||
id, ok := itemIDParam(w, r, "itemId")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
repo, resource := api.resolveAnnotated(w, r, id)
|
||||
if repo == nil {
|
||||
item, ok := api.resolveAnnotated(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := repo.SetStar(r.Context(), starred, id); err != nil {
|
||||
api.internalError(w, r, err)
|
||||
return
|
||||
ctx := r.Context()
|
||||
if change.rating != nil {
|
||||
if err := item.repo.SetRating(ctx, *change.rating, id); err != nil {
|
||||
api.internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
item.ann.Rating = *change.rating
|
||||
}
|
||||
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
|
||||
encodedID := dto.EncodeID(id)
|
||||
api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID})
|
||||
if change.favorite != nil {
|
||||
if err := item.repo.SetStar(ctx, *change.favorite, id); err != nil {
|
||||
api.internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
item.ann.Starred = *change.favorite
|
||||
}
|
||||
if change.rating != nil || change.favorite != nil {
|
||||
api.broker.SendMessage(ctx, (&events.RefreshResource{}).With(item.resource, id))
|
||||
}
|
||||
api.ok(w, r, dto.UserData(item.ann, id))
|
||||
}
|
||||
|
||||
// applyCallerUserData is applyUserData for routes that may name a userId: only the caller's own
|
||||
// annotations can be written, so naming another user is refused. Undecodable ids are ignored.
|
||||
func (api *Router) applyCallerUserData(w http.ResponseWriter, r *http.Request, change userDataChange) {
|
||||
raw := chi.URLParam(r, "userId")
|
||||
if raw == "" {
|
||||
raw = r.URL.Query().Get("userid")
|
||||
}
|
||||
if target, ok := dto.DecodeID(raw); ok {
|
||||
if u, _ := request.UserFrom(r.Context()); target != u.ID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
api.applyUserData(w, r, change)
|
||||
}
|
||||
|
||||
func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
api.applyUserData(w, r, userDataChange{favorite: new(true)})
|
||||
}
|
||||
|
||||
func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) { api.setFavorite(w, r, true) }
|
||||
func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
api.setFavorite(w, r, false)
|
||||
api.applyUserData(w, r, userDataChange{favorite: new(false)})
|
||||
}
|
||||
|
||||
func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) {
|
||||
id, ok := itemIDParam(w, r, "itemId")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
repo, resource := api.resolveAnnotated(w, r, id)
|
||||
if repo == nil {
|
||||
return
|
||||
}
|
||||
if err := repo.SetRating(r.Context(), rating, id); err != nil {
|
||||
api.internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
|
||||
encodedID := dto.EncodeID(id)
|
||||
d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID}
|
||||
if rating > 0 {
|
||||
jfRating := float64(rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10, mirrors dto.UserData
|
||||
d.Rating = &jfRating
|
||||
}
|
||||
api.ok(w, r, d)
|
||||
}
|
||||
|
||||
// setRating maps Jellyfin's 0-10 rating (a nullable double, so fractional values are valid) to
|
||||
// Navidrome's 0-5 stars. A nonzero rating floors at one star: rounding to 0 would clear it, since
|
||||
// SetRating(0) is the delete path.
|
||||
// setRating reads Jellyfin's likes flag, or the numeric rating extension older clients send. Unlike
|
||||
// Jellyfin, a missing or malformed value is a 400 rather than a clear: clearing is DELETE's job.
|
||||
func (api *Router) setRating(w http.ResponseWriter, r *http.Request) {
|
||||
jfRating := req.Params(r).Float64Or("rating", 0)
|
||||
jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain rating
|
||||
rating := int(math.Round(jfRating / 2))
|
||||
if jfRating > 0 {
|
||||
rating = max(rating, 1)
|
||||
q := r.URL.Query()
|
||||
var stars int
|
||||
switch {
|
||||
case q.Has("likes"):
|
||||
likes, err := strconv.ParseBool(q.Get("likes"))
|
||||
if err != nil {
|
||||
http.Error(w, "likes must be true or false", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
stars = dto.StarsFromLikes(likes)
|
||||
case q.Has("rating"):
|
||||
rating, err := strconv.ParseFloat(q.Get("rating"), 64)
|
||||
s, ok := dto.StarsFromRating(rating)
|
||||
if err != nil || !ok {
|
||||
http.Error(w, "rating must be a number from 0 to 10", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
stars = s
|
||||
default:
|
||||
http.Error(w, "likes is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
api.setItemRating(w, r, rating)
|
||||
api.applyCallerUserData(w, r, userDataChange{rating: &stars})
|
||||
}
|
||||
|
||||
func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) {
|
||||
api.setItemRating(w, r, 0)
|
||||
api.applyCallerUserData(w, r, userDataChange{rating: new(0)})
|
||||
}
|
||||
|
||||
// updateUserItemData writes Rating, Likes and IsFavorite; omitted or null fields are left untouched.
|
||||
// Play state is ignored because it only changes through playback reporting.
|
||||
func (api *Router) updateUserItemData(w http.ResponseWriter, r *http.Request) {
|
||||
body, ok := decodeUserDataBody(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
change := userDataChange{favorite: body.IsFavorite}
|
||||
// Jellyfin applies Likes before Rating, so Rating wins when a body carries both.
|
||||
switch {
|
||||
case body.Rating != nil:
|
||||
stars, ok := dto.StarsFromRating(*body.Rating)
|
||||
if !ok {
|
||||
http.Error(w, "Rating must be from 0 to 10", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
change.rating = &stars
|
||||
case body.Likes != nil:
|
||||
change.rating = new(dto.StarsFromLikes(*body.Likes))
|
||||
}
|
||||
api.applyCallerUserData(w, r, change)
|
||||
}
|
||||
|
||||
const maxUserDataBody = 64 << 10
|
||||
|
||||
// decodeUserDataBody accepts exactly one JSON object; Decode alone would ignore anything after it.
|
||||
func decodeUserDataBody(w http.ResponseWriter, r *http.Request) (*dto.UpdateUserItemDataDto, bool) {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxUserDataBody))
|
||||
var body *dto.UpdateUserItemDataDto
|
||||
err := dec.Decode(&body)
|
||||
if err == nil && body != nil {
|
||||
if _, err = dec.Token(); errors.Is(err, io.EOF) {
|
||||
return body, true
|
||||
}
|
||||
}
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return nil, false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
|
|
@ -21,7 +23,7 @@ var _ = Describe("Annotations", func() {
|
|||
var broker *fakeEventBroker
|
||||
// alice has access to library 1 only.
|
||||
ctxUser := func() context.Context {
|
||||
return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
|
||||
return request.WithUser(GinkgoT().Context(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
|
|
@ -30,6 +32,16 @@ var _ = Describe("Annotations", func() {
|
|||
api = &Router{ds: ds, broker: broker}
|
||||
})
|
||||
|
||||
songWith := func(ann model.Annotations) *tests.MockMediaFileRepo {
|
||||
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: ann}})
|
||||
return mfRepo
|
||||
}
|
||||
songReq := func(method, target, body string, params ...string) *http.Request {
|
||||
r := httptest.NewRequest(method, target, strings.NewReader(body)).WithContext(ctxUser())
|
||||
return withChiURLParam(r, append([]string{"itemId", dto.EncodeID(testID("s1"))}, params...)...)
|
||||
}
|
||||
|
||||
Describe("markFavorite / unmarkFavorite", func() {
|
||||
It("stars a song and returns IsFavorite=true", func() {
|
||||
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||
|
|
@ -268,41 +280,275 @@ var _ = Describe("Annotations", func() {
|
|||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||
})
|
||||
|
||||
It("clamps a Rating above 10 to Navidrome's max (5)", func() {
|
||||
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
||||
invoke(api.setRating, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
|
||||
})
|
||||
|
||||
It("clamps a negative Rating to Navidrome's min (0)", func() {
|
||||
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
||||
invoke(api.setRating, w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
|
||||
})
|
||||
|
||||
It("emits a refreshResource event when rating a song", func() {
|
||||
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
||||
invoke(api.setRating, w, r)
|
||||
songWith(model.Annotations{})
|
||||
invoke(api.setRating, httptest.NewRecorder(), songReq("POST", "/Users/u1/Items/s1/Rating?Rating=8", ""))
|
||||
Expect(broker.Events).To(HaveLen(1))
|
||||
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`))
|
||||
})
|
||||
|
||||
Context("Jellyfin likes semantics", func() {
|
||||
It("stores likes=true as 5 stars and reports Rating 10 / Likes true", func() {
|
||||
mfRepo := songWith(model.Annotations{})
|
||||
w := httptest.NewRecorder()
|
||||
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=true", ""))
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
|
||||
d := parseUserData(w)
|
||||
Expect(*d.Rating).To(Equal(10.0))
|
||||
Expect(*d.Likes).To(BeTrue())
|
||||
})
|
||||
|
||||
It("stores likes=false as 1 star (Jellyfin's dislike is Rating 1), not a clear", func() {
|
||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||
w := httptest.NewRecorder()
|
||||
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?Likes=False", ""))
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
|
||||
d := parseUserData(w)
|
||||
Expect(*d.Rating).To(Equal(2.0))
|
||||
Expect(*d.Likes).To(BeFalse())
|
||||
})
|
||||
|
||||
It("prefers likes over the numeric rating extension when both are sent", func() {
|
||||
mfRepo := songWith(model.Annotations{})
|
||||
w := httptest.NewRecorder()
|
||||
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=false&rating=10", ""))
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
|
||||
})
|
||||
|
||||
It("still clears on an explicit rating=0 from numeric clients", func() {
|
||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||
w := httptest.NewRecorder()
|
||||
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?rating=0", ""))
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
|
||||
Expect(parseUserData(w).Rating).To(BeNil())
|
||||
})
|
||||
|
||||
It("keeps the favorite and play state and reports them in the response", func() {
|
||||
playDate := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
mfRepo := songWith(model.Annotations{Starred: true, PlayCount: 3, PlayDate: &playDate})
|
||||
w := httptest.NewRecorder()
|
||||
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=true", ""))
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
song := mfRepo.Data[testID("s1")]
|
||||
Expect(song.Starred).To(BeTrue())
|
||||
Expect(song.PlayCount).To(Equal(int64(3)))
|
||||
Expect(song.PlayDate).To(Equal(&playDate))
|
||||
d := parseUserData(w)
|
||||
Expect(d.IsFavorite).To(BeTrue())
|
||||
Expect(d.PlayCount).To(Equal(3))
|
||||
Expect(d.Played).To(BeTrue())
|
||||
})
|
||||
|
||||
DescribeTable("rejects a missing or malformed value without touching the stored rating",
|
||||
func(query string) {
|
||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||
w := httptest.NewRecorder()
|
||||
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating"+query, ""))
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||
Expect(broker.Events).To(BeEmpty())
|
||||
},
|
||||
Entry("no params (Jellyfin would clear)", ""),
|
||||
Entry("unrelated params only", "?userId=x&foo=1"),
|
||||
Entry("empty likes", "?likes="),
|
||||
Entry("non-boolean likes", "?likes=maybe"),
|
||||
Entry("non-boolean likes even with a valid rating", "?likes=maybe&rating=8"),
|
||||
Entry("empty rating", "?rating="),
|
||||
Entry("non-numeric rating", "?rating=abc"),
|
||||
Entry("NaN rating", "?rating=NaN"),
|
||||
Entry("infinite rating", "?rating=Inf"),
|
||||
Entry("rating above 10", "?Rating=100"),
|
||||
Entry("negative rating", "?Rating=-5"),
|
||||
)
|
||||
})
|
||||
|
||||
Context("authorization", func() {
|
||||
DescribeTable("refuses a userId naming another user and leaves the rating",
|
||||
func(method, target string, params ...string) {
|
||||
handler := api.setRating
|
||||
if method == "DELETE" {
|
||||
handler = api.removeRating
|
||||
}
|
||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||
w := httptest.NewRecorder()
|
||||
invoke(handler, w, songReq(method, target, "", params...))
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||
Expect(broker.Events).To(BeEmpty())
|
||||
},
|
||||
Entry("path userId", "POST", "/Users/u2/Items/s1/Rating?likes=true", "userId", dto.EncodeID(testID("u2"))),
|
||||
Entry("query userId on the /UserItems form", "POST", "/UserItems/s1/Rating?likes=true&userId="+dto.EncodeID(testID("u2"))),
|
||||
Entry("clearing", "DELETE", "/Users/u2/Items/s1/Rating", "userId", dto.EncodeID(testID("u2"))),
|
||||
)
|
||||
|
||||
It("accepts the caller's own userId", func() {
|
||||
mfRepo := songWith(model.Annotations{})
|
||||
w := httptest.NewRecorder()
|
||||
invoke(api.setRating, w, songReq("POST", "/Users/u1/Items/s1/Rating?likes=true", "", "userId", dto.EncodeID(testID("u1"))))
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("updateUserItemData", func() {
|
||||
post := func(body string, params ...string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
invoke(api.updateUserItemData, w, songReq("POST", "/UserItems/s1/UserData", body, params...))
|
||||
return w
|
||||
}
|
||||
|
||||
It("maps a 0-10 Rating to stars and answers with the full UserData", func() {
|
||||
mfRepo := songWith(model.Annotations{Starred: true, PlayCount: 2})
|
||||
w := post(`{"Rating":8}`)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||
d := parseUserData(w)
|
||||
Expect(*d.Rating).To(Equal(8.0))
|
||||
Expect(*d.Likes).To(BeTrue())
|
||||
Expect(d.IsFavorite).To(BeTrue())
|
||||
Expect(d.PlayCount).To(Equal(2))
|
||||
Expect(d.ItemId).To(Equal(dto.EncodeID(testID("s1"))))
|
||||
Expect(broker.Events).To(HaveLen(1))
|
||||
})
|
||||
|
||||
DescribeTable("stores the personal rating",
|
||||
func(body string, before, expected int) {
|
||||
mfRepo := songWith(model.Annotations{Rating: before})
|
||||
Expect(post(body).Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(expected))
|
||||
},
|
||||
Entry("Likes true", `{"Likes":true}`, 0, 5),
|
||||
Entry("Likes false (Rating 1)", `{"Likes":false}`, 4, 1),
|
||||
Entry("Rating wins over Likes, as Jellyfin applies Likes first", `{"Likes":true,"Rating":2}`, 0, 1),
|
||||
Entry("fractional Rating rounds to the nearest star", `{"Rating":7.5}`, 0, 4),
|
||||
Entry("small nonzero Rating floors at one star", `{"Rating":0.4}`, 0, 1),
|
||||
Entry("Rating 0 clears", `{"Rating":0}`, 4, 0),
|
||||
)
|
||||
|
||||
DescribeTable("leaves the rating untouched when Rating and Likes are omitted or null",
|
||||
func(body string, wantEvents int) {
|
||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||
w := post(body)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||
Expect(*parseUserData(w).Rating).To(Equal(8.0))
|
||||
Expect(broker.Events).To(HaveLen(wantEvents))
|
||||
},
|
||||
Entry("empty object writes nothing", `{}`, 0),
|
||||
Entry("explicit nulls write nothing", `{"Rating":null,"Likes":null,"IsFavorite":null}`, 0),
|
||||
Entry("favorite only", `{"IsFavorite":true}`, 1),
|
||||
)
|
||||
|
||||
It("sets and unsets the favorite without touching the rating", func() {
|
||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||
Expect(post(`{"IsFavorite":true}`).Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data[testID("s1")].Starred).To(BeTrue())
|
||||
Expect(post(`{"IsFavorite":false}`).Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||
})
|
||||
|
||||
It("ignores play-state fields, which only change through playback reporting", func() {
|
||||
mfRepo := songWith(model.Annotations{PlayCount: 2})
|
||||
w := post(`{"Played":true,"PlayCount":99,"LastPlayedDate":"2026-01-01T00:00:00Z","PlaybackPositionTicks":5}`)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
song := mfRepo.Data[testID("s1")]
|
||||
Expect(song.PlayCount).To(Equal(int64(2)))
|
||||
Expect(song.PlayDate).To(BeNil())
|
||||
Expect(broker.Events).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("leaves state unchanged when a client echoes back the UserData it read", func() {
|
||||
mfRepo := songWith(model.Annotations{Rating: 3, Starred: true, PlayCount: 1})
|
||||
echo, err := json.Marshal(dto.UserData(mfRepo.Data[testID("s1")].Annotations, testID("s1")))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(post(string(echo)).Code).To(Equal(http.StatusOK))
|
||||
song := mfRepo.Data[testID("s1")]
|
||||
Expect(song.Rating).To(Equal(3))
|
||||
Expect(song.Starred).To(BeTrue())
|
||||
Expect(song.PlayCount).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
DescribeTable("rejects an invalid body without writing anything",
|
||||
func(body string) {
|
||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||
Expect(post(body).Code).To(Equal(http.StatusBadRequest))
|
||||
song := mfRepo.Data[testID("s1")]
|
||||
Expect(song.Rating).To(Equal(4))
|
||||
Expect(song.Starred).To(BeFalse())
|
||||
Expect(broker.Events).To(BeEmpty())
|
||||
},
|
||||
Entry("empty body", ""),
|
||||
Entry("malformed JSON", `{"Rating":`),
|
||||
Entry("Rating above 10, even with a valid IsFavorite", `{"IsFavorite":true,"Rating":10.5}`),
|
||||
Entry("negative Rating", `{"IsFavorite":true,"Rating":-1}`),
|
||||
Entry("Rating as a string", `{"Rating":"8"}`),
|
||||
Entry("Likes as a string", `{"Likes":"yes"}`),
|
||||
Entry("IsFavorite as a number", `{"IsFavorite":1}`),
|
||||
Entry("JSON null", `null`),
|
||||
Entry("JSON array", `[]`),
|
||||
Entry("trailing garbage after the object", `{"Rating":8} x`),
|
||||
Entry("a second JSON value after the object", `{"Rating":8}{"Rating":2}`),
|
||||
Entry("a second object after whitespace", "{\"Rating\":8}\n{}"),
|
||||
)
|
||||
|
||||
It("accepts trailing whitespace after the object", func() {
|
||||
mfRepo := songWith(model.Annotations{})
|
||||
Expect(post("{\"Rating\":8}\r\n\t ").Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||
})
|
||||
|
||||
It("rejects a body larger than the limit", func() {
|
||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||
big := `{"Rating":8,"Pad":"` + strings.Repeat("x", 70<<10) + `"}`
|
||||
Expect(post(big).Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||
})
|
||||
|
||||
Context("authorization", func() {
|
||||
It("refuses a userId naming another user without writing", func() {
|
||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||
w := post(`{"Rating":10,"IsFavorite":true}`, "userId", dto.EncodeID(testID("u2")))
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
|
||||
})
|
||||
|
||||
It("accepts the caller's own userId", func() {
|
||||
songWith(model.Annotations{})
|
||||
Expect(post(`{"Rating":10}`, "userId", dto.EncodeID(testID("u1"))).Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("returns 404 and writes nothing for an album in a library the user can't access", func() {
|
||||
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
||||
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/UserItems/a1/UserData", strings.NewReader(`{"Rating":10,"IsFavorite":true}`)).WithContext(ctxUser())
|
||||
invoke(api.updateUserItemData, w, withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))))
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(0))
|
||||
Expect(albumRepo.Data[testID("a1")].Starred).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns 404 for an unknown item", func() {
|
||||
w := post(`{"Rating":10}`)
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func parseUserData(w *httptest.ResponseRecorder) dto.UserItemDataDto {
|
||||
var d dto.UserItemDataDto
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
||||
return d
|
||||
}
|
||||
|
||||
type fakeEventBroker struct {
|
||||
http.Handler
|
||||
Events []events.Event
|
||||
|
|
|
|||
|
|
@ -146,6 +146,8 @@ func (api *Router) routes() http.Handler {
|
|||
r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite)
|
||||
r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite)
|
||||
r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite)
|
||||
r.Post("/useritems/{itemId}/rating", api.setRating)
|
||||
r.Delete("/useritems/{itemId}/rating", api.removeRating)
|
||||
r.Post("/users/{userId}/items/{itemId}/rating", api.setRating)
|
||||
r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating)
|
||||
|
||||
|
|
@ -153,6 +155,8 @@ func (api *Router) routes() http.Handler {
|
|||
// /Users/{userId}/Items is the legacy spelling.
|
||||
r.Get("/useritems/{itemId}/userdata", api.getUserItemData)
|
||||
r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData)
|
||||
r.Post("/useritems/{itemId}/userdata", api.updateUserItemData)
|
||||
r.Post("/users/{userId}/items/{itemId}/userdata", api.updateUserItemData)
|
||||
|
||||
r.Get("/artists/{itemId}/similar", api.getSimilarArtists)
|
||||
r.Get("/items/{itemId}/similar", api.getSimilarItems)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,14 @@ type UserItemDataDto struct {
|
|||
Key string `json:"Key"`
|
||||
ItemId string `json:"ItemId,omitempty"`
|
||||
LastPlayedDate *string `json:"LastPlayedDate,omitempty"`
|
||||
Likes *bool `json:"Likes,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateUserItemDataDto is the POST .../UserData body; other Jellyfin fields are accepted and ignored.
|
||||
type UpdateUserItemDataDto struct {
|
||||
Rating *float64 `json:"Rating"`
|
||||
Likes *bool `json:"Likes"`
|
||||
IsFavorite *bool `json:"IsFavorite"`
|
||||
}
|
||||
|
||||
type BaseItemDto struct {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package dto
|
|||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -121,6 +122,30 @@ func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo {
|
|||
}
|
||||
}
|
||||
|
||||
// minLikeRating is Jellyfin's UserItemData.MinLikeValue: a rating at or above it reads as Likes.
|
||||
const minLikeRating = 6.5
|
||||
|
||||
// StarsFromRating maps Jellyfin's 0-10 rating to 0-5 stars, rejecting out-of-range values and NaN.
|
||||
// A nonzero rating floors at one star, since 0 stars means "not rated".
|
||||
func StarsFromRating(rating float64) (int, bool) {
|
||||
if !(rating >= 0 && rating <= 10) {
|
||||
return 0, false
|
||||
}
|
||||
stars := int(math.Round(rating / 2))
|
||||
if rating > 0 {
|
||||
stars = max(stars, 1)
|
||||
}
|
||||
return stars, true
|
||||
}
|
||||
|
||||
// StarsFromLikes mirrors Jellyfin, which stores likes as Rating 10 and dislikes as Rating 1.
|
||||
func StarsFromLikes(likes bool) int {
|
||||
if likes {
|
||||
return 5
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func UserData(a model.Annotations, itemID string) *UserItemDataDto {
|
||||
// Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto.
|
||||
encodedID := EncodeID(itemID)
|
||||
|
|
@ -134,6 +159,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto {
|
|||
if a.Rating > 0 {
|
||||
r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10
|
||||
d.Rating = &r
|
||||
d.Likes = new(r >= minLikeRating)
|
||||
}
|
||||
if s := JellyfinDate(a.PlayDate); s != "" {
|
||||
d.LastPlayedDate = &s
|
||||
|
|
|
|||
|
|
@ -478,6 +478,30 @@ var _ = Describe("mappers", func() {
|
|||
Expect(item.ImageBlurHashes).To(BeNil())
|
||||
})
|
||||
|
||||
DescribeTable("UserData derives Likes from the rating like Jellyfin (rating >= 6.5)",
|
||||
func(stars int, expected *bool) {
|
||||
d := UserData(model.Annotations{Rating: stars}, testID("s1"))
|
||||
Expect(d.Likes).To(Equal(expected))
|
||||
},
|
||||
Entry("unrated has no Likes", 0, nil),
|
||||
Entry("1 star (Jellyfin 2) dislikes", 1, new(false)),
|
||||
Entry("3 stars (Jellyfin 6) dislikes", 3, new(false)),
|
||||
Entry("4 stars (Jellyfin 8) likes", 4, new(true)),
|
||||
Entry("5 stars (Jellyfin 10) likes", 5, new(true)),
|
||||
)
|
||||
|
||||
It("emits Rating as a whole number and omits Likes when unrated", func() {
|
||||
rated, err := json.Marshal(UserData(model.Annotations{Rating: 4}, testID("s1")))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(rated)).To(ContainSubstring(`"Rating":8,`))
|
||||
Expect(string(rated)).To(ContainSubstring(`"Likes":true`))
|
||||
|
||||
unrated, err := json.Marshal(UserData(model.Annotations{}, testID("s1")))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(unrated)).ToNot(ContainSubstring(`"Rating"`))
|
||||
Expect(string(unrated)).ToNot(ContainSubstring(`"Likes"`))
|
||||
})
|
||||
|
||||
It("emits DateCreated and SortName on playlists, like albums and artists", func() {
|
||||
p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)}
|
||||
Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z"))
|
||||
|
|
|
|||
|
|
@ -130,13 +130,78 @@ var _ = Describe("Annotations", func() {
|
|||
Expect(*itemUserData(id).Rating).To(Equal(float64(8)))
|
||||
})
|
||||
|
||||
It("clamps an out-of-range rating to the valid domain", func() {
|
||||
It("rejects an out-of-range rating and keeps the stored one", func() {
|
||||
id := albumID("Help!")
|
||||
Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=8", "").Code).To(Equal(http.StatusOK))
|
||||
Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", "").Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(*itemUserData(id).Rating).To(Equal(float64(8)))
|
||||
})
|
||||
|
||||
It("stores Jellyfin likes on the current /UserItems route instead of clearing", func() {
|
||||
id := songID("So What")
|
||||
var liked dto.UserItemDataDto
|
||||
parseInto(post("/UserItems/"+enc(id)+"/Rating?likes=true", ""), &liked)
|
||||
Expect(*liked.Rating).To(Equal(float64(10)))
|
||||
Expect(*liked.Likes).To(BeTrue())
|
||||
|
||||
var disliked dto.UserItemDataDto
|
||||
parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?likes=false", ""), &disliked)
|
||||
Expect(*disliked.Rating).To(Equal(float64(2)))
|
||||
Expect(*disliked.Likes).To(BeFalse())
|
||||
Expect(*itemUserData(id).Likes).To(BeFalse())
|
||||
|
||||
Expect(del("/UserItems/" + enc(id) + "/Rating").Code).To(Equal(http.StatusOK))
|
||||
Expect(itemUserData(id).Rating).To(BeNil())
|
||||
Expect(itemUserData(id).Likes).To(BeNil())
|
||||
})
|
||||
|
||||
It("does not clear a rating when the request has no value", func() {
|
||||
id := songID("So What")
|
||||
Expect(post("/UserItems/"+enc(id)+"/Rating?likes=true", "").Code).To(Equal(http.StatusOK))
|
||||
Expect(post("/UserItems/"+enc(id)+"/Rating", "").Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(post("/UserItems/"+enc(id)+"/Rating?likes=sure", "").Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(*itemUserData(id).Rating).To(Equal(float64(10)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("POST /UserItems/{id}/UserData", func() {
|
||||
It("sets Rating, Likes and IsFavorite on both route spellings", func() {
|
||||
id := songID("Come Together")
|
||||
var data dto.UserItemDataDto
|
||||
parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", ""), &data)
|
||||
// 100 clamps to 10 (Jellyfin) -> 5 (Navidrome) -> 10 back out.
|
||||
Expect(data.Rating).ToNot(BeNil())
|
||||
Expect(*data.Rating).To(Equal(float64(10)))
|
||||
parseInto(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":6}`), &data)
|
||||
Expect(*data.Rating).To(Equal(float64(6)))
|
||||
Expect(*data.Likes).To(BeFalse())
|
||||
|
||||
parseInto(post("/Users/"+enc(adminUser.ID)+"/Items/"+enc(id)+"/UserData", `{"Likes":true,"IsFavorite":true}`), &data)
|
||||
stored := itemUserData(id)
|
||||
Expect(*stored.Rating).To(Equal(float64(10)))
|
||||
Expect(*stored.Likes).To(BeTrue())
|
||||
Expect(stored.IsFavorite).To(BeTrue())
|
||||
})
|
||||
|
||||
It("leaves omitted fields and play state untouched", func() {
|
||||
id := songID("Come Together")
|
||||
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":8,"IsFavorite":true}`).Code).To(Equal(http.StatusOK))
|
||||
before := itemUserData(id)
|
||||
|
||||
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Played":true,"PlayCount":42,"LastPlayedDate":"2026-01-01T00:00:00Z"}`).Code).To(Equal(http.StatusOK))
|
||||
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"IsFavorite":false}`).Code).To(Equal(http.StatusOK))
|
||||
|
||||
after := itemUserData(id)
|
||||
Expect(*after.Rating).To(Equal(float64(8)))
|
||||
Expect(after.IsFavorite).To(BeFalse())
|
||||
Expect(after.PlayCount).To(Equal(before.PlayCount))
|
||||
Expect(after.Played).To(Equal(before.Played))
|
||||
Expect(after.LastPlayedDate).To(Equal(before.LastPlayedDate))
|
||||
})
|
||||
|
||||
It("rejects invalid bodies without writing", func() {
|
||||
id := songID("Come Together")
|
||||
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":11,"IsFavorite":true}`).Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":8} {"Rating":2}`).Code).To(Equal(http.StatusBadRequest))
|
||||
stored := itemUserData(id)
|
||||
Expect(stored.Rating).To(BeNil())
|
||||
Expect(stored.IsFavorite).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
|
@ -28,6 +29,36 @@ var _ = Describe("Multi-user access control", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("personal ratings", func() {
|
||||
userData := func(u model.User, id string) *dto.UserItemDataDto {
|
||||
var data dto.UserItemDataDto
|
||||
parseInto(getAs(u, "/UserItems/"+enc(id)+"/UserData"), &data)
|
||||
return &data
|
||||
}
|
||||
|
||||
It("keeps each user's rating separate", func() {
|
||||
id := songID("So What")
|
||||
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusOK))
|
||||
Expect(postAs(regularUser, "/UserItems/"+enc(id)+"/Rating?likes=false", "").Code).To(Equal(http.StatusOK))
|
||||
|
||||
Expect(*userData(adminUser, id).Rating).To(Equal(float64(10)))
|
||||
Expect(*userData(regularUser, id).Rating).To(Equal(float64(2)))
|
||||
})
|
||||
|
||||
It("refuses writes that name another user, even for an admin, and changes nothing", func() {
|
||||
id := songID("So What")
|
||||
Expect(postAs(regularUser, "/UserItems/"+enc(id)+"/UserData", `{"Rating":4}`).Code).To(Equal(http.StatusOK))
|
||||
|
||||
Expect(postAs(regularUser, "/Users/"+enc(adminUser.ID)+"/Items/"+enc(id)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusForbidden))
|
||||
Expect(post("/Users/"+enc(regularUser.ID)+"/Items/"+enc(id)+"/Rating?likes=true", "").Code).To(Equal(http.StatusForbidden))
|
||||
Expect(del("/Users/" + enc(regularUser.ID) + "/Items/" + enc(id) + "/Rating").Code).To(Equal(http.StatusForbidden))
|
||||
Expect(post("/UserItems/"+enc(id)+"/UserData?userId="+enc(regularUser.ID), `{"Rating":10}`).Code).To(Equal(http.StatusForbidden))
|
||||
|
||||
Expect(userData(adminUser, id).Rating).To(BeNil())
|
||||
Expect(*userData(regularUser, id).Rating).To(Equal(float64(4)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("private playlists", func() {
|
||||
It("does not expose another user's private playlist", func() {
|
||||
adminPl := createPlaylist("Admin Private", nil)
|
||||
|
|
@ -53,6 +84,7 @@ var _ = Describe("Multi-user access control", func() {
|
|||
adminPl := createPlaylist("Admin Private", nil)
|
||||
Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound))
|
||||
Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound))
|
||||
Expect(postAs(regularUser, "/UserItems/"+enc(adminPl)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("lets each user manage their own playlist", func() {
|
||||
|
|
|
|||
|
|
@ -18,11 +18,13 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// withChiURLParam simulates chi's routing having captured a path parameter, since these
|
||||
// tests call handlers directly instead of going through the full router.
|
||||
func withChiURLParam(r *http.Request, key, value string) *http.Request {
|
||||
// withChiURLParam simulates chi's routing having captured path parameters (key, value pairs), since
|
||||
// these tests call handlers directly instead of going through the full router.
|
||||
func withChiURLParam(r *http.Request, kv ...string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add(key, value)
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
rctx.URLParams.Add(kv[i], kv[i+1])
|
||||
}
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ArrayField,
|
||||
BooleanField,
|
||||
ChipField,
|
||||
DateField,
|
||||
FunctionField,
|
||||
SingleFieldList,
|
||||
TextField,
|
||||
|
|
@ -16,7 +17,6 @@ import {
|
|||
} from 'react-admin'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import {
|
||||
DateField,
|
||||
ArtistLinkField,
|
||||
MultiLineTextField,
|
||||
ParticipantsInfo,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
Datagrid,
|
||||
DatagridBody,
|
||||
DatagridRow,
|
||||
DateField,
|
||||
NumberField,
|
||||
TextField,
|
||||
FunctionField,
|
||||
|
|
@ -12,7 +13,6 @@ import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useDrag } from 'react-dnd'
|
||||
import {
|
||||
DateField,
|
||||
ArtistLinkField,
|
||||
ArtworkAvatar,
|
||||
DurationField,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import React from 'react'
|
||||
import { isDateSet } from '../utils/validations'
|
||||
import { DateField as RADateField, useRecordContext } from 'react-admin'
|
||||
import { DateField as RADateField } from 'react-admin'
|
||||
import { useDateLocale } from '../i18n/useDateLocale'
|
||||
|
||||
export const DateField = (props) => {
|
||||
const record = useRecordContext(props)
|
||||
const { record, source } = props
|
||||
const locale = useDateLocale()
|
||||
const value = record?.[props.source]
|
||||
const value = record?.[source]
|
||||
if (!isDateSet(value)) return null
|
||||
return <RADateField locales={locale} {...props} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { RecordContextProvider } from 'react-admin'
|
||||
import { DateField } from './DateField'
|
||||
|
||||
vi.mock('react-admin', async (importOriginal) => ({
|
||||
|
|
@ -30,13 +29,4 @@ describe('<DateField>', () => {
|
|||
)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('reads the record from the record context', () => {
|
||||
render(
|
||||
<RecordContextProvider value={record}>
|
||||
<DateField source="updatedAt" />
|
||||
</RecordContextProvider>,
|
||||
)
|
||||
expect(screen.getByText('17.9.2026')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import TableContainer from '@material-ui/core/TableContainer'
|
|||
import TableRow from '@material-ui/core/TableRow'
|
||||
import {
|
||||
BooleanField,
|
||||
DateField,
|
||||
TextField,
|
||||
NumberField,
|
||||
FunctionField,
|
||||
|
|
@ -14,7 +15,6 @@ import {
|
|||
} from 'react-admin'
|
||||
import { humanize, underscore } from 'inflection'
|
||||
import {
|
||||
DateField,
|
||||
ArtistLinkField,
|
||||
BitrateField,
|
||||
ParticipantsInfo,
|
||||
|
|
|
|||
|
|
@ -677,11 +677,6 @@
|
|||
"options": {
|
||||
"theme": "Theme",
|
||||
"language": "Language",
|
||||
"dateFormat": "Date Format",
|
||||
"dateFormats": {
|
||||
"language": "Match language",
|
||||
"browser": "Match browser"
|
||||
},
|
||||
"defaultView": "Default View",
|
||||
"desktop_notifications": "Desktop Notifications",
|
||||
"lastfmNotConfigured": "Last.fm API-Key is not configured",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { useLocale } from 'react-admin'
|
||||
|
||||
export const getStoredDateFormat = () =>
|
||||
localStorage.getItem('dateFormat') || 'language'
|
||||
|
||||
// Our language codes are mostly region-less ("en"), and Intl reads a bare "en"
|
||||
// as en-US. Borrow the region from the browser when it speaks the same language.
|
||||
const resolveDateLocale = (locale, browserLocales = []) => {
|
||||
|
|
@ -13,10 +10,5 @@ const resolveDateLocale = (locale, browserLocales = []) => {
|
|||
)
|
||||
}
|
||||
|
||||
// With the "browser" date format, return undefined so Intl uses the browser's
|
||||
// own locale, regardless of the selected language.
|
||||
export const useDateLocale = () => {
|
||||
const locale = useLocale()
|
||||
if (getStoredDateFormat() === 'browser') return undefined
|
||||
return resolveDateLocale(locale, navigator.languages)
|
||||
}
|
||||
export const useDateLocale = () =>
|
||||
resolveDateLocale(useLocale(), navigator.languages)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ vi.mock('react-admin', () => ({
|
|||
describe('useDateLocale', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.removeItem('dateFormat')
|
||||
})
|
||||
|
||||
const renderWith = async (locale, browserLocales) => {
|
||||
|
|
@ -43,10 +42,4 @@ describe('useDateLocale', () => {
|
|||
const result = await renderWith(undefined, ['en-GB'])
|
||||
expect(result.current).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined to use the browser locale when set to browser', async () => {
|
||||
localStorage.setItem('dateFormat', 'browser')
|
||||
const result = await renderWith('en', ['fr-FR'])
|
||||
expect(result.current).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
DateField,
|
||||
List,
|
||||
Pagination,
|
||||
SizeField,
|
||||
|
|
@ -8,6 +7,7 @@ import {
|
|||
} from '../common/index'
|
||||
import {
|
||||
Datagrid,
|
||||
DateField,
|
||||
TextField,
|
||||
downloadCSV,
|
||||
Filter,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { SimpleForm, Title, useTranslate } from 'react-admin'
|
|||
import { Card } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { SelectLanguage } from './SelectLanguage'
|
||||
import { SelectDateFormat } from './SelectDateFormat'
|
||||
import { SelectTheme } from './SelectTheme'
|
||||
import { SelectDefaultView } from './SelectDefaultView'
|
||||
import { NotificationsToggle } from './NotificationsToggle'
|
||||
|
|
@ -25,7 +24,6 @@ const Personal = () => {
|
|||
<SimpleForm toolbar={null} variant={'outlined'}>
|
||||
<SelectTheme />
|
||||
<SelectLanguage />
|
||||
<SelectDateFormat />
|
||||
<SelectDefaultView />
|
||||
{config.enableReplayGain && <ReplayGainToggle />}
|
||||
<NotificationsToggle />
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import { SelectInput, useTranslate } from 'react-admin'
|
||||
import { getStoredDateFormat } from '../i18n/useDateLocale'
|
||||
|
||||
export const SelectDateFormat = (props) => {
|
||||
const translate = useTranslate()
|
||||
|
||||
return (
|
||||
<SelectInput
|
||||
{...props}
|
||||
source="dateFormat"
|
||||
label={translate('menu.personal.options.dateFormat')}
|
||||
defaultValue={getStoredDateFormat()}
|
||||
choices={[
|
||||
{ id: 'language', name: 'menu.personal.options.dateFormats.language' },
|
||||
{ id: 'browser', name: 'menu.personal.options.dateFormats.browser' },
|
||||
]}
|
||||
onChange={(event) => {
|
||||
localStorage.setItem('dateFormat', event.target.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useMemo } from 'react'
|
||||
import {
|
||||
Datagrid,
|
||||
DateField,
|
||||
EditButton,
|
||||
Filter,
|
||||
NullableBooleanInput,
|
||||
|
|
@ -19,7 +20,6 @@ import Switch from '@material-ui/core/Switch'
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import {
|
||||
DateField,
|
||||
ArtworkAvatar,
|
||||
DurationField,
|
||||
List,
|
||||
|
|
@ -95,6 +95,7 @@ export const ToggleField = ({ resource, source }) => {
|
|||
return (
|
||||
<Switch
|
||||
checked={record[source]}
|
||||
color="primary"
|
||||
onClick={handleClick}
|
||||
disabled={!isWritable(record.ownerId)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
</button>
|
||||
),
|
||||
isWritable: (ownerId) => ownerId === 'me',
|
||||
}))
|
||||
|
||||
describe('<PlaylistLove />', () => {
|
||||
|
|
@ -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('<ToggleField />', () => {
|
||||
const renderToggle = (record) =>
|
||||
render(
|
||||
<TestContext>
|
||||
<RecordContextProvider value={record}>
|
||||
<ToggleField resource="playlist" source="public" />
|
||||
</RecordContextProvider>
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
it.each([
|
||||
['owner', 'me', false],
|
||||
['non-owner', 'someone-else', true],
|
||||
])('renders a primary-colored switch for the %s', (_, ownerId, disabled) => {
|
||||
renderToggle({ id: 'pl-1', public: true, ownerId })
|
||||
const input = screen.getByRole('checkbox')
|
||||
const switchBase = input.closest('.MuiSwitch-switchBase')
|
||||
expect(input.checked).toBe(true)
|
||||
expect(input.disabled).toBe(disabled)
|
||||
expect(switchBase.classList).toContain('MuiSwitch-colorPrimary')
|
||||
expect(switchBase.classList).not.toContain('MuiSwitch-colorSecondary')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React, { cloneElement } from 'react'
|
|||
import {
|
||||
CreateButton,
|
||||
Datagrid,
|
||||
DateField,
|
||||
EditButton,
|
||||
Filter,
|
||||
sanitizeListRestProps,
|
||||
|
|
@ -14,7 +15,6 @@ import {
|
|||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import {
|
||||
DateField,
|
||||
List,
|
||||
defaultRowsPerPageOptions,
|
||||
getStoredPerPage,
|
||||
|
|
|
|||
|
|
@ -185,16 +185,6 @@ export default {
|
|||
color: `${foreground} !important`,
|
||||
},
|
||||
},
|
||||
MuiSwitch: {
|
||||
colorSecondary: {
|
||||
'&$checked': {
|
||||
color: green,
|
||||
},
|
||||
'&$checked + $track': {
|
||||
backgroundColor: green,
|
||||
},
|
||||
},
|
||||
},
|
||||
NDAlbumGridView: {
|
||||
albumName: {
|
||||
marginTop: '0.5rem',
|
||||
|
|
|
|||
|
|
@ -121,16 +121,6 @@ export default {
|
|||
boxShadow: '3px 3px 5px #3c3836',
|
||||
},
|
||||
},
|
||||
MuiSwitch: {
|
||||
colorSecondary: {
|
||||
'&$checked': {
|
||||
color: '#458588',
|
||||
},
|
||||
'&$checked + $track': {
|
||||
backgroundColor: '#458588',
|
||||
},
|
||||
},
|
||||
},
|
||||
NDMobileArtistDetails: {
|
||||
bgContainer: {
|
||||
background:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
148
ui/src/themes/solarizedDark.css.js
Normal file
148
ui/src/themes/solarizedDark.css.js
Normal file
|
|
@ -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
|
||||
380
ui/src/themes/solarizedDark.js
Normal file
380
ui/src/themes/solarizedDark.js
Normal file
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
123
ui/src/themes/solarizedLight.css.js
Normal file
123
ui/src/themes/solarizedLight.css.js
Normal file
|
|
@ -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
|
||||
380
ui/src/themes/solarizedLight.js
Normal file
380
ui/src/themes/solarizedLight.js
Normal file
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
@ -184,16 +184,6 @@ export default {
|
|||
color: `${foreground} !important`,
|
||||
},
|
||||
},
|
||||
MuiSwitch: {
|
||||
colorSecondary: {
|
||||
'&$checked': {
|
||||
color: blue,
|
||||
},
|
||||
'&$checked + $track': {
|
||||
backgroundColor: blue,
|
||||
},
|
||||
},
|
||||
},
|
||||
NDAlbumGridView: {
|
||||
albumName: {
|
||||
marginTop: '0.5rem',
|
||||
|
|
|
|||
|
|
@ -184,16 +184,6 @@ export default {
|
|||
color: `${foreground} !important`,
|
||||
},
|
||||
},
|
||||
MuiSwitch: {
|
||||
colorSecondary: {
|
||||
'&$checked': {
|
||||
color: blue,
|
||||
},
|
||||
'&$checked + $track': {
|
||||
backgroundColor: blue,
|
||||
},
|
||||
},
|
||||
},
|
||||
NDAlbumGridView: {
|
||||
albumName: {
|
||||
marginTop: '0.5rem',
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<Provider store={createStore(themeReducer, { theme })}>
|
||||
{children}
|
||||
</Provider>
|
||||
),
|
||||
})
|
||||
render(
|
||||
<ThemeProvider theme={createMuiTheme(result.current)}>
|
||||
<Switch checked onChange={() => {}} />
|
||||
</ThemeProvider>,
|
||||
)
|
||||
const switchBase = screen
|
||||
.getByRole('checkbox')
|
||||
.closest('.MuiSwitch-switchBase')
|
||||
expect(switchBase.classList).toContain('MuiSwitch-colorPrimary')
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue