mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge branch 'master' into feat/support-playlist-paths
This commit is contained in:
commit
0fb04dd611
@ -371,7 +371,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
|
||||
}
|
||||
|
||||
if !params.IgnoreScrobble && player.ScrobbleEnabled &&
|
||||
// NowPlaying gating, by design distinct from scrobble submission:
|
||||
// - IgnoreScrobble=true -> still send NowPlaying (suppresses only the
|
||||
// scrobble submission/play-count above), mirroring the legacy scrobble
|
||||
// endpoint's submission=false behavior.
|
||||
// - player.ScrobbleEnabled=false -> never send NowPlaying.
|
||||
// External agents here are the active scrobblers (Last.fm, ListenBrainz, and
|
||||
// scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying.
|
||||
if player.ScrobbleEnabled &&
|
||||
(params.State == StateStarting || params.State == StatePlaying) {
|
||||
if info, err := p.playMap.Get(clientId); err == nil {
|
||||
p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000))
|
||||
|
||||
@ -521,6 +521,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
|
||||
It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() {
|
||||
fake.ScrobbleCalled.Store(false)
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
@ -531,6 +532,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(track.PlayCount).To(Equal(int64(0)))
|
||||
Consistently(func() bool { return fake.ScrobbleCalled.Load() }).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() {
|
||||
@ -715,14 +717,14 @@ var _ = Describe("PlayTracker", func() {
|
||||
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("does NOT dispatch when ignoreScrobble=true", func() {
|
||||
It("still dispatches NowPlaying when ignoreScrobble=true", func() {
|
||||
fake.nowPlayingCalled.Store(false)
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
IgnoreScrobble: true,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
|
||||
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("does NOT dispatch when ScrobbleEnabled=false", func() {
|
||||
|
||||
1
db/db.go
1
db/db.go
@ -51,7 +51,6 @@ func Db() *sql.DB {
|
||||
_, err = db.Exec("PRAGMA optimize=0x10002")
|
||||
if err != nil {
|
||||
log.Error("Error applying PRAGMA optimize", err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return db
|
||||
|
||||
@ -104,6 +104,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
|
||||
"missing": booleanFilter,
|
||||
"artists_id": artistFilter,
|
||||
"library_id": libraryIdFilter,
|
||||
"path": startsWithFilter("media_file.path"),
|
||||
}
|
||||
// Add all album tags as filters
|
||||
for tag := range model.TagMappings() {
|
||||
|
||||
@ -524,6 +524,34 @@ var _ = Describe("MediaRepository", func() {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("path", func() {
|
||||
It("matches files whose path starts with the given prefix", func() {
|
||||
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"path": "test/"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
files := res.(model.MediaFiles)
|
||||
|
||||
var found bool
|
||||
for _, f := range files {
|
||||
Expect(f.Path).To(HavePrefix("test/"))
|
||||
if f.ID == mfWithoutAnnotation.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
Expect(found).To(BeTrue(), "MediaFile with matching path prefix should be included")
|
||||
})
|
||||
|
||||
It("excludes files whose path does not start with the given prefix", func() {
|
||||
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"path": "no-such-prefix/"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
files := res.(model.MediaFiles)
|
||||
Expect(files).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Search", func() {
|
||||
|
||||
@ -62,18 +62,6 @@ func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBu
|
||||
return s.Where(r.addRestriction())
|
||||
}
|
||||
|
||||
func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer {
|
||||
s := And{}
|
||||
if len(sql) > 0 {
|
||||
s = append(s, sql[0])
|
||||
}
|
||||
u := loggedUser(r.ctx)
|
||||
if u.IsAdmin {
|
||||
return s
|
||||
}
|
||||
return append(s, Eq{"user_id": u.ID})
|
||||
}
|
||||
|
||||
func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[string]int64, error) {
|
||||
sel := r.newSelect(options...).
|
||||
Columns(
|
||||
@ -125,6 +113,10 @@ func (r *playerRepository) NewInstance() any {
|
||||
return &model.Player{}
|
||||
}
|
||||
|
||||
// isPermitted authorizes creating a new record, based on the owner declared in the request body.
|
||||
// This is only safe for inserts: there is no stored row yet, and a non-admin may only create a
|
||||
// player they own. Updates must not use this (the body owner is attacker-controlled); they go
|
||||
// through updateOwned, which authorizes against the persisted user_id in the WHERE clause.
|
||||
func (r *playerRepository) isPermitted(p *model.Player) bool {
|
||||
u := loggedUser(r.ctx)
|
||||
return u.IsAdmin || p.UserId == u.ID
|
||||
@ -145,23 +137,11 @@ func (r *playerRepository) Save(entity any) (string, error) {
|
||||
func (r *playerRepository) Update(id string, entity any, cols ...string) error {
|
||||
t := entity.(*model.Player)
|
||||
t.ID = id
|
||||
if !r.isPermitted(t) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
_, err := r.put(id, t, cols...)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
return r.updateOwned(id, t, cols...)
|
||||
}
|
||||
|
||||
func (r *playerRepository) Delete(id string) error {
|
||||
filter := r.addRestriction(And{Eq{"player.id": id}})
|
||||
err := r.delete(filter)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
return r.deleteOwned(id)
|
||||
}
|
||||
|
||||
var _ model.PlayerRepository = (*playerRepository)(nil)
|
||||
|
||||
@ -110,33 +110,43 @@ var _ = Describe("PlayerRepository", func() {
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
DescribeTable("item type", func(player model.Player) {
|
||||
err := repo.Delete(player.ID)
|
||||
It("deletes a player owned by the current user", func() {
|
||||
err := repo.Delete(userPlayer.ID)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
isReal := player.UserId != ""
|
||||
canDelete := admin || player.UserId == userPlayer.UserId
|
||||
|
||||
count, err := repo.Count()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(count).To(Equal(baseCount - 1))
|
||||
|
||||
if isReal && canDelete {
|
||||
Expect(count).To(Equal(baseCount - 1))
|
||||
} else {
|
||||
Expect(count).To(Equal(baseCount))
|
||||
}
|
||||
_, err = repo.Get(userPlayer.ID)
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
item, err := repo.Get(player.ID)
|
||||
if !isReal || canDelete {
|
||||
It("does not delete another user's player when not admin", func() {
|
||||
err := repo.Delete(otherPlayer.ID)
|
||||
|
||||
if admin {
|
||||
// Admins may delete any player.
|
||||
Expect(err).To(BeNil())
|
||||
Expect(repo.Count()).To(Equal(baseCount - 1))
|
||||
_, err = repo.Get(otherPlayer.ID)
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
} else {
|
||||
Expect(*item).To(Equal(player))
|
||||
// The ownership-restricted delete matches no owned row, so it reports
|
||||
// permission-denied and leaves the other user's player untouched.
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
Expect(repo.Count()).To(Equal(baseCount))
|
||||
item, err := repo.Get(otherPlayer.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(*item).To(Equal(otherPlayer))
|
||||
}
|
||||
},
|
||||
Entry("same user", userPlayer),
|
||||
Entry("other item", otherPlayer),
|
||||
Entry("fake item", model.Player{}),
|
||||
)
|
||||
})
|
||||
|
||||
It("returns not-found for a nonexistent player", func() {
|
||||
err := repo.Delete("i don't exist")
|
||||
Expect(err).To(Equal(rest.ErrNotFound))
|
||||
Expect(repo.Count()).To(Equal(baseCount))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Read", func() {
|
||||
@ -215,9 +225,12 @@ var _ = Describe("PlayerRepository", func() {
|
||||
clone.MaxBitRate = 10000
|
||||
err := repo.Update(clone.ID, &clone, "ip")
|
||||
|
||||
if clone.UserId == "" {
|
||||
if player.UserId == "" {
|
||||
Expect(err).To(HaveOccurred())
|
||||
} else if !admin && player.Username == adminPlayer1.Username {
|
||||
// A non-admin cannot target another user's player: the ownership-restricted
|
||||
// update matches no owned row, so it reports permission-denied rather than
|
||||
// touching it.
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
clone.IP = player.IP
|
||||
} else {
|
||||
@ -244,4 +257,86 @@ var _ = Describe("PlayerRepository", func() {
|
||||
Entry("admin context", true, players, adminPlayer1, regularPlayer),
|
||||
Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1),
|
||||
)
|
||||
|
||||
Describe("Ownership enforcement (cross-tenant write protection)", func() {
|
||||
var regularRepo *playerRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, regularUser)
|
||||
regularRepo = NewPlayerRepository(ctx, database).(*playerRepository)
|
||||
})
|
||||
|
||||
It("does not let a regular user hijack another user's player by spoofing userId in the body", func() {
|
||||
// Attacker (regularUser) targets the victim's (adminUser) player by URL id,
|
||||
// but sets userId in the body to their own id to try to pass the permission check.
|
||||
spoofed := model.Player{
|
||||
ID: adminPlayer1.ID,
|
||||
Name: "HIJACKED",
|
||||
UserId: regularUser.ID, // attacker's own id, spoofed in the body
|
||||
MaxBitRate: 1,
|
||||
}
|
||||
|
||||
// The ownership-restricted update matches no row owned by the attacker, so the write
|
||||
// targets nothing and reports permission-denied rather than overwriting the victim's row.
|
||||
err := regularRepo.Update(adminPlayer1.ID, &spoofed, "name", "user_id", "max_bit_rate")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
|
||||
// The victim's player must remain untouched.
|
||||
stored, err := adminRepo.Get(adminPlayer1.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(*stored).To(Equal(adminPlayer1))
|
||||
})
|
||||
|
||||
It("does not let a regular user reassign their own player to another user", func() {
|
||||
// Owner updates their own player but tries to give it away to the admin. The update
|
||||
// succeeds for the other fields, but user_id is never written, so ownership stays put.
|
||||
reassign := regularPlayer
|
||||
reassign.UserId = adminUser.ID
|
||||
reassign.Name = "given-away"
|
||||
|
||||
err := regularRepo.Update(regularPlayer.ID, &reassign, "name", "user_id")
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
// Ownership must not have changed.
|
||||
stored, err := adminRepo.Get(regularPlayer.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(stored.UserId).To(Equal(regularUser.ID))
|
||||
})
|
||||
|
||||
It("does not let an admin reassign a player to another user", func() {
|
||||
// Even an admin cannot change a player's owner via update.
|
||||
reassign := regularPlayer
|
||||
reassign.UserId = adminUser.ID
|
||||
reassign.Name = "admin-renamed"
|
||||
|
||||
err := adminRepo.Update(regularPlayer.ID, &reassign, "name", "user_id")
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
// The name change applies, but ownership must not have moved.
|
||||
stored, err := adminRepo.Get(regularPlayer.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(stored.Name).To(Equal("admin-renamed"))
|
||||
Expect(stored.UserId).To(Equal(regularUser.ID))
|
||||
})
|
||||
|
||||
It("lets the owner update their own player", func() {
|
||||
update := regularPlayer
|
||||
update.Name = "renamed-by-owner"
|
||||
|
||||
err := regularRepo.Update(regularPlayer.ID, &update, "name")
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
stored, err := adminRepo.Get(regularPlayer.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(stored.Name).To(Equal("renamed-by-owner"))
|
||||
Expect(stored.UserId).To(Equal(regularUser.ID))
|
||||
})
|
||||
|
||||
It("returns not found when updating a nonexistent player", func() {
|
||||
ghost := model.Player{ID: "does-not-exist", Name: "ghost", UserId: regularUser.ID}
|
||||
err := regularRepo.Update("does-not-exist", &ghost, "name")
|
||||
Expect(err).To(Equal(rest.ErrNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -30,47 +30,18 @@ func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareReposito
|
||||
return r
|
||||
}
|
||||
|
||||
// TODO: Ownership checks should be moved to the service layer (core/share.go)
|
||||
func (r *shareRepository) checkOwnership(id string) error {
|
||||
usr := loggedUser(r.ctx)
|
||||
if usr.IsAdmin || usr.ID == invalidUserId {
|
||||
return nil
|
||||
}
|
||||
sel := r.newSelect().Columns("user_id").Where(Eq{"id": id})
|
||||
var share struct {
|
||||
UserID string `db:"user_id"`
|
||||
}
|
||||
err := r.queryOne(sel, &share)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if share.UserID != usr.ID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *shareRepository) Delete(id string) error {
|
||||
if err := r.checkOwnership(id); err != nil {
|
||||
return err
|
||||
}
|
||||
err := r.delete(Eq{"id": id})
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
return r.deleteOwned(id)
|
||||
}
|
||||
|
||||
func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelect(options...).Join("user u on u.id = share.user_id").
|
||||
Columns("share.*", "user_name as username")
|
||||
Columns("share.*", "user_name as username").
|
||||
Where(r.addRestriction())
|
||||
}
|
||||
|
||||
func (r *shareRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Eq{"id": id})
|
||||
return r.exists(r.addRestriction(And{Eq{"id": id}}))
|
||||
}
|
||||
|
||||
func (r *shareRepository) Get(id string) (*model.Share, error) {
|
||||
@ -166,17 +137,12 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles {
|
||||
|
||||
func (r *shareRepository) Update(id string, entity any, cols ...string) error {
|
||||
s := entity.(*model.Share)
|
||||
if err := r.checkOwnership(id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.ID = id
|
||||
s.UpdatedAt = time.Now()
|
||||
cols = append(cols, "updated_at")
|
||||
_, err := r.put(id, s, cols...)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
if len(cols) > 0 {
|
||||
cols = append(cols, "updated_at")
|
||||
}
|
||||
return err
|
||||
return r.updateOwned(id, s, cols...)
|
||||
}
|
||||
|
||||
func (r *shareRepository) Save(entity any) (string, error) {
|
||||
|
||||
@ -20,7 +20,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = request.WithUser(log.NewContext(context.TODO()), adminUser)
|
||||
ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
||||
repo = NewShareRepository(ctx, GetDBXBuilder())
|
||||
|
||||
// Insert the admin user into the database (required for foreign key constraint)
|
||||
@ -38,7 +38,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
Context("Repository creation and basic operations", func() {
|
||||
It("should create repository successfully with no user context", func() {
|
||||
// Create repository with no user context (headless)
|
||||
headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder())
|
||||
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
Expect(headlessRepo).ToNot(BeNil())
|
||||
})
|
||||
|
||||
@ -60,7 +60,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Headless process should see all shares
|
||||
headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder())
|
||||
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
shares, err := headlessRepo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -92,7 +92,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Headless process should be able to get the share
|
||||
headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder())
|
||||
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
share, err := headlessRepo.Get(shareID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(share.ID).To(Equal(shareID))
|
||||
@ -155,7 +155,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
Describe("Delete", func() {
|
||||
It("allows a non-admin user to delete their own share", func() {
|
||||
insertShare("own-share-del", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
|
||||
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Delete("own-share-del")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -163,15 +163,21 @@ var _ = Describe("ShareRepository", func() {
|
||||
|
||||
It("denies a non-admin user from deleting another user's share", func() {
|
||||
insertShare("other-share-del", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), otherUser)
|
||||
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Delete("other-share-del")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
|
||||
// The share was not deleted: the owner can still read it.
|
||||
ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
|
||||
ownerRepo := NewShareRepository(ownerCtx, GetDBXBuilder())
|
||||
_, err = ownerRepo.(rest.Repository).Read("other-share-del")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows an admin to delete any user's share", func() {
|
||||
insertShare("admin-del-share", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), adminUser)
|
||||
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Delete("admin-del-share")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -179,7 +185,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
|
||||
It("allows headless context (no user) to delete a share", func() {
|
||||
insertShare("headless-del-share", ownerUser.ID)
|
||||
repo := NewShareRepository(context.Background(), GetDBXBuilder())
|
||||
repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Delete("headless-del-share")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
@ -188,7 +194,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
Describe("Update", func() {
|
||||
It("allows a non-admin user to update their own share", func() {
|
||||
insertShare("own-share-upd", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
|
||||
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("own-share-upd", &model.Share{Description: "Updated"}, "description")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -196,7 +202,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
|
||||
It("denies a non-admin user from updating another user's share", func() {
|
||||
insertShare("other-share-upd", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), otherUser)
|
||||
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("other-share-upd", &model.Share{Description: "Hacked"}, "description")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
@ -204,7 +210,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
|
||||
It("allows an admin to update any user's share", func() {
|
||||
insertShare("admin-upd-share", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), adminUser)
|
||||
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("admin-upd-share", &model.Share{Description: "Admin Updated"}, "description")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -212,10 +218,178 @@ var _ = Describe("ShareRepository", func() {
|
||||
|
||||
It("allows headless context (no user) to update a share", func() {
|
||||
insertShare("headless-upd-share", ownerUser.ID)
|
||||
repo := NewShareRepository(context.Background(), GetDBXBuilder())
|
||||
repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("headless-upd-share", &model.Share{Description: "Headless"}, "description")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns not found when updating a nonexistent share", func() {
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("does-not-exist", &model.Share{Description: "Ghost"}, "description")
|
||||
Expect(err).To(Equal(rest.ErrNotFound))
|
||||
})
|
||||
|
||||
It("updates all columns when no specific columns are given", func() {
|
||||
insertShare("all-cols-share", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
// No cols: the update must write every column, not just updated_at.
|
||||
err := repo.(rest.Persistable).Update("all-cols-share",
|
||||
&model.Share{Description: "All Updated", MaxBitRate: 192, ResourceType: "album", ResourceIDs: "2002"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
got, err := repo.(rest.Repository).Read("all-cols-share")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
share := got.(*model.Share)
|
||||
Expect(share.Description).To(Equal("All Updated"))
|
||||
Expect(share.MaxBitRate).To(Equal(192))
|
||||
Expect(share.ResourceType).To(Equal("album"))
|
||||
})
|
||||
|
||||
It("does not let an owner reassign their share to another user", func() {
|
||||
insertShare("reassign-share", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("reassign-share",
|
||||
&model.Share{UserID: otherUser.ID, Description: "Given away"}, "user_id", "description")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Ownership must not have moved, even though user_id was passed in the body and cols.
|
||||
got, err := repo.(rest.Repository).Read("reassign-share")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.(*model.Share).UserID).To(Equal(ownerUser.ID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Read scoping", func() {
|
||||
BeforeEach(func() {
|
||||
// Persist owner/other users so the JOIN in selectShare resolves.
|
||||
ur := NewUserRepository(ctx, GetDBXBuilder())
|
||||
Expect(ur.Put(&ownerUser)).To(Succeed())
|
||||
Expect(ur.Put(&otherUser)).To(Succeed())
|
||||
|
||||
insertShare("share-owner-1", ownerUser.ID)
|
||||
insertShare("share-owner-2", ownerUser.ID)
|
||||
insertShare("share-other-1", otherUser.ID)
|
||||
})
|
||||
|
||||
Context("non-admin user", func() {
|
||||
var nonAdminRepo model.ShareRepository
|
||||
var nonAdminRest rest.Repository
|
||||
|
||||
BeforeEach(func() {
|
||||
nonAdminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
|
||||
nonAdminRepo = NewShareRepository(nonAdminCtx, GetDBXBuilder())
|
||||
nonAdminRest = nonAdminRepo.(rest.Repository)
|
||||
})
|
||||
|
||||
It("GetAll returns only own shares", func() {
|
||||
shares, err := nonAdminRepo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ids := make([]string, len(shares))
|
||||
for i, s := range shares {
|
||||
ids[i] = s.ID
|
||||
}
|
||||
Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2"))
|
||||
})
|
||||
|
||||
It("ReadAll returns only own shares", func() {
|
||||
res, err := nonAdminRest.ReadAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
shares := res.(model.Shares)
|
||||
ids := make([]string, len(shares))
|
||||
for i, s := range shares {
|
||||
ids[i] = s.ID
|
||||
}
|
||||
Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2"))
|
||||
})
|
||||
|
||||
It("Get returns own share", func() {
|
||||
s, err := nonAdminRepo.Get("share-owner-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.ID).To(Equal("share-owner-1"))
|
||||
})
|
||||
|
||||
It("Get returns ErrNotFound for another user's share", func() {
|
||||
_, err := nonAdminRepo.Get("share-other-1")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("Read returns ErrNotFound for another user's share", func() {
|
||||
_, err := nonAdminRest.Read("share-other-1")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("Exists returns true for own share", func() {
|
||||
exists, err := nonAdminRepo.Exists("share-owner-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exists).To(BeTrue())
|
||||
})
|
||||
|
||||
It("Exists returns false for another user's share", func() {
|
||||
exists, err := nonAdminRepo.Exists("share-other-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exists).To(BeFalse())
|
||||
})
|
||||
|
||||
It("CountAll counts only own shares", func() {
|
||||
count, err := nonAdminRepo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeNumerically("==", 2))
|
||||
})
|
||||
|
||||
It("Count (rest) counts only own shares", func() {
|
||||
count, err := nonAdminRest.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeNumerically("==", 2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("admin user", func() {
|
||||
It("GetAll returns all shares", func() {
|
||||
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
||||
adminRepo := NewShareRepository(adminCtx, GetDBXBuilder())
|
||||
shares, err := adminRepo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ids := make([]string, len(shares))
|
||||
for i, s := range shares {
|
||||
ids[i] = s.ID
|
||||
}
|
||||
Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2", "share-other-1"))
|
||||
})
|
||||
|
||||
It("CountAll counts all shares", func() {
|
||||
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
||||
adminRepo := NewShareRepository(adminCtx, GetDBXBuilder())
|
||||
count, err := adminRepo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeNumerically("==", 3))
|
||||
})
|
||||
})
|
||||
|
||||
Context("headless context (public share route)", func() {
|
||||
It("GetAll returns all shares", func() {
|
||||
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
shares, err := headlessRepo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(shares).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("Get returns another user's share", func() {
|
||||
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
s, err := headlessRepo.Get("share-other-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.ID).To(Equal("share-other-1"))
|
||||
})
|
||||
|
||||
It("Exists returns true for any share", func() {
|
||||
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
exists, err := headlessRepo.Exists("share-other-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exists).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -57,6 +58,33 @@ func loggedUser(ctx context.Context) *model.User {
|
||||
}
|
||||
}
|
||||
|
||||
// ownerFilter returns the predicate restricting access to rows owned by the logged-in user, for
|
||||
// tables with a user_id column. It returns nil for admins and for headless/system contexts (invalid
|
||||
// user), meaning "no ownership restriction". Callers should skip the WHERE clause when it is nil.
|
||||
//
|
||||
// The predicate uses an unqualified user_id, so it only works on queries where that column is
|
||||
// unambiguous (no join introducing a second user_id).
|
||||
func (r sqlRepository) ownerFilter() Sqlizer {
|
||||
if usr := loggedUser(r.ctx); !usr.IsAdmin && usr.ID != invalidUserId {
|
||||
return Eq{"user_id": usr.ID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addRestriction combines an optional caller predicate with the ownership filter, producing the
|
||||
// WHERE clause for owner-scoped reads. For admins and headless contexts ownerFilter() is nil and
|
||||
// only the caller's predicate (if any) remains.
|
||||
func (r sqlRepository) addRestriction(sql ...Sqlizer) Sqlizer {
|
||||
s := And{}
|
||||
if len(sql) > 0 {
|
||||
s = append(s, sql[0])
|
||||
}
|
||||
if owner := r.ownerFilter(); owner != nil {
|
||||
s = append(s, owner)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
|
||||
if r.tableName == "" {
|
||||
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
|
||||
@ -186,15 +214,6 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti
|
||||
return sq
|
||||
}
|
||||
|
||||
func (r *sqlRepository) withTableName(filter filterFunc) filterFunc {
|
||||
return func(field string, value any) Sqlizer {
|
||||
if r.tableName != "" {
|
||||
field = r.tableName + "." + field
|
||||
}
|
||||
return filter(field, value)
|
||||
}
|
||||
}
|
||||
|
||||
// libraryIdFilter is a filter function to be added to resources that have a library_id column.
|
||||
func libraryIdFilter(_ string, value any) Sqlizer {
|
||||
return Eq{"library_id": value}
|
||||
@ -382,6 +401,65 @@ func (r sqlRepository) exists(cond Sqlizer) (bool, error) {
|
||||
return res.Exist > 0, err
|
||||
}
|
||||
|
||||
// updateOwned performs an atomic, ownership-restricted update of the row identified by id, for
|
||||
// repositories whose table has a user_id column. Non-admins can only update rows they own: the
|
||||
// ownership predicate is part of the UPDATE's WHERE clause, so a row owned by another user simply
|
||||
// does not match and no write happens. Ownership itself is immutable here: user_id is never written,
|
||||
// so no caller (admin included) can reassign a row to a different owner via an update. Unlike put,
|
||||
// it never falls through to an INSERT, so a non-matching id never creates a row.
|
||||
//
|
||||
// When the update matches no row it classifies the failure: if the row exists but is owned by
|
||||
// another user it returns rest.ErrPermissionDenied, otherwise rest.ErrNotFound. The write itself is
|
||||
// still atomic; the extra lookup happens only on the failure path (count == 0), where no write
|
||||
// occurred, so there is no TOCTOU on the update.
|
||||
func (r sqlRepository) updateOwned(id string, m any, colsToUpdate ...string) error {
|
||||
values, err := toSQLArgs(m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error preparing values to write to DB: %w", err)
|
||||
}
|
||||
updateValues := filterUpdateValues(values, id, colsToUpdate...)
|
||||
delete(updateValues, "user_id") // ownership is immutable on update
|
||||
update := Update(r.tableName).Where(r.addRestriction(Eq{"id": id})).SetMap(updateValues)
|
||||
count, err := r.executeSQL(update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return r.classifyOwnedWriteMiss(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteOwned performs an atomic, ownership-restricted delete of the row identified by id, for
|
||||
// repositories whose table has a user_id column. Non-admins can only delete rows they own: the
|
||||
// ownership predicate is part of the DELETE's WHERE clause, so a row owned by another user simply
|
||||
// does not match and is left untouched. The failure path mirrors updateOwned (see
|
||||
// classifyOwnedWriteMiss), so there is no TOCTOU on the delete.
|
||||
func (r sqlRepository) deleteOwned(id string) error {
|
||||
count, err := r.executeSQL(Delete(r.tableName).Where(r.addRestriction(Eq{"id": id})))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return r.classifyOwnedWriteMiss(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyOwnedWriteMiss explains why an ownership-filtered write (updateOwned/deleteOwned) matched
|
||||
// no row: rest.ErrPermissionDenied if the row exists but is owned by another user, otherwise
|
||||
// rest.ErrNotFound. It runs only on the failure path (count == 0), where no write occurred.
|
||||
func (r sqlRepository) classifyOwnedWriteMiss(id string) error {
|
||||
exists, err := r.exists(Eq{"id": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
|
||||
func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) {
|
||||
countQuery = countQuery.
|
||||
RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count").
|
||||
@ -408,6 +486,30 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate
|
||||
return r.put(res.ID, m, colsToUpdate...)
|
||||
}
|
||||
|
||||
// filterUpdateValues selects, from a marshaled column map, the values to write in an UPDATE on the
|
||||
// row identified by id: only the requested colsToUpdate (or all columns when none are specified),
|
||||
// dropping columns that must never be overwritten on update (created_at, birth_time).
|
||||
func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any {
|
||||
updateValues := map[string]any{}
|
||||
|
||||
// This is a map of the columns that need to be updated, if specified
|
||||
c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) {
|
||||
return toSnakeCase(s), struct{}{}
|
||||
})
|
||||
for k, v := range values {
|
||||
if _, found := c2upd[k]; len(c2upd) == 0 || found {
|
||||
updateValues[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
updateValues["id"] = id
|
||||
delete(updateValues, "created_at")
|
||||
// To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now
|
||||
// TODO move to mediafile_repository when each repo has its own upsert method
|
||||
delete(updateValues, "birth_time")
|
||||
return updateValues
|
||||
}
|
||||
|
||||
func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) {
|
||||
values, err := toSQLArgs(m)
|
||||
if err != nil {
|
||||
@ -415,24 +517,7 @@ func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId stri
|
||||
}
|
||||
// If there's an ID, try to update first
|
||||
if id != "" {
|
||||
updateValues := map[string]any{}
|
||||
|
||||
// This is a map of the columns that need to be updated, if specified
|
||||
c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) {
|
||||
return toSnakeCase(s), struct{}{}
|
||||
})
|
||||
for k, v := range values {
|
||||
if _, found := c2upd[k]; len(c2upd) == 0 || found {
|
||||
updateValues[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
updateValues["id"] = id
|
||||
delete(updateValues, "created_at")
|
||||
// To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now
|
||||
// TODO move to mediafile_repository when each repo has its own upsert method
|
||||
delete(updateValues, "birth_time")
|
||||
update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues)
|
||||
update := Update(r.tableName).Where(Eq{"id": id}).SetMap(filterUpdateValues(values, id, colsToUpdate...))
|
||||
count, err := r.executeSQL(update)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@ -46,7 +46,7 @@ func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.Query
|
||||
continue
|
||||
}
|
||||
// Default to a "starts with" filter
|
||||
filters = append(filters, startsWithFilter(f, v))
|
||||
filters = append(filters, Like{f: fmt.Sprintf("%s%%", v)})
|
||||
}
|
||||
return filters
|
||||
}
|
||||
@ -91,8 +91,10 @@ func eqFilter(field string, value any) Sqlizer {
|
||||
return Eq{field: value}
|
||||
}
|
||||
|
||||
func startsWithFilter(field string, value any) Sqlizer {
|
||||
return Like{field: fmt.Sprintf("%s%%", value)}
|
||||
func startsWithFilter(field string) func(string, any) Sqlizer {
|
||||
return func(_ string, value any) Sqlizer {
|
||||
return Like{field: fmt.Sprintf("%s%%", value)}
|
||||
}
|
||||
}
|
||||
|
||||
func containsFilter(field string) func(string, any) Sqlizer {
|
||||
|
||||
@ -53,14 +53,29 @@ func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, erro
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Read(id string) (any, error) {
|
||||
return r.Get(id)
|
||||
res, err := r.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !loggedUser(r.ctx).IsAdmin {
|
||||
res.Command = ""
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
|
||||
res := model.Transcodings{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !loggedUser(r.ctx).IsAdmin {
|
||||
for i := range res {
|
||||
res[i].Command = ""
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) EntityName() string {
|
||||
|
||||
@ -64,9 +64,69 @@ var _ = Describe("TranscodingRepository", func() {
|
||||
_, err = adminRepo.Get("to-delete")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("reads the Command field via the REST Read method", func() {
|
||||
tr := &model.Transcoding{ID: "adminread", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
res, err := adminRepo.(*transcodingRepository).Read("adminread")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.(*model.Transcoding).Command).To(Equal("ffmpeg -secret"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Regular User", func() {
|
||||
It("reads a transcoding but with the Command field redacted", func() {
|
||||
tr := &model.Transcoding{ID: "readreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
res, err := repo.(*transcodingRepository).Read("readreg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
t := res.(*model.Transcoding)
|
||||
Expect(t.Name).To(Equal("temp"))
|
||||
Expect(t.TargetFormat).To(Equal("test_format"))
|
||||
Expect(t.Command).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("lists transcodings but with the Command field redacted", func() {
|
||||
tr := &model.Transcoding{ID: "listreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
res, err := repo.(*transcodingRepository).ReadAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
list := res.(model.Transcodings)
|
||||
Expect(list).ToNot(BeEmpty())
|
||||
for _, t := range list {
|
||||
Expect(t.Command).To(BeEmpty())
|
||||
}
|
||||
})
|
||||
|
||||
It("counts transcodings", func() {
|
||||
count, err := repo.(*transcodingRepository).Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeNumerically(">=", 0))
|
||||
})
|
||||
|
||||
It("can still resolve a transcoding for streaming via Get (Command not redacted)", func() {
|
||||
tr := &model.Transcoding{ID: "streamreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
res, err := repo.Get("streamreg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.ID).To(Equal("streamreg"))
|
||||
Expect(res.Command).To(Equal("ffmpeg -secret"))
|
||||
})
|
||||
|
||||
It("can still resolve a transcoding for streaming via FindByFormat (Command not redacted)", func() {
|
||||
tr := &model.Transcoding{ID: "fmtreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
res, err := repo.FindByFormat("test_format")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.ID).To(Equal("fmtreg"))
|
||||
Expect(res.Command).To(Equal("ffmpeg -secret"))
|
||||
})
|
||||
|
||||
It("fails to create", func() {
|
||||
err := repo.Put(&model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"})
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
|
||||
@ -59,7 +59,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
|
||||
r.registerModel(&model.User{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"password": invalidFilter(ctx),
|
||||
"name": r.withTableName(startsWithFilter),
|
||||
"name": startsWithFilter(r.tableName + ".name"),
|
||||
})
|
||||
once.Do(func() {
|
||||
_ = r.initPasswordEncryptionKey()
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -207,6 +208,52 @@ var _ = Describe("UserRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReadAll name filter", func() {
|
||||
var adminRepo model.ResourceRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin-id", UserName: "admin", IsAdmin: true})
|
||||
adminRepo = NewUserRepository(adminCtx, GetDBXBuilder()).(model.ResourceRepository)
|
||||
|
||||
for _, u := range []model.User{
|
||||
{ID: "filter-alice", UserName: "alice_filter", Name: "Alice Filter", NewPassword: "x"},
|
||||
{ID: "filter-bob", UserName: "bob_filter", Name: "Bob Filter", NewPassword: "x"},
|
||||
} {
|
||||
Expect(adminRepo.(model.UserRepository).Put(&u)).To(Succeed())
|
||||
}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
ur := adminRepo.(model.UserRepository)
|
||||
_ = ur.Delete("filter-alice")
|
||||
_ = ur.Delete("filter-bob")
|
||||
})
|
||||
|
||||
It("matches users whose name starts with the given prefix", func() {
|
||||
res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Alice"}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
users := res.(model.Users)
|
||||
|
||||
var names []string
|
||||
for _, u := range users {
|
||||
names = append(names, u.Name)
|
||||
}
|
||||
Expect(names).To(ContainElement("Alice Filter"))
|
||||
Expect(names).ToNot(ContainElement("Bob Filter"))
|
||||
})
|
||||
|
||||
It("does not match names by mid-string substring (startsWith, not contains)", func() {
|
||||
res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Filter"}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
users := res.(model.Users)
|
||||
|
||||
for _, u := range users {
|
||||
Expect(u.ID).ToNot(Or(Equal("filter-alice"), Equal("filter-bob")),
|
||||
"a mid-string substring should not match a startsWith filter")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("validateUsernameUnique", func() {
|
||||
var repo *tests.MockedUserRepo
|
||||
var existingUser *model.User
|
||||
|
||||
@ -114,7 +114,7 @@ release:
|
||||
## Where to go next?
|
||||
|
||||
* Read installation instructions on our [website](https://www.navidrome.org/docs/installation/).
|
||||
* Host Navidrome on [PikaPods](https://www.pikapods.com/pods/navidrome) for a simple cloud solution.
|
||||
* Host Navidrome on [PikaPods](https://www.pikapods.com/pods/navidrome) or [Danian](https://danian.co/navidrome?nd) for a simple cloud solution.
|
||||
* Reach out on [Discord](https://discord.gg/xh7j7yF), [Reddit](https://www.reddit.com/r/navidrome/) and [Twitter](https://twitter.com/navidrome)!
|
||||
|
||||
# Add the MSI installers to the release
|
||||
|
||||
@ -38,7 +38,9 @@
|
||||
"missing": "Fehlend",
|
||||
"libraryName": "Bibliothek",
|
||||
"composer": "Komponist",
|
||||
"disc": "Disc %{discNumber}"
|
||||
"disc": "Disc %{discNumber}",
|
||||
"albumGain": "Album Gain",
|
||||
"trackGain": "Titel Gain"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Später abspielen",
|
||||
|
||||
@ -35,6 +35,8 @@
|
||||
"rawTags": "Etiquetas sin procesar",
|
||||
"bitDepth": "Profundidad de bits",
|
||||
"sampleRate": "Frecuencia de muestreo",
|
||||
"albumGain": "Ganancia del álbum",
|
||||
"trackGain": "Ganancia de pista",
|
||||
"missing": "Faltante",
|
||||
"libraryName": "Biblioteca",
|
||||
"composer": "Compositor",
|
||||
@ -693,7 +695,7 @@
|
||||
"quickScan": "Escaneo rápido",
|
||||
"fullScan": "Escaneo completo",
|
||||
"serverUptime": "Uptime del servidor",
|
||||
"serverDown": "OFFLINE",
|
||||
"serverDown": "DESCONECTADO",
|
||||
"scanType": "Tipo",
|
||||
"status": "Error de escaneo",
|
||||
"elapsedTime": "Tiempo transcurrido",
|
||||
|
||||
@ -154,12 +154,12 @@
|
||||
"currentPassword": "Senine salasõna",
|
||||
"newPassword": "Uus salasõna",
|
||||
"token": "Tunnusluba",
|
||||
"lastAccessAt": "Viimasti avatud",
|
||||
"lastAccessAt": "Viimati avatud",
|
||||
"libraries": "Kogumikud"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Sinu nime muudatused on näha järgmisel sisselogimisel",
|
||||
"libraries": "Vali selle kasutaja jaoks konkreetsed kogumikus või jäta vaikimisi väärtuse kasutamiseks tühjaks"
|
||||
"libraries": "Vali selle kasutaja jaoks konkreetsed kogumikud või jäta vaikimisi väärtuse kasutamiseks tühjaks"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Kasutaja on lisatud",
|
||||
@ -413,10 +413,10 @@
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Aitäh, et paigaldasite Navidrome'i!",
|
||||
"welcome1": "Aitäh, et paigaldasid Navidrome'i!",
|
||||
"welcome2": "Alustamiseks lisa peakasutaja",
|
||||
"confirmPassword": "Korda salasõna",
|
||||
"buttonCreateAdmin": "Loo admin",
|
||||
"buttonCreateAdmin": "Lisa peakasutaja",
|
||||
"auth_check_error": "Jätkamiseks palun logi sisse",
|
||||
"user_menu": "Profiil",
|
||||
"username": "Kasutajanimi",
|
||||
@ -427,7 +427,7 @@
|
||||
"insightsCollectionNote": "Navidrome kogub anonüümset kasutustusstatistikat, mille alusel on võimalik projekti paremaks muuta. Klõpsides [siin], saad lugeda lisateavet ning soovi korral sellest kogumisest loobuda"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Palun kasutage ainult tähti ja numbreid",
|
||||
"invalidChars": "Palun kasuta ainult tähti ja numbreid",
|
||||
"passwordDoesNotMatch": "Salasõnad ei kattu",
|
||||
"required": "Nõutav",
|
||||
"minLength": "Pikkus peab olema vähemalt %{min} tähemärki",
|
||||
@ -558,8 +558,8 @@
|
||||
},
|
||||
"message": {
|
||||
"note": "MÄRGE",
|
||||
"transcodingDisabled": "Transkodeeringu seadistuse muutmine läbi veebiliidese ei ole turvariskide tõttu saadaval. Kui soovite muuta või lisada transkodeerimisega seotud seadistusi, taaskäivitage server %{config} valikuga.",
|
||||
"transcodingEnabled": "Navidrome käivitati %{config} valikuga, mis lubab läbi veebiliidese transkodeerimisseadistuste jooksutada süsteemikäsklusi. Turvakaalutlustel on soovitatav kasutada seda valikut ainult transkodeerimisseadete muutmiseks.",
|
||||
"transcodingDisabled": "Teisendusseadistuste muutmine läbi veebiliidese ei ole turvariskide tõttu saadaval. Kui soovid muuta või lisada teisendamisega seotud seadistusi, taaskäivita server %{config} valikuga.",
|
||||
"transcodingEnabled": "Navidrome käivitati %{config} valikuga, mis lubab läbi veebiliidese teisendusseadistuste käivitada süsteemikäsklusi. Turvakaalutlustel on soovitatav kasutada seda valikut ainult teisendusvalikute muutmiseks.",
|
||||
"songsAddedToPlaylist": "Lisasin ühe loo esitusloendisse |||| Lisasin %{smart_count} lugu esitusloendisse",
|
||||
"noPlaylistsAvailable": "Pole saadaval",
|
||||
"delete_user_title": "Kustuta kasutaja „%{name}“",
|
||||
@ -603,13 +603,13 @@
|
||||
},
|
||||
"menu": {
|
||||
"library": "Kogumik",
|
||||
"settings": "Seaded",
|
||||
"settings": "Seadistused",
|
||||
"version": "Versioon",
|
||||
"theme": "Teema",
|
||||
"theme": "Kujundus",
|
||||
"personal": {
|
||||
"name": "Isiklik",
|
||||
"options": {
|
||||
"theme": "Teema",
|
||||
"theme": "Kujundus",
|
||||
"language": "Keel",
|
||||
"defaultView": "Vaikimisi vaade",
|
||||
"desktop_notifications": "Teavitused töölaual",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"languageName": "Euskara",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Abestia |||| Abesti",
|
||||
"name": "Abestia |||| Abestiak",
|
||||
"fields": {
|
||||
"albumArtist": "Albumaren artista",
|
||||
"duration": "Iraupena",
|
||||
@ -22,6 +22,8 @@
|
||||
"bitRate": "Bit-tasa",
|
||||
"bitDepth": "Bit-sakonera",
|
||||
"sampleRate": "Lagin-tasa",
|
||||
"albumGain": "Album-irabazia",
|
||||
"trackGain": "Pista-irabazia",
|
||||
"channels": "Kanalak",
|
||||
"disc": "%{discNumber}. diskoa",
|
||||
"discSubtitle": "Diskoaren azpititulua",
|
||||
@ -53,7 +55,7 @@
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Albuma |||| Album",
|
||||
"name": "Albuma |||| Albumak",
|
||||
"fields": {
|
||||
"albumArtist": "Albumaren artista",
|
||||
"artist": "Artista",
|
||||
@ -104,7 +106,7 @@
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artista |||| Artista",
|
||||
"name": "Artista |||| Artistak",
|
||||
"fields": {
|
||||
"name": "Izena",
|
||||
"albumCount": "Album kopurua",
|
||||
@ -117,7 +119,7 @@
|
||||
"missing": "Ez da aurkitu"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albumeko egilea |||| Albumeko artistak",
|
||||
"albumartist": "Albumeko artista |||| Albumeko artistak",
|
||||
"artist": "Artista |||| Artistak",
|
||||
"composer": "Konpositorea |||| Konpositoreak",
|
||||
"conductor": "Orkestra zuzendaria |||| Orkestra zuzendariak",
|
||||
@ -335,7 +337,7 @@
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Plugina |||| Plugin",
|
||||
"name": "Plugina |||| Pluginak",
|
||||
"fields": {
|
||||
"id": "IDa",
|
||||
"name": "Izena",
|
||||
@ -492,7 +494,7 @@
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Jaregin edo hautatu igo nahi dituzun fitxategiak.",
|
||||
"upload_single": "AJaregin edo hautatu igo nahi duzun fitxategia."
|
||||
"upload_single": "Jaregin edo hautatu igo nahi duzun fitxategia."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Jaregin edo hautatu igo nahi dituzun irudiak.",
|
||||
@ -537,9 +539,9 @@
|
||||
"skip_nav": "Joan edukira"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Elementu bat eguneratu da |||| %{smart_count} elementu eguneratu dira",
|
||||
"updated": "Elementua eguneratu da |||| %{smart_count} elementu eguneratu dira",
|
||||
"created": "Elementua sortu da",
|
||||
"deleted": "Elementu bat ezabatu da |||| %{smart_count} elementu ezabatu dira.",
|
||||
"deleted": "Elementua ezabatu da |||| %{smart_count} elementu ezabatu dira.",
|
||||
"bad_item": "Elementu okerra",
|
||||
"item_doesnt_exist": "Elementua ez dago",
|
||||
"http_error": "Errorea zerbitzariarekin komunikatzerakoan",
|
||||
@ -588,7 +590,7 @@
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
|
||||
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
|
||||
"openIn": {
|
||||
"lastfm": "Ikusi Last.fm-n",
|
||||
"lastfm": "Ikusi Last.fm-en",
|
||||
"musicbrainz": "Ikusi MusicBrainz-en"
|
||||
},
|
||||
"lastfmLink": "Irakurri gehiago…",
|
||||
|
||||
@ -38,7 +38,9 @@
|
||||
"missing": "Puuttuva",
|
||||
"libraryName": "Kirjasto",
|
||||
"composer": "Säveltäjä",
|
||||
"disc": "Levy %{discNumber}"
|
||||
"disc": "Levy %{discNumber}",
|
||||
"albumGain": "Albumin äänenvoimakkuus",
|
||||
"trackGain": "Kappaleen äänenvoimakkuus"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lisää jonoon",
|
||||
|
||||
@ -38,7 +38,9 @@
|
||||
"missing": "Falta",
|
||||
"libraryName": "Biblioteca",
|
||||
"composer": "Composición",
|
||||
"disc": "Disco %{discNumber}"
|
||||
"disc": "Disco %{discNumber}",
|
||||
"albumGain": "Gañancia de Album",
|
||||
"trackGain": "Gañancia de Canción"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ao final da cola",
|
||||
|
||||
@ -37,7 +37,10 @@
|
||||
"sampleRate": "Sample rate",
|
||||
"missing": "Hilang",
|
||||
"libraryName": "Pustaka",
|
||||
"composer": "Komposer"
|
||||
"composer": "Komposer",
|
||||
"disc": "Disk %{discNumber}",
|
||||
"albumGain": "Album gain",
|
||||
"trackGain": "Trek gain"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Tambah ke antrean",
|
||||
@ -353,7 +356,8 @@
|
||||
"allUsers": "Izinkan semua pengguna",
|
||||
"selectedUsers": "Pengguna yang dipilih",
|
||||
"allLibraries": "Izinkan semua pustaka",
|
||||
"selectedLibraries": "Pustaka dipilih"
|
||||
"selectedLibraries": "Pustaka dipilih",
|
||||
"allowWriteAccess": "Izinkan akses tulis"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
@ -398,7 +402,8 @@
|
||||
"librariesRequired": "Plugin ini membutuhkan akses ke informasi pustaka. Pilih beberapa pustaka yang bisa diakses, atau aktifkan 'Izinkan semua pustaka'.",
|
||||
"requiredHosts": "Hosts diperlukan",
|
||||
"configValidationError": "Validasi konfigurasi gagal:",
|
||||
"schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid."
|
||||
"schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid.",
|
||||
"allowWriteAccessHelp": "Ketika diaktifkan, plugin dapat mengubah file di direktori pustaka. Bawaannya, plugin hanya memiliki akses read-only"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "key",
|
||||
@ -588,7 +593,13 @@
|
||||
"remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka.",
|
||||
"noSimilarSongsFound": "Tidak ada lagu yang serupa ditemukan",
|
||||
"noTopSongsFound": "Tidak ada lagu teratas ditemukan",
|
||||
"startingInstantMix": "Memuat Mix Instan..."
|
||||
"startingInstantMix": "Memuat Mix Instan...",
|
||||
"uploadCover": "Unggah Sampul",
|
||||
"removeCover": "Hapus Sampul",
|
||||
"coverUploaded": "Sampul diperbarui",
|
||||
"coverRemoved": "Sampul dihapus",
|
||||
"coverUploadError": "Kesalahan mengunggah sampul",
|
||||
"coverRemoveError": "Kesalahan menghapus sampul"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Pustaka",
|
||||
@ -674,7 +685,8 @@
|
||||
"exportSuccess": "Konfigurasi sudah diekspor ke papan klip dalam bentuk format TOML",
|
||||
"exportFailed": "Gagal menyalin konfigurasi",
|
||||
"devFlagsHeader": "Flag Pengembangan (subyek untuk perubahan/pemindahan)",
|
||||
"devFlagsComment": "Ini adalan pengaturan eksperimen dan mungkin akan dihapus di versi mendatang"
|
||||
"devFlagsComment": "Ini adalan pengaturan eksperimen dan mungkin akan dihapus di versi mendatang",
|
||||
"downloadToml": "Unduh Konfigurasi (TOML)"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@ -38,7 +38,9 @@
|
||||
"missing": "Ontbrekend",
|
||||
"libraryName": "Bibliotheek",
|
||||
"composer": "Componist",
|
||||
"disc": "Schijf %{discNumber}"
|
||||
"disc": "Schijf %{discNumber}",
|
||||
"albumGain": "Album gain",
|
||||
"trackGain": "Nummer gain"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Voeg toe aan wachtrij",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"languageName": "Slovenčina",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Skladba |||| Skladieb",
|
||||
"name": "Skladba |||| Skladby",
|
||||
"fields": {
|
||||
"albumArtist": "Interpret albumu",
|
||||
"duration": "Dĺžka",
|
||||
@ -10,20 +10,14 @@
|
||||
"playCount": "Počet prehratí",
|
||||
"title": "Názov",
|
||||
"artist": "Interpret",
|
||||
"composer": "Skladateľ",
|
||||
"album": "Album",
|
||||
"path": "Cesta k súboru",
|
||||
"libraryName": "Knižnica",
|
||||
"genre": "Žáner",
|
||||
"compilation": "Kompilácia",
|
||||
"year": "Rok",
|
||||
"size": "Veľkosť súboru",
|
||||
"updatedAt": "Nahrané",
|
||||
"bitRate": "Prenosová rýchlosť",
|
||||
"bitDepth": "Bitová hĺbka",
|
||||
"sampleRate": "Vzorkovacia frekvencia",
|
||||
"channels": "Kanály",
|
||||
"disc": "Disk %{discNumber}",
|
||||
"discSubtitle": "Podtitul disku",
|
||||
"starred": "Obľúbené",
|
||||
"comment": "Komentár",
|
||||
@ -31,6 +25,7 @@
|
||||
"quality": "Kvalita",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Naposledy prehraná skladba",
|
||||
"channels": "Kanály",
|
||||
"createdAt": "Pridané",
|
||||
"grouping": "Zoskupovanie",
|
||||
"mood": "Nálada",
|
||||
@ -38,17 +33,24 @@
|
||||
"tags": "Ďalšie značky",
|
||||
"mappedTags": "Mapované značky",
|
||||
"rawTags": "Nespracované značky",
|
||||
"missing": "Chýbajúce"
|
||||
"bitDepth": "Bitová hĺbka",
|
||||
"sampleRate": "Vzorkovacia frekvencia",
|
||||
"missing": "Chýbajúce",
|
||||
"libraryName": "Knižnica",
|
||||
"composer": "Skladateľ",
|
||||
"disc": "Disk %{discNumber}",
|
||||
"albumGain": "Zosilnenie albumu",
|
||||
"trackGain": "Zosilnenie stopy"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Prehrať neskôr",
|
||||
"playNow": "Prehrať teraz",
|
||||
"addToPlaylist": "Pridať do zoznamu skladieb",
|
||||
"showInPlaylist": "Zobraziť v zozname skladieb",
|
||||
"shuffleAll": "Zamiešať všetko",
|
||||
"download": "Stiahnuť",
|
||||
"playNext": "Prehrať ako ďalšie",
|
||||
"info": "Získať informácie",
|
||||
"showInPlaylist": "Zobraziť v zozname skladieb",
|
||||
"instantMix": "Okamžitý mix"
|
||||
}
|
||||
},
|
||||
@ -60,38 +62,38 @@
|
||||
"duration": "Dĺžka",
|
||||
"songCount": "Skladby",
|
||||
"playCount": "Počet prehratí",
|
||||
"size": "Veľkosť",
|
||||
"name": "Názov",
|
||||
"libraryName": "Knižnica",
|
||||
"genre": "Žáner",
|
||||
"compilation": "Kompilácia",
|
||||
"year": "Rok",
|
||||
"date": "Dátum záznamu",
|
||||
"originalDate": "Pôvodné",
|
||||
"releaseDate": "Vydané",
|
||||
"releases": "Vydanie |||| Vydania",
|
||||
"released": "Vydané",
|
||||
"updatedAt": "Aktualizované",
|
||||
"comment": "Komentár",
|
||||
"rating": "Hodnotenie",
|
||||
"createdAt": "Pridané",
|
||||
"size": "Veľkosť",
|
||||
"originalDate": "Pôvodné",
|
||||
"releaseDate": "Vydané",
|
||||
"releases": "Vydanie |||| Vydania",
|
||||
"released": "Vydané",
|
||||
"recordLabel": "Štítok",
|
||||
"catalogNum": "Katalógové číslo",
|
||||
"releaseType": "Typ vydania",
|
||||
"grouping": "Zoskupovanie",
|
||||
"media": "Médiá",
|
||||
"mood": "Nálada",
|
||||
"missing": "Chýbajúce"
|
||||
"date": "Dátum záznamu",
|
||||
"missing": "Chýbajúce",
|
||||
"libraryName": "Knižnica"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Prehrať",
|
||||
"playNext": "Prehrať ako ďalšie",
|
||||
"addToQueue": "Prehrať neskôr",
|
||||
"share": "Zdieľať",
|
||||
"shuffle": "Zamiešať",
|
||||
"addToPlaylist": "Pridať do zoznamu skladieb",
|
||||
"download": "Stiahnuť",
|
||||
"info": "Získať informácie"
|
||||
"info": "Získať informácie",
|
||||
"share": "Zdieľať"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Všetko",
|
||||
@ -109,10 +111,10 @@
|
||||
"name": "Názov",
|
||||
"albumCount": "Počet albumov",
|
||||
"songCount": "Počet skladieb",
|
||||
"size": "Veľkosť",
|
||||
"playCount": "Prehrania",
|
||||
"rating": "Hodnotenie",
|
||||
"genre": "Žáner",
|
||||
"size": "Veľkosť",
|
||||
"role": "Rola",
|
||||
"missing": "Chýbajúci"
|
||||
},
|
||||
@ -133,9 +135,9 @@
|
||||
"maincredit": "Interpret albumu alebo interpret |||| Interpreti albumov alebo interpreti"
|
||||
},
|
||||
"actions": {
|
||||
"topSongs": "Najpopulárnejšie skladby",
|
||||
"shuffle": "Zamiešať",
|
||||
"radio": "Rádio"
|
||||
"radio": "Rádio",
|
||||
"topSongs": "Najpopulárnejšie skladby"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@ -144,7 +146,6 @@
|
||||
"userName": "Používateľské meno",
|
||||
"isAdmin": "Správca",
|
||||
"lastLoginAt": "Naposledy prihlásený",
|
||||
"lastAccessAt": "Posledný Prístup",
|
||||
"updatedAt": "Upravený",
|
||||
"name": "Meno",
|
||||
"password": "Heslo",
|
||||
@ -153,6 +154,7 @@
|
||||
"currentPassword": "Súčastné heslo",
|
||||
"newPassword": "Nové heslo",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Posledný Prístup",
|
||||
"libraries": "Knižnice"
|
||||
},
|
||||
"helperTexts": {
|
||||
@ -164,14 +166,14 @@
|
||||
"updated": "Používateľ upravený",
|
||||
"deleted": "Používateľ odstránený"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Vložte svoj používateľský ListenBrainz token.",
|
||||
"clickHereForToken": "Kliknite sem pre získanie svojho tokenu",
|
||||
"selectAllLibraries": "Vybrať všetky knižnice",
|
||||
"adminAutoLibraries": "Administrátori majú automaticky prístup ku všetkým knižniciam"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@ -214,9 +216,9 @@
|
||||
"selectPlaylist": "Vybrať zoznam skladieb:",
|
||||
"addNewPlaylist": "Vytvoriť \"%{name}\"",
|
||||
"export": "Export",
|
||||
"saveQueue": "Uložiť rad do zoznamu skladieb",
|
||||
"makePublic": "Zverejniť",
|
||||
"makePrivate": "Nastaviť ako súkromné",
|
||||
"saveQueue": "Uložiť rad do zoznamu skladieb",
|
||||
"searchOrCreate": "Vyhľadajte zoznamy skladieb alebo napíšte pre vytvorenie nového...",
|
||||
"pressEnterToCreate": "Stlačte Enter pre vytvorenie nového zoznamu skladieb",
|
||||
"removeFromSelection": "Odstrániť z výberu"
|
||||
@ -247,7 +249,6 @@
|
||||
"username": "Zdieľané",
|
||||
"url": "URL",
|
||||
"description": "Popis",
|
||||
"downloadable": "Povoliť sťahovanie?",
|
||||
"contents": "Obsah",
|
||||
"expiresAt": "Vyprší",
|
||||
"lastVisitedAt": "Naposledy navštívené",
|
||||
@ -255,19 +256,17 @@
|
||||
"format": "Formát",
|
||||
"maxBitRate": "Max. Bit Rate",
|
||||
"updatedAt": "Nahrané",
|
||||
"createdAt": "Vytvorené"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
"createdAt": "Vytvorené",
|
||||
"downloadable": "Povoliť sťahovanie?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Chýbajúci súbor |||| Chýbajúce súbory",
|
||||
"empty": "Žiadne chýbajúce súbory",
|
||||
"fields": {
|
||||
"path": "Cesta",
|
||||
"size": "Veľkosť",
|
||||
"libraryName": "Knižnica",
|
||||
"updatedAt": "Zmizol dňa"
|
||||
"updatedAt": "Zmizol dňa",
|
||||
"libraryName": "Knižnica"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Odstrániť",
|
||||
@ -275,7 +274,8 @@
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Chýbajúce súbory odstránené"
|
||||
}
|
||||
},
|
||||
"empty": "Žiadne chýbajúce súbory"
|
||||
},
|
||||
"library": {
|
||||
"name": "Knižnica |||| Knižnice",
|
||||
@ -305,20 +305,20 @@
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Skenovať knižnicu",
|
||||
"quickScan": "Rýchly sken",
|
||||
"fullScan": "Úplný sken",
|
||||
"manageUsers": "Spravovať prístup používateľov",
|
||||
"viewDetails": "Zobraziť detaily"
|
||||
"viewDetails": "Zobraziť detaily",
|
||||
"quickScan": "Rýchly sken",
|
||||
"fullScan": "Úplný sken"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Knižnica úspešne vytvorená",
|
||||
"updated": "Knižnica úspešne aktualizovaná",
|
||||
"deleted": "Knižnica úspešne odstránená",
|
||||
"scanStarted": "Skenovanie knižnice spustené",
|
||||
"scanCompleted": "Skenovanie knižnice dokončené",
|
||||
"quickScanStarted": "Rýchly sken spustený",
|
||||
"fullScanStarted": "Úplný sken spustený",
|
||||
"scanError": "Chyba pri spustení skenu. Skontrolujte logy",
|
||||
"scanCompleted": "Skenovanie knižnice dokončené"
|
||||
"scanError": "Chyba pri spustení skenu. Skontrolujte logy"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Názov knižnice je povinný",
|
||||
@ -391,8 +391,6 @@
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Nakonfigurujte plugin pomocou párov kľúč-hodnota. Nechajte prázdne, ak plugin nevyžaduje žiadnu konfiguráciu.",
|
||||
"configValidationError": "Overenie konfigurácie zlyhalo:",
|
||||
"schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.",
|
||||
"clickPermissions": "Kliknite na oprávnenie pre detaily",
|
||||
"noConfig": "Žiadna konfigurácia nastavená",
|
||||
"allUsersHelp": "Keď je povolené, plugin bude mať prístup ku všetkým používateľom, vrátane tých vytvorených v budúcnosti.",
|
||||
@ -402,8 +400,10 @@
|
||||
"allLibrariesHelp": "Keď je povolené, plugin bude mať prístup ku všetkým knižniciam, vrátane tých vytvorených v budúcnosti.",
|
||||
"noLibraries": "Žiadne knižnice nevybrané",
|
||||
"librariesRequired": "Tento plugin vyžaduje prístup k informáciám o knižniciach. Vyberte, ku ktorým knižniciam má plugin prístup, alebo povolte 'Povoliť všetky knižnice'.",
|
||||
"allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie.",
|
||||
"requiredHosts": "Požadovaní hostitelia"
|
||||
"requiredHosts": "Požadovaní hostitelia",
|
||||
"configValidationError": "Overenie konfigurácie zlyhalo:",
|
||||
"schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.",
|
||||
"allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "kľúč",
|
||||
@ -446,7 +446,6 @@
|
||||
"add": "Pridať",
|
||||
"back": "Ísť späť",
|
||||
"bulk_actions": "1 vybraná |||| %{smart_count} vybraných",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "Zrušiť",
|
||||
"clear_input_value": "Vymazať hodnotu",
|
||||
"clone": "Klonovať",
|
||||
@ -470,6 +469,7 @@
|
||||
"close_menu": "Zavrieť ponuku",
|
||||
"unselect": "Zrušiť výber",
|
||||
"skip": "Preskočiť",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Zdieľať",
|
||||
"download": "Stiahnuť"
|
||||
},
|
||||
@ -557,58 +557,52 @@
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"uploadCover": "Nahrať obrázok obalu",
|
||||
"removeCover": "Odstrániť obrázok obalu",
|
||||
"coverUploaded": "Obrázok obalu albumu aktualizovaný",
|
||||
"coverRemoved": "Obrázok obalu albumu odstránený",
|
||||
"coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu",
|
||||
"coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu",
|
||||
"note": "POZNÁMKA",
|
||||
"transcodingDisabled": "Zmena nastavení transkódovania je vo webovom prostredí vypnutá z bezpečnostných dôvodov. Ak chcete zmeniť (upraviť alebo pridať) možnosti transkódovania, reštartujte server s možnosťou %{config}.",
|
||||
"transcodingEnabled": "Navidrome práve beží s možnosťou %{config}, ktorá umožňuje spúšťanie systémových príkazov z nastavení transkódovania pomocou webového rozhrania. Odporúčame ju vypnúť z bezpečnostných dôvodov a používať ju iba pri úprave nastavení transkódovania.",
|
||||
"songsAddedToPlaylist": "1 skladba pridaná do zoznamu skladieb |||| %{smart_count} skladieb pridaných do zoznamu skladieb",
|
||||
"noSimilarSongsFound": "Nenašli sa žiadne podobné skladby",
|
||||
"startingInstantMix": "Načítava sa Instant Mix...",
|
||||
"noTopSongsFound": "Nenašli sa žiadne top skladby",
|
||||
"noPlaylistsAvailable": "Žiadne nie sú dostupné",
|
||||
"delete_user_title": "Odstrániť používateľa '%{name}'",
|
||||
"delete_user_content": "Ste si istí, že chcete odstrániť tohto používateľa a všetky jeho dáta (vrátane zoznamov skladieb a nastavení)?",
|
||||
"remove_missing_title": "Odstráňte chýbajúce súbory",
|
||||
"remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.",
|
||||
"remove_all_missing_title": "Odstráňte všetky chýbajúce súbory",
|
||||
"remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.",
|
||||
"notifications_blocked": "Zablokovali ste si oznámenia pre túto stránku v nastaveniach vášho prehliadača",
|
||||
"notifications_not_available": "Tento prehliadač nepodporuje oznámenia na ploche alebo nepristupujete k Navidrome cez https",
|
||||
"lastfmLinkSuccess": "Last.fm úspešne pripojené a scrobbling zapnutý",
|
||||
"lastfmLinkFailure": "Last.fm sa nepodarilo pripojiť",
|
||||
"lastfmUnlinkSuccess": "Last.fm odpojené a scrobbling vypnutý",
|
||||
"lastfmUnlinkFailure": "Last.fm sa nepodarilo odpojiť",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť",
|
||||
"openIn": {
|
||||
"lastfm": "Otvoriť na Last.fm",
|
||||
"musicbrainz": "Otvoriť na MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Čítať ďalej...",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť",
|
||||
"downloadOriginalFormat": "Stiahnuť v pôvodnom formáte",
|
||||
"shareOriginalFormat": "Zdieľať v pôvodnom formáte",
|
||||
"shareDialogTitle": "Zdieľať %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Zdieľať 1 %{resource} |||| Zdieľať %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter",
|
||||
"shareSuccess": "URL skopírovaná do schránky: %{url}",
|
||||
"shareFailure": "Chyba pri kopírovaní URL %{url} do schránky",
|
||||
"downloadDialogTitle": "Stiahnuť %{resource} '%{name}' (%{size})",
|
||||
"downloadOriginalFormat": "Stiahnuť v pôvodnom formáte"
|
||||
"shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Odstráňte chýbajúce súbory",
|
||||
"remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.",
|
||||
"remove_all_missing_title": "Odstráňte všetky chýbajúce súbory",
|
||||
"remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.",
|
||||
"noSimilarSongsFound": "Nenašli sa žiadne podobné skladby",
|
||||
"noTopSongsFound": "Nenašli sa žiadne top skladby",
|
||||
"startingInstantMix": "Načítava sa Instant Mix...",
|
||||
"uploadCover": "Nahrať obrázok obalu",
|
||||
"removeCover": "Odstrániť obrázok obalu",
|
||||
"coverUploaded": "Obrázok obalu albumu aktualizovaný",
|
||||
"coverRemoved": "Obrázok obalu albumu odstránený",
|
||||
"coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu",
|
||||
"coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Knižnica",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Všetky knižnice (%{count})",
|
||||
"multipleLibraries": "%{selected} z %{total} knižníc",
|
||||
"selectLibraries": "Vyberte knižnice",
|
||||
"none": "Žiadne"
|
||||
},
|
||||
"settings": "Nastavenia",
|
||||
"version": "Verzia",
|
||||
"theme": "Téma",
|
||||
@ -619,7 +613,6 @@
|
||||
"language": "Jazyk",
|
||||
"defaultView": "Predvolená stránka",
|
||||
"desktop_notifications": "Oznámenia na ploche",
|
||||
"lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný",
|
||||
"lastfmScrobbling": "Scrobblovať na Last.fm",
|
||||
"listenBrainzScrobbling": "Scrobblovať na ListenBrainz",
|
||||
"replaygain": "Mód ReplayGain",
|
||||
@ -628,13 +621,20 @@
|
||||
"none": "Vypnuté",
|
||||
"album": "Použiť Album Gain",
|
||||
"track": "Použiť Track Gain"
|
||||
}
|
||||
},
|
||||
"lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný"
|
||||
}
|
||||
},
|
||||
"albumList": "Albumy",
|
||||
"about": "O Navidrome",
|
||||
"playlists": "Zoznamy skladieb",
|
||||
"sharedPlaylists": "Zdieľané zoznamy skladieb",
|
||||
"about": "O Navidrome"
|
||||
"librarySelector": {
|
||||
"allLibraries": "Všetky knižnice (%{count})",
|
||||
"multipleLibraries": "%{selected} z %{total} knižníc",
|
||||
"selectLibraries": "Vyberte knižnice",
|
||||
"none": "Žiadne"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Rad",
|
||||
@ -682,11 +682,11 @@
|
||||
"currentValue": "Aktuálna hodnota",
|
||||
"configurationFile": "Konfiguračný súbor",
|
||||
"exportToml": "Exportovať konfiguráciu (TOML)",
|
||||
"downloadToml": "Stiahnuť konfiguráciu (TOML)",
|
||||
"exportSuccess": "Konfigurácia exportovaná do schránky vo formáte TOML",
|
||||
"exportFailed": "Nepodarilo sa skopírovať konfiguráciu",
|
||||
"devFlagsHeader": "Vývojové príznaky (môžu byť zmenené/odstránené)",
|
||||
"devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách"
|
||||
"devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách",
|
||||
"downloadToml": "Stiahnuť konfiguráciu (TOML)"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@ -694,17 +694,12 @@
|
||||
"totalScanned": "Naskenované priečinky",
|
||||
"quickScan": "Rýchly sken",
|
||||
"fullScan": "Úplný sken",
|
||||
"selectiveScan": "Selektívne",
|
||||
"serverUptime": "Doba od spustenia",
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Posledný Sken",
|
||||
"status": "Chyba skenovania",
|
||||
"elapsedTime": "Uplynutý čas"
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "Práve hrá",
|
||||
"empty": "Nič sa neprehráva",
|
||||
"minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami"
|
||||
"elapsedTime": "Uplynutý čas",
|
||||
"selectiveScan": "Selektívne"
|
||||
},
|
||||
"help": {
|
||||
"title": "Klávesové skratky Navidrome",
|
||||
@ -714,10 +709,15 @@
|
||||
"toggle_play": "Prehrať / Pozastaviť",
|
||||
"prev_song": "Predchádzajúca skladba",
|
||||
"next_song": "Nasledujúca skladba",
|
||||
"current_song": "Prejsť na aktuálnu skladbu",
|
||||
"vol_up": "Zvýšiť hlasitosť",
|
||||
"vol_down": "Znížiť hlasitosť",
|
||||
"toggle_love": "Pridať túto skladbu do obľúbených"
|
||||
"toggle_love": "Pridať túto skladbu do obľúbených",
|
||||
"current_song": "Prejsť na aktuálnu skladbu"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "Práve hrá",
|
||||
"empty": "Nič sa neprehráva",
|
||||
"minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami"
|
||||
}
|
||||
}
|
||||
@ -38,7 +38,9 @@
|
||||
"missing": "หายไป",
|
||||
"libraryName": "ห้องสมุด",
|
||||
"composer": "ผู้แต่ง",
|
||||
"disc": ""
|
||||
"disc": "พื้นที่ %{discNumber}",
|
||||
"albumGain": "เนื้อหาในอัลบั้ม",
|
||||
"trackGain": "เนื้อหาในเพลง"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "เพิ่มในคิว",
|
||||
@ -355,7 +357,7 @@
|
||||
"selectedUsers": "ผู้ใช้ถูกเลือก",
|
||||
"allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด",
|
||||
"selectedLibraries": "ห้องสมุดเพลงถูกเลือก",
|
||||
"allowWriteAccess": ""
|
||||
"allowWriteAccess": "อนุญาตให้เขียน"
|
||||
},
|
||||
"sections": {
|
||||
"status": "สถานะ",
|
||||
@ -401,7 +403,7 @@
|
||||
"requiredHosts": "ต้องการ Host",
|
||||
"configValidationError": "การตั้งค่าเกิดความผิดพลาด",
|
||||
"schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน",
|
||||
"allowWriteAccessHelp": ""
|
||||
"allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "คีย์",
|
||||
@ -591,7 +593,13 @@
|
||||
"remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
|
||||
"noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน",
|
||||
"noTopSongsFound": "ไม่พบเพลงยอดนิยม",
|
||||
"startingInstantMix": "กำลังโหลดอินสแตนท์ มิก..."
|
||||
"startingInstantMix": "กำลังโหลดอินสแตนท์ มิก...",
|
||||
"uploadCover": "อัพโหลดภาพหน้าปก",
|
||||
"removeCover": "ลบถาพหน้าปก",
|
||||
"coverUploaded": "ภาพหน้าปกถูกอัพเดทแล้ว",
|
||||
"coverRemoved": "ภาพหน้าปกถูกลบแล้ว",
|
||||
"coverUploadError": "อัพโหลดภาพหน้าปกผิดพลาด",
|
||||
"coverRemoveError": "ลบภาพหน้าปกผิดพลาด"
|
||||
},
|
||||
"menu": {
|
||||
"library": "ห้องสมุดเพลง",
|
||||
@ -712,4 +720,4 @@
|
||||
"empty": "ไม่มีเพลงเล่น",
|
||||
"minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,7 +38,9 @@
|
||||
"missing": "遺失",
|
||||
"libraryName": "媒體庫",
|
||||
"composer": "作曲者",
|
||||
"disc": "光碟 %{discNumber}"
|
||||
"disc": "光碟 %{discNumber}",
|
||||
"albumGain": "專輯增益",
|
||||
"trackGain": "曲目增益"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "加入至播放佇列",
|
||||
@ -718,4 +720,4 @@
|
||||
"empty": "無播放內容",
|
||||
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -125,3 +125,82 @@ var _ = Describe("Sharing Endpoints", Ordered, func() {
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Sharing Cross-User Isolation", Ordered, func() {
|
||||
var userA, userB model.User
|
||||
var shareID string
|
||||
var albumID string
|
||||
|
||||
BeforeAll(func() {
|
||||
conf.Server.EnableSharing = true
|
||||
setupTestDB()
|
||||
|
||||
userA = createUser("share-user-a", "share-user-a", "Share User A", false)
|
||||
userB = createUser("share-user-b", "share-user-b", "Share User B", false)
|
||||
|
||||
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"album.name": "Abbey Road"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albums).ToNot(BeEmpty())
|
||||
albumID = albums[0].ID
|
||||
|
||||
resp := doReqWithUser(userA, "createShare", "id", albumID, "description", "User A's share")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Shares.Share).To(HaveLen(1))
|
||||
shareID = resp.Shares.Share[0].ID
|
||||
Expect(resp.Shares.Share[0].Username).To(Equal(userA.UserName))
|
||||
})
|
||||
|
||||
It("userB's getShares does not leak userA's share", func() {
|
||||
resp := doReqWithUser(userB, "getShares")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Shares).ToNot(BeNil())
|
||||
Expect(resp.Shares.Share).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("userA still sees own share", func() {
|
||||
resp := doReqWithUser(userA, "getShares")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Shares.Share).To(HaveLen(1))
|
||||
Expect(resp.Shares.Share[0].ID).To(Equal(shareID))
|
||||
Expect(resp.Shares.Share[0].Description).To(Equal("User A's share"))
|
||||
})
|
||||
|
||||
It("admin sees userA's share", func() {
|
||||
resp := doReqWithUser(adminUser, "getShares")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
ids := make([]string, len(resp.Shares.Share))
|
||||
for i, s := range resp.Shares.Share {
|
||||
ids[i] = s.ID
|
||||
}
|
||||
Expect(ids).To(ContainElement(shareID))
|
||||
})
|
||||
|
||||
It("userB cannot updateShare on userA's share", func() {
|
||||
resp := doReqWithUser(userB, "updateShare", "id", shareID, "description", "hijacked")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
|
||||
// Confirm description unchanged for userA.
|
||||
check := doReqWithUser(userA, "getShares")
|
||||
Expect(check.Shares.Share).To(HaveLen(1))
|
||||
Expect(check.Shares.Share[0].Description).To(Equal("User A's share"))
|
||||
})
|
||||
|
||||
It("userB cannot deleteShare on userA's share", func() {
|
||||
resp := doReqWithUser(userB, "deleteShare", "id", shareID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
|
||||
// Confirm share still present for userA.
|
||||
check := doReqWithUser(userA, "getShares")
|
||||
Expect(check.Shares.Share).To(HaveLen(1))
|
||||
Expect(check.Shares.Share[0].ID).To(Equal(shareID))
|
||||
})
|
||||
})
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
@ -301,9 +302,9 @@ func mapToSubsonicError(err error) subError {
|
||||
err = newError(responses.ErrorMissingParameter, err.Error())
|
||||
case errors.Is(err, req.ErrInvalidParam):
|
||||
err = newError(responses.ErrorGeneric, err.Error())
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
case errors.Is(err, model.ErrNotFound), errors.Is(err, rest.ErrNotFound):
|
||||
err = newError(responses.ErrorDataNotFound, "data not found")
|
||||
case errors.Is(err, model.ErrNotAuthorized):
|
||||
case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, rest.ErrPermissionDenied):
|
||||
err = newError(responses.ErrorAuthorizationFail)
|
||||
case errors.Is(err, stream.ErrTooManyTranscodes):
|
||||
err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly")
|
||||
|
||||
@ -4,13 +4,16 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -187,3 +190,24 @@ var _ = Describe("sendResponse", func() {
|
||||
Expect(pointer).To(Equal(responses.ErrorDataNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("mapToSubsonicError", func() {
|
||||
DescribeTable("maps repository errors to the correct Subsonic error code",
|
||||
func(err error, expectedCode int32) {
|
||||
subErr := mapToSubsonicError(err)
|
||||
Expect(subErr.code).To(Equal(expectedCode))
|
||||
},
|
||||
Entry("rest.ErrPermissionDenied -> not authorized (50)",
|
||||
rest.ErrPermissionDenied, responses.ErrorAuthorizationFail),
|
||||
Entry("rest.ErrNotFound -> data not found (70)",
|
||||
rest.ErrNotFound, responses.ErrorDataNotFound),
|
||||
Entry("model.ErrNotAuthorized -> not authorized (50)",
|
||||
model.ErrNotAuthorized, responses.ErrorAuthorizationFail),
|
||||
Entry("model.ErrNotFound -> data not found (70)",
|
||||
model.ErrNotFound, responses.ErrorDataNotFound),
|
||||
Entry("wrapped rest.ErrPermissionDenied is still mapped",
|
||||
fmt.Errorf("update share: %w", rest.ErrPermissionDenied), responses.ErrorAuthorizationFail),
|
||||
Entry("unknown error -> generic (0)",
|
||||
errors.New("boom"), responses.ErrorGeneric),
|
||||
)
|
||||
})
|
||||
|
||||
@ -92,7 +92,7 @@ func SongsByAlbum(albumId string) Options {
|
||||
|
||||
func SongsByRandom(genre string, fromYear, toYear int) Options {
|
||||
options := Options{
|
||||
Sort: "random",
|
||||
Sort: "random()",
|
||||
}
|
||||
ff := And{}
|
||||
if genre != "" {
|
||||
|
||||
@ -370,6 +370,7 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, stream.ErrTokenInvalid), errors.Is(err, stream.ErrTokenStale):
|
||||
log.Warn(ctx, "Invalid or stale transcode token", "mediaID", mediaID, err)
|
||||
http.Error(w, "Gone", http.StatusGone)
|
||||
default:
|
||||
log.Error(ctx, "Error validating transcode params", err)
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import ReactGA from 'react-ga'
|
||||
import { Provider } from 'react-redux'
|
||||
import { createHashHistory } from 'history'
|
||||
import { Admin as RAAdmin, Resource } from 'react-admin'
|
||||
import {
|
||||
Admin as RAAdmin,
|
||||
Resource,
|
||||
useSetLocale,
|
||||
useRefresh,
|
||||
} from 'react-admin'
|
||||
import { HotKeys } from 'react-hotkeys'
|
||||
import dataProvider from './dataProvider'
|
||||
import authProvider from './authProvider'
|
||||
@ -36,7 +41,7 @@ import {
|
||||
transcodingReducer,
|
||||
} from './reducers'
|
||||
import createAdminStore from './store/createAdminStore'
|
||||
import { i18nProvider } from './i18n'
|
||||
import { i18nProvider, retrieveTranslation } from './i18n'
|
||||
import config, { shareInfo } from './config'
|
||||
import { keyMap } from './hotkeys'
|
||||
import useChangeThemeColor from './useChangeThemeColor'
|
||||
@ -44,6 +49,7 @@ import SharePlayer from './share/SharePlayer'
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend'
|
||||
import { DndProvider } from 'react-dnd'
|
||||
import missing from './missing/index.js'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const history = createHashHistory()
|
||||
|
||||
@ -84,6 +90,24 @@ const App = () => (
|
||||
)
|
||||
|
||||
const Admin = (props) => {
|
||||
const setLocale = useSetLocale()
|
||||
const refresh = useRefresh()
|
||||
useEffect(() => {
|
||||
if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) {
|
||||
retrieveTranslation(config.defaultLanguage)
|
||||
.then(() => setLocale(config.defaultLanguage))
|
||||
.then(() => {
|
||||
localStorage.setItem('locale', config.defaultLanguage)
|
||||
refresh(true)
|
||||
})
|
||||
.catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
'Cannot load language "' + config.defaultLanguage + '": ' + e,
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [setLocale, refresh])
|
||||
useChangeThemeColor()
|
||||
/* eslint-disable react/jsx-key */
|
||||
return (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Field, Form } from 'react-final-form'
|
||||
import { useDispatch } from 'react-redux'
|
||||
@ -13,8 +13,6 @@ import {
|
||||
createMuiTheme,
|
||||
useLogin,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
useSetLocale,
|
||||
useTranslate,
|
||||
useVersion,
|
||||
} from 'react-admin'
|
||||
@ -24,7 +22,6 @@ import Notification from './Notification'
|
||||
import useCurrentTheme from '../themes/useCurrentTheme'
|
||||
import config from '../config'
|
||||
import { clearQueue } from '../actions'
|
||||
import { retrieveTranslation } from '../i18n'
|
||||
import { INSIGHTS_DOC_URL } from '../consts.js'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
@ -101,8 +98,13 @@ const renderInput = ({
|
||||
}) => (
|
||||
<TextField
|
||||
error={!!(touched && error)}
|
||||
inputProps={{
|
||||
// mobile keyboards: suppress capitalization and correction for login related fields
|
||||
autocapitalize: 'none',
|
||||
autocorrect: 'off',
|
||||
...inputProps,
|
||||
}}
|
||||
helperText={touched && error}
|
||||
{...inputProps}
|
||||
{...props}
|
||||
fullWidth
|
||||
/>
|
||||
@ -402,27 +404,8 @@ Login.propTypes = {
|
||||
// the right theme
|
||||
const LoginWithTheme = (props) => {
|
||||
const theme = useCurrentTheme()
|
||||
const setLocale = useSetLocale()
|
||||
const refresh = useRefresh()
|
||||
const version = useVersion()
|
||||
|
||||
useEffect(() => {
|
||||
if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) {
|
||||
retrieveTranslation(config.defaultLanguage)
|
||||
.then(() => {
|
||||
setLocale(config.defaultLanguage).then(() => {
|
||||
localStorage.setItem('locale', config.defaultLanguage)
|
||||
})
|
||||
refresh(true)
|
||||
})
|
||||
.catch((e) => {
|
||||
throw new Error(
|
||||
'Cannot load language "' + config.defaultLanguage + '": ' + e,
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [refresh, setLocale])
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={createMuiTheme(theme)}>
|
||||
<Login key={version} {...props} />
|
||||
|
||||
203
ui/src/themes/catppuccinLatte.css.js
Normal file
203
ui/src/themes/catppuccinLatte.css.js
Normal file
@ -0,0 +1,203 @@
|
||||
const stylesheet = `
|
||||
.react-jinke-music-player-main.light-theme svg,
|
||||
.react-jinke-music-player .music-player-controller,
|
||||
.react-jinke-music-player .audio-circle-process-bar circle[class='stroke'] {
|
||||
color: #6c6f85;
|
||||
stroke: #6c6f85;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main svg:active,
|
||||
.react-jinke-music-player-main svg:hover {
|
||||
color: #7c7f93;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme svg:active,
|
||||
.react-jinke-music-player-main.light-theme svg:hover {
|
||||
color: #7c7f93;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-mobile-play-model-tip,
|
||||
.react-jinke-music-player-main.light-theme .play-mode-title {
|
||||
background-color: #6c6f85;
|
||||
color: #eff1f5;
|
||||
}
|
||||
|
||||
.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: #6c6f85;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
|
||||
background-color: #6c6f85;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
|
||||
box-shadow: 0 0 2px #6c6f85;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .audio-item.playing svg {
|
||||
color: #6c6f85;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .audio-item.playing .player-singer {
|
||||
color: #6c6f85 !important;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .loading svg {
|
||||
color: #6c6f85 !important;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
|
||||
border: hidden;
|
||||
box-shadow:
|
||||
rgba(76, 79, 105, 0.12) 0px 4px 6px,
|
||||
rgba(76, 79, 105, 0.08) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.rc-slider-rail,
|
||||
.rc-slider-track {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.rc-slider {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .rc-switch-checked {
|
||||
background-color: #6c6f85 !important;
|
||||
border: 1px solid #6c6f85;
|
||||
}
|
||||
|
||||
.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: #e6e9ef;
|
||||
color: #4c4f69;
|
||||
box-shadow: 0 0 8px rgba(76, 79, 105, 0.15);
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .music-player-panel {
|
||||
color: #4c4f69;
|
||||
}
|
||||
|
||||
.audio-lists-panel {
|
||||
background-color: #e6e9ef;
|
||||
bottom: 6.25rem;
|
||||
box-shadow:
|
||||
rgba(76, 79, 105, 0.12) 0px 4px 6px,
|
||||
rgba(76, 79, 105, 0.08) 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: rgba(76, 79, 105, 0.08);
|
||||
}
|
||||
|
||||
.audio-lists-panel-header {
|
||||
border-bottom: 1px solid #ccd0da;
|
||||
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;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .audio-lists-panel-header {
|
||||
background-color: #e6e9ef;
|
||||
color: #4c4f69;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item {
|
||||
line-height: 32px;
|
||||
color: #4c4f69;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .img-content {
|
||||
box-shadow:
|
||||
rgba(76, 79, 105, 0.12) 0px 4px 6px,
|
||||
rgba(76, 79, 105, 0.08) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-lyric {
|
||||
color: #6c6f85; /* subtext0 */
|
||||
-webkit-text-stroke: 0.35px #eff1f5;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .lyric-btn-active,
|
||||
.react-jinke-music-player-main .lyric-btn-active svg {
|
||||
color: #6c6f85 !important;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item.playing,
|
||||
.audio-lists-panel-content .audio-item.playing svg {
|
||||
color: #6c6f85;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg,
|
||||
.audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg {
|
||||
color: #6c6f85;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item .player-icons {
|
||||
scale: 75%;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item:active,
|
||||
.audio-lists-panel-content .audio-item:hover {
|
||||
background-color: #dce0e8; /* surface1 */
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
.react-jinke-music-player-mobile-cover {
|
||||
border: none;
|
||||
box-shadow:
|
||||
rgba(76, 79, 105, 0.12) 0px 4px 6px,
|
||||
rgba(76, 79, 105, 0.08) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.react-jinke-music-player .music-player-controller {
|
||||
border: none;
|
||||
background-color: #e6e9ef;
|
||||
border-color: #e6e9ef;
|
||||
box-shadow:
|
||||
rgba(76, 79, 105, 0.12) 0px 4px 6px,
|
||||
rgba(76, 79, 105, 0.08) 0px 5px 7px;
|
||||
color: #6c6f85;
|
||||
}
|
||||
|
||||
.react-jinke-music-player .music-player-controller.music-player-playing:before {
|
||||
border: 1px solid rgba(76, 79, 105, 0.18);
|
||||
}
|
||||
|
||||
.react-jinke-music-player .music-player-controller .music-player-controller-setting {
|
||||
background: rgba(108, 111, 133, 0.2);
|
||||
color: #eff1f5;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-mobile-progress .rc-slider-handle,
|
||||
.react-jinke-music-player-mobile-progress .rc-slider-track {
|
||||
background-color: #6c6f85;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-mobile-progress .rc-slider-handle {
|
||||
border: none;
|
||||
}
|
||||
`
|
||||
|
||||
export default stylesheet
|
||||
104
ui/src/themes/catppuccinLatte.js
Normal file
104
ui/src/themes/catppuccinLatte.js
Normal file
@ -0,0 +1,104 @@
|
||||
import stylesheet from './catppuccinLatte.css.js'
|
||||
|
||||
export default {
|
||||
themeName: 'Catppuccin Latte',
|
||||
palette: {
|
||||
primary: { main: '#8839ef' }, // mauve
|
||||
secondary: {
|
||||
main: '#ccd0da', // surface0
|
||||
contrastText: '#4c4f69', // text
|
||||
},
|
||||
type: 'light',
|
||||
background: {
|
||||
default: '#eff1f5', // base
|
||||
},
|
||||
},
|
||||
|
||||
overrides: {
|
||||
MuiPaper: {
|
||||
root: {
|
||||
color: '#4c4f69', // text
|
||||
backgroundColor: '#e6e9ef', // mantle
|
||||
},
|
||||
},
|
||||
|
||||
MuiButton: {
|
||||
textPrimary: {
|
||||
color: '#1e66f5', // blue
|
||||
},
|
||||
textSecondary: {
|
||||
color: '#4c4f69', // text
|
||||
},
|
||||
},
|
||||
|
||||
MuiChip: {
|
||||
clickable: {
|
||||
background: '#ccd0da', // surface0
|
||||
},
|
||||
},
|
||||
|
||||
MuiFormGroup: {
|
||||
root: {
|
||||
color: '#4c4f69',
|
||||
},
|
||||
},
|
||||
|
||||
MuiFormHelperText: {
|
||||
root: {
|
||||
Mui: {
|
||||
error: {
|
||||
color: '#d20f39', // red
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
MuiTableHead: {
|
||||
root: {
|
||||
color: '#4c4f69',
|
||||
background: '#e6e9ef',
|
||||
},
|
||||
},
|
||||
|
||||
MuiTableCell: {
|
||||
root: {
|
||||
color: '#4c4f69',
|
||||
background: '#e6e9ef !important',
|
||||
},
|
||||
head: {
|
||||
color: '#4c4f69',
|
||||
background: '#e6e9ef !important',
|
||||
},
|
||||
},
|
||||
|
||||
NDLogin: {
|
||||
systemNameLink: {
|
||||
color: '#8839ef', // mauve
|
||||
},
|
||||
icon: {},
|
||||
welcome: {
|
||||
color: '#4c4f69',
|
||||
},
|
||||
card: {
|
||||
minWidth: 300,
|
||||
background: '#eff1f5',
|
||||
},
|
||||
avatar: {},
|
||||
button: {
|
||||
boxShadow: '3px 3px 5px #ccd0da',
|
||||
},
|
||||
},
|
||||
|
||||
NDMobileArtistDetails: {
|
||||
bgContainer: {
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(255 255 255 / 72%), rgb(239 241 245))!important',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
player: {
|
||||
theme: 'light',
|
||||
stylesheet,
|
||||
},
|
||||
}
|
||||
@ -5,7 +5,7 @@ const stylesheet = `
|
||||
}
|
||||
|
||||
.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: #458588
|
||||
background-color: #ebdbb2
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
|
||||
@ -50,6 +50,13 @@ const stylesheet = `
|
||||
.MuiCheckbox-colorSecondary.Mui-checked {
|
||||
color: #458588 !important
|
||||
}
|
||||
.react-jinke-music-player-main .music-player-panel svg {
|
||||
color: #ebdbb2;
|
||||
fill: #ebdbb2;
|
||||
}
|
||||
.react-jinke-music-player-main .music-player-panel button {
|
||||
color: #ebdbb2;
|
||||
}
|
||||
`
|
||||
|
||||
export default stylesheet
|
||||
|
||||
@ -14,22 +14,34 @@ export default {
|
||||
background: {
|
||||
default: '#282828',
|
||||
},
|
||||
text: {
|
||||
primary: '#ebdbb2',
|
||||
secondary: '#a89984',
|
||||
},
|
||||
},
|
||||
overrides: {
|
||||
MuiPaper: {
|
||||
root: {
|
||||
color: '#ebdbb2',
|
||||
backgroundColor: '#3c3836',
|
||||
MuiSnackbarContent: {
|
||||
root: {
|
||||
color: '#ebdbb2',
|
||||
backgroundColor: '#cc241d',
|
||||
},
|
||||
message: {
|
||||
color: '#ebdbb2',
|
||||
backgroundColor: '#cc241d',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiSnackbarContent: {
|
||||
root: {
|
||||
color: '#3c3836',
|
||||
backgroundColor: '#a89984',
|
||||
},
|
||||
message: {
|
||||
color: '#3c3836',
|
||||
backgroundColor: '#a89984',
|
||||
},
|
||||
},
|
||||
MuiTypography: {
|
||||
root: {
|
||||
color: '#ebdbb2',
|
||||
},
|
||||
colorTextSecondary: {
|
||||
color: '#a89984',
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
@ -45,6 +57,19 @@ export default {
|
||||
color: '#ebdbb2',
|
||||
},
|
||||
},
|
||||
MuiListItemIcon: {
|
||||
root: {
|
||||
color: '#ebdbb2',
|
||||
},
|
||||
},
|
||||
MuiListItemText: {
|
||||
primary: {
|
||||
color: '#ebdbb2',
|
||||
},
|
||||
secondary: {
|
||||
color: '#a89984',
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
clickable: {
|
||||
background: '#49483e',
|
||||
@ -57,11 +82,10 @@ export default {
|
||||
},
|
||||
MuiFormHelperText: {
|
||||
root: {
|
||||
Mui: {
|
||||
error: {
|
||||
color: '#cc241d',
|
||||
},
|
||||
},
|
||||
color: '#ebdbb2',
|
||||
},
|
||||
error: {
|
||||
color: '#cc241d',
|
||||
},
|
||||
},
|
||||
MuiTableHead: {
|
||||
@ -113,6 +137,17 @@ export default {
|
||||
'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important',
|
||||
},
|
||||
},
|
||||
NDAlbumGridView: {
|
||||
albumName: {
|
||||
marginTop: '0.5rem',
|
||||
fontWeight: 700,
|
||||
textTransform: 'none',
|
||||
color: '#ebdbb2',
|
||||
},
|
||||
albumSubtitle: {
|
||||
color: '#a89984',
|
||||
},
|
||||
},
|
||||
},
|
||||
player: {
|
||||
theme: 'dark',
|
||||
|
||||
@ -9,12 +9,17 @@ import ElectricPurpleTheme from './electricPurple'
|
||||
import NordTheme from './nord'
|
||||
import GruvboxDarkTheme from './gruvboxDark'
|
||||
import CatppuccinMacchiatoTheme from './catppuccinMacchiato'
|
||||
import CatppuccinLatteTheme from './catppuccinLatte'
|
||||
import DraculaTheme from './dracula'
|
||||
import NuclearTheme from './nuclear'
|
||||
import NutballTheme from './nutball'
|
||||
import AmusicTheme from './amusic'
|
||||
import SquiddiesGlassTheme from './SquiddiesGlass'
|
||||
import NautilineTheme from './nautiline'
|
||||
import MoonbaseAlphaTheme from './moonbaseAlpha'
|
||||
import MoonbaseBravoTheme from './moonbaseBravo'
|
||||
import TokyoNightLightTheme from './tokyoNightLight'
|
||||
import TokyoNightTheme from './tokyoNight'
|
||||
|
||||
export default {
|
||||
// Classic default themes
|
||||
@ -24,6 +29,7 @@ export default {
|
||||
// New themes should be added here, in alphabetic order
|
||||
AmusicTheme,
|
||||
CatppuccinMacchiatoTheme,
|
||||
CatppuccinLatteTheme,
|
||||
DraculaTheme,
|
||||
ElectricPurpleTheme,
|
||||
ExtraDarkTheme,
|
||||
@ -31,10 +37,14 @@ export default {
|
||||
GruvboxDarkTheme,
|
||||
LigeraTheme,
|
||||
MonokaiTheme,
|
||||
MoonbaseAlphaTheme,
|
||||
MoonbaseBravoTheme,
|
||||
NautilineTheme,
|
||||
NordTheme,
|
||||
NuclearTheme,
|
||||
NutballTheme,
|
||||
SpotifyTheme,
|
||||
SquiddiesGlassTheme,
|
||||
TokyoNightLightTheme,
|
||||
TokyoNightTheme,
|
||||
}
|
||||
|
||||
63
ui/src/themes/moonbaseAlpha.css.js
Normal file
63
ui/src/themes/moonbaseAlpha.css.js
Normal file
@ -0,0 +1,63 @@
|
||||
const stylesheet = `
|
||||
|
||||
.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
|
||||
color: #9a7420
|
||||
}
|
||||
|
||||
.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: #b8862e;
|
||||
border-color: #9a7420
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
|
||||
background-color: #c9b896;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
|
||||
box-shadow: 0 0 2px #b8862e
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .audio-item.playing svg {
|
||||
color: #9a7420
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .audio-item.playing .player-singer {
|
||||
color: #9a7420 !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .rc-slider-rail {
|
||||
background-color: #ddd7cc !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .lyric-btn {
|
||||
color: #1a1917 !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel {
|
||||
color: #1a1917 !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .lyric-btn-active svg {
|
||||
color: #9a7420 !important
|
||||
}
|
||||
|
||||
.music-player-lyric {
|
||||
color: #9a7420 !important
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
|
||||
color: #9a7420
|
||||
}
|
||||
.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: #9a7420
|
||||
}
|
||||
|
||||
.progress-bar-content .audio-title a {
|
||||
color: #1a1917
|
||||
}
|
||||
|
||||
.MuiCheckbox-colorSecondary.Mui-checked {
|
||||
color: #b8862e !important
|
||||
}
|
||||
`
|
||||
export default stylesheet
|
||||
90
ui/src/themes/moonbaseAlpha.js
Normal file
90
ui/src/themes/moonbaseAlpha.js
Normal file
@ -0,0 +1,90 @@
|
||||
import stylesheet from './moonbaseAlpha.css.js'
|
||||
|
||||
export default {
|
||||
themeName: 'Moonbase - Alpha',
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#9a7420',
|
||||
},
|
||||
secondary: {
|
||||
main: '#ede8df',
|
||||
contrastText: '#1a1917',
|
||||
},
|
||||
type: 'light',
|
||||
background: {
|
||||
default: '#f5f0e8',
|
||||
},
|
||||
},
|
||||
overrides: {
|
||||
MuiPaper: {
|
||||
root: {
|
||||
color: '#1a1917',
|
||||
backgroundColor: '#faf8f4',
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
textPrimary: {
|
||||
color: '#9a7420',
|
||||
},
|
||||
textSecondary: {
|
||||
color: '#1a1917',
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
clickable: {
|
||||
background: '#ede8df',
|
||||
},
|
||||
},
|
||||
MuiFormGroup: {
|
||||
root: {
|
||||
color: '#1a1917',
|
||||
},
|
||||
},
|
||||
MuiFormHelperText: {
|
||||
error: {
|
||||
color: '#b04a2e',
|
||||
},
|
||||
},
|
||||
MuiTableHead: {
|
||||
root: {
|
||||
color: '#6b635a',
|
||||
background: '#f5f0e8 !important',
|
||||
},
|
||||
},
|
||||
MuiTableCell: {
|
||||
root: {
|
||||
color: '#1a1917',
|
||||
background: '#faf8f4 !important',
|
||||
},
|
||||
head: {
|
||||
color: '#6b635a',
|
||||
background: '#f5f0e8 !important',
|
||||
},
|
||||
},
|
||||
NDLogin: {
|
||||
systemNameLink: {
|
||||
color: '#9a7420',
|
||||
},
|
||||
welcome: {
|
||||
color: '#1a1917',
|
||||
},
|
||||
card: {
|
||||
minWidth: 300,
|
||||
background: '#faf8f4',
|
||||
},
|
||||
button: {
|
||||
boxShadow: '3px 3px 5px rgba(0, 0, 0, 0.12)',
|
||||
},
|
||||
},
|
||||
NDMobileArtistDetails: {
|
||||
bgContainer: {
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(245, 240, 232, 0.72), #faf8f4)!important',
|
||||
},
|
||||
},
|
||||
},
|
||||
player: {
|
||||
theme: 'light',
|
||||
stylesheet,
|
||||
},
|
||||
}
|
||||
63
ui/src/themes/moonbaseBravo.css.js
Normal file
63
ui/src/themes/moonbaseBravo.css.js
Normal file
@ -0,0 +1,63 @@
|
||||
const stylesheet = `
|
||||
|
||||
.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
|
||||
color: #d4a039
|
||||
}
|
||||
|
||||
.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: #d4a039;
|
||||
border-color: #b8862e
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
|
||||
background-color: #d4a039;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
|
||||
box-shadow: 0 0 2px #d4a039
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .audio-item.playing svg {
|
||||
color: #d4a039
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .audio-item.playing .player-singer {
|
||||
color: #d4a039 !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .rc-slider-rail {
|
||||
background-color: #2a2a27 !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .lyric-btn {
|
||||
color: #e5ddd3 !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel {
|
||||
color: #e5ddd3 !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .lyric-btn-active svg {
|
||||
color: #d4a039 !important
|
||||
}
|
||||
|
||||
.music-player-lyric {
|
||||
color: #d4a039 !important
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
|
||||
color: #d4a039
|
||||
}
|
||||
.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: #d4a039
|
||||
}
|
||||
|
||||
.progress-bar-content .audio-title a {
|
||||
color: #e5ddd3
|
||||
}
|
||||
|
||||
.MuiCheckbox-colorSecondary.Mui-checked {
|
||||
color: #d4a039 !important
|
||||
}
|
||||
`
|
||||
export default stylesheet
|
||||
90
ui/src/themes/moonbaseBravo.js
Normal file
90
ui/src/themes/moonbaseBravo.js
Normal file
@ -0,0 +1,90 @@
|
||||
import stylesheet from './moonbaseBravo.css.js'
|
||||
|
||||
export default {
|
||||
themeName: 'Moonbase - Bravo',
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#d4a039',
|
||||
},
|
||||
secondary: {
|
||||
main: '#1e1e1c',
|
||||
contrastText: '#e5ddd3',
|
||||
},
|
||||
type: 'dark',
|
||||
background: {
|
||||
default: '#0a0a09',
|
||||
},
|
||||
},
|
||||
overrides: {
|
||||
MuiPaper: {
|
||||
root: {
|
||||
color: '#e5ddd3',
|
||||
backgroundColor: '#141413',
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
textPrimary: {
|
||||
color: '#d4a039',
|
||||
},
|
||||
textSecondary: {
|
||||
color: '#e5ddd3',
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
clickable: {
|
||||
background: '#1e1e1c',
|
||||
},
|
||||
},
|
||||
MuiFormGroup: {
|
||||
root: {
|
||||
color: '#e5ddd3',
|
||||
},
|
||||
},
|
||||
MuiFormHelperText: {
|
||||
error: {
|
||||
color: '#c45c3c',
|
||||
},
|
||||
},
|
||||
MuiTableHead: {
|
||||
root: {
|
||||
color: '#8a8278',
|
||||
background: '#0a0a09 !important',
|
||||
},
|
||||
},
|
||||
MuiTableCell: {
|
||||
root: {
|
||||
color: '#e5ddd3',
|
||||
background: '#141413 !important',
|
||||
},
|
||||
head: {
|
||||
color: '#8a8278',
|
||||
background: '#0a0a09 !important',
|
||||
},
|
||||
},
|
||||
NDLogin: {
|
||||
systemNameLink: {
|
||||
color: '#d4a039',
|
||||
},
|
||||
welcome: {
|
||||
color: '#e5ddd3',
|
||||
},
|
||||
card: {
|
||||
minWidth: 300,
|
||||
background: '#1e1e1c',
|
||||
},
|
||||
button: {
|
||||
boxShadow: '3px 3px 5px #0a0a09',
|
||||
},
|
||||
},
|
||||
NDMobileArtistDetails: {
|
||||
bgContainer: {
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(10, 10, 9, 0.72), #141413)!important',
|
||||
},
|
||||
},
|
||||
},
|
||||
player: {
|
||||
theme: 'dark',
|
||||
stylesheet,
|
||||
},
|
||||
}
|
||||
143
ui/src/themes/tokyoNight.css.js
Normal file
143
ui/src/themes/tokyoNight.css.js
Normal file
@ -0,0 +1,143 @@
|
||||
const stylesheet = `
|
||||
|
||||
.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
|
||||
color: #7aa2f7
|
||||
}
|
||||
|
||||
.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: #7aa2f7
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
|
||||
background-color: #7aa2f7;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
|
||||
box-shadow: 0 0 2px #7aa2f7
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .audio-item.playing svg {
|
||||
color: #7aa2f7
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .audio-item.playing .player-singer {
|
||||
color: #7aa2f7 !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .loading svg {
|
||||
color: #7aa2f7 !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;
|
||||
}
|
||||
|
||||
.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: #24283b;
|
||||
color: #c0caf5;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.audio-lists-panel {
|
||||
background-color: #24283b;
|
||||
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: #292e42;
|
||||
}
|
||||
|
||||
.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: #c0caf5;
|
||||
-webkit-text-stroke: 0.5px #1a1b26;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg {
|
||||
color: #7aa2f7 !important;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
|
||||
color: #7aa2f7
|
||||
}
|
||||
|
||||
.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: #7aa2f7
|
||||
}
|
||||
|
||||
.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: #7aa2f7;
|
||||
}
|
||||
|
||||
.react-jinke-music-player .music-player-controller .music-player-controller-setting {
|
||||
color: rgba(122, 162, 247, .3);
|
||||
}
|
||||
|
||||
.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track {
|
||||
background-color: #7aa2f7;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-mobile-progress .rc-slider-handle {
|
||||
border: none;
|
||||
}
|
||||
`
|
||||
|
||||
export default stylesheet
|
||||
382
ui/src/themes/tokyoNight.js
Normal file
382
ui/src/themes/tokyoNight.js
Normal file
@ -0,0 +1,382 @@
|
||||
import stylesheet from './tokyoNight.css.js'
|
||||
|
||||
const background = '#1a1b26'
|
||||
const surface = '#24283b'
|
||||
const currentLine = '#292e42'
|
||||
const foreground = '#c0caf5'
|
||||
const comment = '#565f89'
|
||||
const blue = '#7aa2f7'
|
||||
const cyan = '#7dcfff'
|
||||
const purple = '#bb9af7'
|
||||
const red = '#f7768e'
|
||||
|
||||
// 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: 'Tokyo Night',
|
||||
palette: {
|
||||
primary: {
|
||||
main: blue,
|
||||
},
|
||||
secondary: {
|
||||
main: purple,
|
||||
contrastText: foreground,
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
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`,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTableHead: {
|
||||
root: {
|
||||
color: foreground,
|
||||
background: surface,
|
||||
},
|
||||
},
|
||||
MuiTableCell: {
|
||||
root: {
|
||||
color: foreground,
|
||||
background: `${surface} !important`,
|
||||
borderBottom: `1px solid ${currentLine}`,
|
||||
},
|
||||
head: {
|
||||
color: `${blue} !important`,
|
||||
background: `${currentLine} !important`,
|
||||
},
|
||||
body: {
|
||||
color: `${foreground} !important`,
|
||||
},
|
||||
},
|
||||
MuiSwitch: {
|
||||
colorSecondary: {
|
||||
'&$checked': {
|
||||
color: blue,
|
||||
},
|
||||
'&$checked + $track': {
|
||||
backgroundColor: blue,
|
||||
},
|
||||
},
|
||||
},
|
||||
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: purple,
|
||||
},
|
||||
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: blue,
|
||||
},
|
||||
welcome: {
|
||||
color: foreground,
|
||||
},
|
||||
card: {
|
||||
minWidth: 300,
|
||||
background: surface,
|
||||
},
|
||||
button: {
|
||||
boxShadow: '3px 3px 5px #15161e',
|
||||
},
|
||||
},
|
||||
NDMobileArtistDetails: {
|
||||
bgContainer: {
|
||||
background: `linear-gradient(to bottom, rgba(26 27 38 / 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: cyan,
|
||||
},
|
||||
},
|
||||
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/tokyoNightLight.css.js
Normal file
123
ui/src/themes/tokyoNightLight.css.js
Normal file
@ -0,0 +1,123 @@
|
||||
const stylesheet = `
|
||||
.react-jinke-music-player-main.light-theme .loading svg {
|
||||
color: #2e7de9;
|
||||
font-size: 24px
|
||||
}
|
||||
|
||||
.react-jinke-music-player-mobile-play-model-tip {
|
||||
background-color: #2e7de9;
|
||||
}
|
||||
|
||||
.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: #2e7de9
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
|
||||
box-shadow: 0 0 2px #2e7de9
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .audio-item.playing svg {
|
||||
color: #2e7de9
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer {
|
||||
color: #2e7de9 !important
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
|
||||
color: #2e7de9
|
||||
}
|
||||
|
||||
.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: #2e7de9
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme ::-webkit-scrollbar-thumb {
|
||||
background-color: #2e7de9;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme svg {
|
||||
color: #3760bf
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover {
|
||||
color: #2e7de9
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .rc-slider-rail {
|
||||
background-color: rgba(55, 96, 191, .12) !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .music-player-controller {
|
||||
background-color: #d5d6db;
|
||||
border-color: #d5d6db
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .music-player-panel {
|
||||
background-color: #d5d6db;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 34, 77, .05);
|
||||
color: #3760bf
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .music-player-panel .img-content {
|
||||
box-shadow: 0 0 10px #c4c8da
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .music-player-panel .progress-load-bar {
|
||||
background-color: rgba(55, 96, 191, .08) !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .rc-switch {
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .rc-switch:after {
|
||||
background-color: #fff
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .rc-switch-checked {
|
||||
background-color: #2e7de9 !important;
|
||||
border: 1px solid #2e7de9
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .rc-switch-inner {
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .audio-lists-btn {
|
||||
background-color: #e1e2e7 !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: #ebebed;
|
||||
color: #3760bf
|
||||
}
|
||||
|
||||
.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: #2e7de9
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .audio-lists-panel {
|
||||
background-color: #d5d6db;
|
||||
box-shadow: 0 0 2px #c4c8da;
|
||||
color: #3760bf
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item {
|
||||
background-color: #d5d6db
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item:nth-child(odd) {
|
||||
background-color: #dadbe0 !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing {
|
||||
background-color: #c4c8da !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: #2e7de9 !important
|
||||
}
|
||||
`
|
||||
|
||||
export default stylesheet
|
||||
382
ui/src/themes/tokyoNightLight.js
Normal file
382
ui/src/themes/tokyoNightLight.js
Normal file
@ -0,0 +1,382 @@
|
||||
import stylesheet from './tokyoNightLight.css.js'
|
||||
|
||||
const background = '#e1e2e7'
|
||||
const surface = '#d5d6db'
|
||||
const currentLine = '#c4c8da'
|
||||
const foreground = '#3760bf'
|
||||
const comment = '#848cb5'
|
||||
const blue = '#2e7de9'
|
||||
const cyan = '#007197'
|
||||
const purple = '#9854f1'
|
||||
const red = '#f52a65'
|
||||
|
||||
// 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: 'Tokyo Night Light',
|
||||
palette: {
|
||||
primary: {
|
||||
main: blue,
|
||||
},
|
||||
secondary: {
|
||||
main: purple,
|
||||
contrastText: foreground,
|
||||
},
|
||||
error: {
|
||||
main: red,
|
||||
},
|
||||
type: 'light',
|
||||
background: {
|
||||
default: background,
|
||||
paper: surface,
|
||||
},
|
||||
},
|
||||
overrides: {
|
||||
MuiPaper: {
|
||||
root: {
|
||||
color: foreground,
|
||||
backgroundColor: surface,
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
positionFixed: {
|
||||
backgroundColor: `${surface} !important`,
|
||||
boxShadow:
|
||||
'rgba(15, 17, 21, 0.15) 0px 4px 6px, rgba(15, 17, 21, 0.08) 0px 5px 7px',
|
||||
},
|
||||
},
|
||||
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`,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTableHead: {
|
||||
root: {
|
||||
color: foreground,
|
||||
background: surface,
|
||||
},
|
||||
},
|
||||
MuiTableCell: {
|
||||
root: {
|
||||
color: foreground,
|
||||
background: `${surface} !important`,
|
||||
borderBottom: `1px solid ${currentLine}`,
|
||||
},
|
||||
head: {
|
||||
color: `${blue} !important`,
|
||||
background: `${currentLine} !important`,
|
||||
},
|
||||
body: {
|
||||
color: `${foreground} !important`,
|
||||
},
|
||||
},
|
||||
MuiSwitch: {
|
||||
colorSecondary: {
|
||||
'&$checked': {
|
||||
color: blue,
|
||||
},
|
||||
'&$checked + $track': {
|
||||
backgroundColor: blue,
|
||||
},
|
||||
},
|
||||
},
|
||||
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: purple,
|
||||
},
|
||||
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: blue,
|
||||
},
|
||||
welcome: {
|
||||
color: foreground,
|
||||
},
|
||||
card: {
|
||||
minWidth: 300,
|
||||
background: surface,
|
||||
},
|
||||
button: {
|
||||
boxShadow: '3px 3px 5px #a8aecb',
|
||||
},
|
||||
},
|
||||
NDMobileArtistDetails: {
|
||||
bgContainer: {
|
||||
background: `linear-gradient(to bottom, rgba(225 226 231 / 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: cyan,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
}
|
||||
2
utils/cache/cached_http_client_test.go
vendored
2
utils/cache/cached_http_client_test.go
vendored
@ -20,6 +20,8 @@ var _ = Describe("HTTPClient", func() {
|
||||
var header string
|
||||
|
||||
BeforeEach(func() {
|
||||
requestsReceived = 0
|
||||
header = ""
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestsReceived++
|
||||
header = r.Header.Get("head")
|
||||
|
||||
61
utils/cache/file_haunter_test.go
vendored
61
utils/cache/file_haunter_test.go
vendored
@ -29,15 +29,15 @@ var _ = Describe("FileHaunter", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() { _ = os.RemoveAll(tempDir) })
|
||||
|
||||
// Use a short haunter period so cleanup runs promptly; the assertions
|
||||
// below poll with Eventually instead of racing a fixed sleep.
|
||||
fsCache, err = fscache.NewCacheWithHaunter(fs, fscache.NewLRUHaunterStrategy(
|
||||
cache.NewFileHaunter("", maxItems, maxSize, 300*time.Millisecond),
|
||||
cache.NewFileHaunter("", maxItems, maxSize, 100*time.Millisecond),
|
||||
))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(fsCache.Clean)
|
||||
|
||||
Expect(createTestFiles(fsCache)).To(Succeed())
|
||||
|
||||
<-time.After(400 * time.Millisecond)
|
||||
})
|
||||
|
||||
Context("When maxSize is defined", func() {
|
||||
@ -46,24 +46,39 @@ var _ = Describe("FileHaunter", func() {
|
||||
})
|
||||
|
||||
It("removes files", func() {
|
||||
Expect(os.ReadDir(cacheDir)).To(HaveLen(4))
|
||||
Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed")
|
||||
// TODO Fix flaky tests
|
||||
//Expect(fsCache.Exists("stream-0")).To(BeFalse(), "stream-0 should have been scrubbed")
|
||||
// stream-0..4 hold "hello" (5 bytes each) and stream-5 is empty.
|
||||
// With maxSize=20, the haunter scrubs the empty file plus enough of
|
||||
// the oldest files to bring the total size down to <= 20 bytes.
|
||||
// Which files survive (and therefore the exact count) depends on
|
||||
// access-time ordering, so we only assert the haunter's guarantees:
|
||||
// the empty file is always scrubbed and the total size stays within
|
||||
// the configured limit.
|
||||
Eventually(func(g Gomega) {
|
||||
g.Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed")
|
||||
size, err := dirSize(cacheDir)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
g.Expect(size).To(BeNumerically("<=", maxSize))
|
||||
}).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
XContext("When maxItems is defined", func() {
|
||||
Context("When maxItems is defined", func() {
|
||||
BeforeEach(func() {
|
||||
maxItems = 3
|
||||
})
|
||||
|
||||
It("removes files", func() {
|
||||
Expect(os.ReadDir(cacheDir)).To(HaveLen(maxItems))
|
||||
Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed")
|
||||
// TODO Fix flaky tests
|
||||
//Expect(fsCache.Exists("stream-0")).To(BeFalse(), "stream-0 should have been scrubbed")
|
||||
//Expect(fsCache.Exists("stream-1")).To(BeFalse(), "stream-1 should have been scrubbed")
|
||||
// With maxItems=3, the haunter scrubs the empty file plus enough of
|
||||
// the oldest files to bring the count within the limit. As above, the
|
||||
// exact survivors depend on access-time ordering, so we assert the
|
||||
// guaranteed invariants: the empty file is gone and the item count
|
||||
// stays within the configured limit.
|
||||
Eventually(func(g Gomega) {
|
||||
g.Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed")
|
||||
entries, readErr := os.ReadDir(cacheDir)
|
||||
g.Expect(readErr).ToNot(HaveOccurred())
|
||||
g.Expect(len(entries)).To(BeNumerically("<=", maxItems))
|
||||
}).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Succeed())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -93,6 +108,26 @@ func createTestFiles(c *fscache.FSCache) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// dirSize returns the total size in bytes of all regular files in dir.
|
||||
func dirSize(dir string) (uint64, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total uint64
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
total += uint64(info.Size())
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func createCachedStream(c *fscache.FSCache, name string, contents string) fscache.ReadAtCloser {
|
||||
r, w, _ := c.Get(name)
|
||||
_, _ = w.Write([]byte(contents))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user