Merge branch 'master' into feat/support-playlist-paths

This commit is contained in:
Deluan Quintão 2026-05-22 20:19:47 -03:00 committed by GitHub
commit 4a6038cfaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
83 changed files with 2013 additions and 561 deletions

3
.gitignore vendored
View File

@ -37,5 +37,6 @@ AGENTS.md
*.wasm
*.ndp
openspec/
.agents
go.work*
.worktrees/
.worktrees/

View File

@ -1,7 +1,7 @@
package deezer
import (
bytes "bytes"
"bytes"
"context"
"encoding/json"
"errors"

View File

@ -8,7 +8,6 @@ import (
"github.com/djherbis/times"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -91,8 +90,7 @@ var _ = Describe("Extractor", func() {
info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info)
mf := metadata.ToMediaFile(1, "folderID")
return &mf
return new(metadata.ToMediaFile(1, "folderID"))
}
BeforeEach(func() {
@ -109,7 +107,7 @@ var _ = Describe("Extractor", func() {
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
},
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", new(0.0), new(1.0), new(0.0), new(1.0)),
)
})
@ -120,8 +118,8 @@ var _ = Describe("Extractor", func() {
DisplayTitle: "",
Lang: code,
Line: []model.Line{
{Start: gg.P(int64(0)), Value: "This is"},
{Start: gg.P(int64(2500)), Value: secondLine},
{Start: new(int64(0)), Value: "This is"},
{Start: new(int64(2500)), Value: secondLine},
},
Offset: nil,
Synced: true,

View File

@ -57,7 +57,7 @@ func (d *Dir) MustPath() string {
// GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf)
// prints the path string instead of the internal struct fields.
func (d Dir) GoString() string { //nolint:govet
func (d Dir) GoString() string { //nolint:govet // uses a value receiver so Dir values satisfy GoStringer
return fmt.Sprintf("%q", d.path)
}

View File

@ -21,8 +21,7 @@ func TestAuth(t *testing.T) {
}
const (
testJWTSecret = "not so secret"
oneDay = 24 * time.Hour
oneDay = 24 * time.Hour
)
var _ = BeforeSuite(func() {

View File

@ -153,7 +153,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
return album, err
}
album.ExternalInfoUpdatedAt = P(time.Now())
album.ExternalInfoUpdatedAt = new(time.Now())
album.ExternalUrl = info.URL
if info.Description != "" {
@ -269,7 +269,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
return artist, ctx.Err()
}
artist.ExternalInfoUpdatedAt = P(time.Now())
artist.ExternalInfoUpdatedAt = new(time.Now())
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
if err != nil {
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,

View File

@ -272,12 +272,11 @@ var _ = Describe("Provider - ArtistImage", func() {
It("returns cached URL and does not call agent when info is not expired", func() {
// Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt
recentTime := time.Now().Add(-1 * time.Minute)
cachedArtist := &model.Artist{
ID: "artist-cached",
Name: "Cached Artist",
LargeImageUrl: "http://example.com/cached-large.jpg",
ExternalInfoUpdatedAt: &recentTime,
ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)),
}
mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe()
expectedURL, _ := url.Parse("http://example.com/cached-large.jpg")
@ -304,12 +303,11 @@ var _ = Describe("Provider - ArtistImage", func() {
It("returns stale URL and enqueues refresh when info is expired", func() {
// Arrange
conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond
expiredTime := time.Now().Add(-1 * time.Hour)
staleArtist := &model.Artist{
ID: "artist-expired",
Name: "Expired Artist",
LargeImageUrl: "http://example.com/expired-large.jpg",
ExternalInfoUpdatedAt: &expiredTime,
ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)),
}
mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe()
expectedURL, _ := url.Parse("http://example.com/expired-large.jpg")

View File

@ -12,7 +12,6 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
@ -90,7 +89,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ExternalUrl: "http://cached.com/album",
Description: "Cached Desc",
LargeImageUrl: "http://cached.com/large.jpg",
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
}
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
@ -113,7 +112,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ExternalUrl: "http://expired.com/album",
Description: "Expired Desc",
LargeImageUrl: "http://expired.com/large.jpg",
ExternalInfoUpdatedAt: gg.P(expiredTime),
ExternalInfoUpdatedAt: new(expiredTime),
}
mockAlbumRepo.SetData(model.Albums{*originalAlbum})

View File

@ -13,7 +13,6 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
@ -137,7 +136,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
ExternalUrl: "http://cached.url",
Biography: "Cached Bio",
LargeImageUrl: "http://cached_large.jpg",
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
SimilarArtists: model.Artists{
{ID: "ar-similar-present", Name: "Similar Present"},
{ID: "ar-similar-absent", Name: "Similar Absent"},
@ -174,7 +173,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
originalArtist := &model.Artist{
ID: "ar-expired",
Name: "Expired Artist",
ExternalInfoUpdatedAt: gg.P(expiredTime),
ExternalInfoUpdatedAt: new(expiredTime),
SimilarArtists: model.Artists{
{ID: "ar-exp-similar", Name: "Expired Similar"},
},
@ -205,7 +204,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
originalArtist := &model.Artist{
ID: "ar-similar-test",
Name: "Similar Test Artist",
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
SimilarArtists: model.Artists{
{ID: "ar-sim-present", Name: "Similar Present"},
{ID: "", Name: "Similar Absent Raw"},

View File

@ -326,8 +326,7 @@ func (j *ffCmd) start(ctx context.Context) error {
func (j *ffCmd) wait() {
if err := j.cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())
if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" {
errMsg += ": " + stderrOutput

View File

@ -7,7 +7,7 @@ import (
"path/filepath"
"runtime"
"strings"
sync "sync"
"sync"
"testing"
"time"

View File

@ -7,7 +7,6 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
. "github.com/navidrome/navidrome/utils/gg"
)
type InspectOutput struct {
@ -44,7 +43,7 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e
result := &InspectOutput{
File: filePath,
RawTags: tags[file].Tags,
MappedTags: P(md.ToMediaFile(libraryId, folderId)),
MappedTags: new(md.ToMediaFile(libraryId, folderId)),
}
return result, nil

View File

@ -12,7 +12,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -32,15 +31,15 @@ var _ = Describe("sources", func() {
Lang: "eng",
Line: []model.Line{
{
Start: gg.P(int64(18800)),
Start: new(int64(18800)),
Value: "We're no strangers to love",
},
{
Start: gg.P(int64(22801)),
Start: new(int64(22801)),
Value: "You know the rules and so do I",
},
},
Offset: gg.P(int64(-100)),
Offset: new(int64(-100)),
Synced: true,
},
}

View File

@ -5,7 +5,6 @@ import (
"encoding/json"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -74,15 +73,15 @@ var _ = Describe("sources", func() {
Lang: "eng",
Line: []model.Line{
{
Start: gg.P(int64(18800)),
Start: new(int64(18800)),
Value: "We're no strangers to love",
},
{
Start: gg.P(int64(22801)),
Start: new(int64(22801)),
Value: "You know the rules and so do I",
},
},
Offset: gg.P(int64(-100)),
Offset: new(int64(-100)),
Synced: true,
},
}))
@ -122,7 +121,7 @@ var _ = Describe("sources", func() {
// The critical assertion: even with BOM, synced should be true
Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced")
Expect(lyrics[0].Line).To(HaveLen(1))
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0))))
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0))))
Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲"))
})
@ -137,9 +136,9 @@ var _ = Describe("sources", func() {
// UTF-16 should be properly converted to UTF-8
Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced")
Expect(lyrics[0].Line).To(HaveLen(2))
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800))))
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800))))
Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love"))
Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801))))
Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801))))
Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I"))
})
})

View File

@ -62,8 +62,7 @@ func (j *Executor) start(ctx context.Context) error {
func (j *Executor) wait() {
if err := j.cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
} else {
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))

View File

@ -206,7 +206,7 @@ func (t *MpvTrack) IsPlaying() bool {
func waitForSocket(path string, timeout time.Duration, pause time.Duration) error {
start := time.Now()
end := start.Add(timeout)
var retries int = 0
var retries = 0
for {
fileInfo, err := os.Stat(path)

View File

@ -59,8 +59,7 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R
}
err = json.Unmarshal(input, nsp)
if err != nil {
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
if syntaxErr, ok := errors.AsType[*json.SyntaxError](err); ok {
line, col := getPositionFromOffset(input, syntaxErr.Offset)
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
}

View File

@ -144,29 +144,25 @@ var _ = Describe("Playlists", func() {
It("allows owner to update their playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
It("allows admin to update any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
newName := "Updated Name"
err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "pls-other", new("Updated Name"), nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
It("denies non-owner, non-admin from updating", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "nonexistent", new("Updated Name"), nil, nil, nil, nil)
Expect(err).To(Equal(model.ErrNotFound))
})
@ -184,8 +180,7 @@ var _ = Describe("Playlists", func() {
It("allows metadata updates on a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Smart"
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
})

View File

@ -63,7 +63,6 @@ var _ = Describe("REST Adapter", func() {
It("clears server-managed fields to prevent injection via REST API", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
now := time.Now()
pls := &model.Playlist{
Name: "Legit Playlist",
Comment: "A comment",
@ -73,7 +72,7 @@ var _ = Describe("REST Adapter", func() {
Sync: true,
UploadedImage: "injected-image-path",
ExternalImageURL: "http://evil.example.com/ssrf",
EvaluatedAt: &now,
EvaluatedAt: new(time.Now()),
}
_, err := repo.Save(pls)
Expect(err).ToNot(HaveOccurred())

View File

@ -1133,8 +1133,7 @@ func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession
if f.Error != nil {
return f.Error
}
uid := info.UserId
f.userID.Store(&uid)
f.userID.Store(new(info.UserId))
f.LastPlaybackReport.Store(&info)
return nil
}

View File

@ -41,7 +41,7 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error
if !expiresAt.IsZero() && expiresAt.Before(time.Now()) {
return nil, model.ErrExpired
}
share.LastVisitedAt = P(time.Now())
share.LastVisitedAt = new(time.Now())
share.VisitCount++
err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count")
@ -95,7 +95,7 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
}
s.ID = id
if V(s.ExpiresAt).IsZero() {
s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration))
s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration))
}
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]

View File

@ -0,0 +1,9 @@
-- +goose Up
CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id_role
ON media_file_artists (media_file_id, role);
DROP INDEX IF EXISTS media_file_artists_media_file_id;
-- +goose Down
CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id
ON media_file_artists (media_file_id);
DROP INDEX IF EXISTS media_file_artists_media_file_id_role;

View File

@ -11,8 +11,7 @@ import (
var _ = Describe("ArtworkID", func() {
Describe("NewArtworkID()", func() {
It("creates a valid parseable ArtworkID", func() {
now := time.Now()
id := model.NewArtworkID(model.KindAlbumArtwork, "1234", &now)
id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now()))
parsedId, err := model.ParseArtworkID(id.String())
Expect(err).ToNot(HaveOccurred())
Expect(parsedId.Kind).To(Equal(id.Kind))

View File

@ -1,5 +1,3 @@
package criteria
var StartOfPeriod = startOfPeriod
type UnmarshalConjunctionType = unmarshalConjunctionType

View File

@ -1,7 +1,5 @@
package criteria
import "time"
// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively
type conjunction interface {
ChildPlaylistIds() []string
@ -150,10 +148,6 @@ func (nitl NotInTheLast) MarshalJSON() ([]byte, error) {
func (nitl NotInTheLast) fields() map[string]any { return nitl }
func startOfPeriod(numDays int64, from time.Time) string {
return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
}
type InPlaylist map[string]any
func (ipl InPlaylist) MarshalJSON() ([]byte, error) {

View File

@ -8,14 +8,13 @@ import (
var _ = Describe("ToLyrics", func() {
It("should parse tags with spaces", func() {
num := int64(1551)
lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Lang).To(Equal("eng"))
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.DisplayArtist).To(Equal("An artist"))
Expect(lyrics.DisplayTitle).To(Equal("A title"))
Expect(lyrics.Offset).To(Equal(&num))
Expect(lyrics.Offset).To(Equal(new(int64(1551))))
})
It("Should ignore bad offset", func() {
@ -25,39 +24,36 @@ var _ = Describe("ToLyrics", func() {
})
It("should accept lines with no text and weird times", func() {
a, b, c, d := int64(0), int64(10040), int64(40000), int64(1000*60*60)
lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: &a, Value: "Hi there"},
{Start: &b, Value: ""},
{Start: &c, Value: "Test"},
{Start: &d, Value: "late"},
{Start: new(int64(0)), Value: "Hi there"},
{Start: new(int64(10040)), Value: ""},
{Start: new(int64(40000)), Value: "Test"},
{Start: new(int64(1000 * 60 * 60)), Value: "late"},
}))
})
It("Should support multiple timestamps per line", func() {
a, b, c, d := int64(0), int64(10000), int64(13*60*1000), int64(1000*60*60*51)
lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: &a, Value: "Repeated"},
{Start: &b, Value: "Repeated"},
{Start: &c, Value: ""},
{Start: &d, Value: ""},
{Start: new(int64(0)), Value: "Repeated"},
{Start: new(int64(10000)), Value: "Repeated"},
{Start: new(int64(13 * 60 * 1000)), Value: ""},
{Start: new(int64(1000 * 60 * 60 * 51)), Value: ""},
}))
})
It("Should support parsing multiline string", func() {
a, b := int64(0), int64(10*60*1000+1)
lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: &a, Value: "This is\na multiline\n\n[:0] string"},
{Start: &b, Value: "This is\nalso one"},
{Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"},
{Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"},
}))
})
@ -71,49 +67,45 @@ var _ = Describe("ToLyrics", func() {
})
It("Allows timestamp in middle of line if also at beginning", func() {
a, b := int64(0), int64(1000)
lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: &a, Value: "This is [00:00:00] be a synced file"},
{Start: &b, Value: "Line 2"},
{Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"},
{Start: new(int64(1000)), Value: "Line 2"},
}))
})
It("Ignores lines in synchronized lyric prior to first timestamp", func() {
a := int64(0)
lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: &a, Value: "Text"},
{Start: new(int64(0)), Value: "Text"},
}))
})
It("Handles all possible ms cases", func() {
a, b, c := int64(1), int64(10), int64(100)
lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: &a, Value: "a"},
{Start: &b, Value: "b"},
{Start: &c, Value: "c"},
{Start: new(int64(1)), Value: "a"},
{Start: new(int64(10)), Value: "b"},
{Start: new(int64(100)), Value: "c"},
}))
})
It("Properly sorts repeated lyrics out of order", func() {
a, b, c, d, e := int64(0), int64(10000), int64(40000), int64(13*60*1000), int64(1000*60*60*51)
lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: &a, Value: "Repeated"},
{Start: &b, Value: "Test"},
{Start: &c, Value: "Not repeated"},
{Start: &d, Value: "Repeated"},
{Start: &e, Value: "Test"},
{Start: new(int64(0)), Value: "Repeated"},
{Start: new(int64(10000)), Value: "Test"},
{Start: new(int64(40000)), Value: "Not repeated"},
{Start: new(int64(13 * 60 * 1000)), Value: "Repeated"},
{Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"},
}))
})
})

View File

@ -8,7 +8,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
. "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -108,8 +107,8 @@ var _ = Describe("ToMediaFile", func() {
expected := model.LyricList{
{Lang: "eng", Line: []model.Line{
{Value: "This is", Start: P(int64(0))},
{Value: "English SYLT", Start: P(int64(2500))},
{Value: "This is", Start: new(int64(0))},
{Value: "English SYLT", Start: new(int64(2500))},
}, Synced: true},
{Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false},
}

View File

@ -8,7 +8,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -274,8 +273,8 @@ var _ = Describe("Metadata", func() {
mf := createMF("replaygain_track_gain", tagValue)
Expect(mf.RGTrackGain).To(Equal(expected))
},
Entry("0", "0", gg.P(0.0)),
Entry("1.2dB", "1.2dB", gg.P(1.2)),
Entry("0", "0", new(0.0)),
Entry("1.2dB", "1.2dB", new(1.2)),
Entry("Infinity", "Infinity", nil),
Entry("Invalid value", "INVALID VALUE", nil),
Entry("NaN", "NaN", nil),
@ -285,9 +284,9 @@ var _ = Describe("Metadata", func() {
mf := createMF("replaygain_track_peak", tagValue)
Expect(mf.RGTrackPeak).To(Equal(expected))
},
Entry("0", "0", gg.P(0.0)),
Entry("1.0", "1.0", gg.P(1.0)),
Entry("0.5", "0.5", gg.P(0.5)),
Entry("0", "0", new(0.0)),
Entry("1.0", "1.0", new(1.0)),
Entry("0.5", "0.5", new(0.5)),
Entry("Invalid dB suffix", "0.7dB", nil),
Entry("Infinity", "Infinity", nil),
Entry("Invalid value", "INVALID VALUE", nil),
@ -299,8 +298,8 @@ var _ = Describe("Metadata", func() {
Expect(mf.RGTrackGain).To(Equal(expected))
},
Entry("0", "0", gg.P(5.0)),
Entry("-3776", "-3776", gg.P(-9.75)),
Entry("0", "0", new(5.0)),
Entry("-3776", "-3776", new(-9.75)),
Entry("Infinity", "Infinity", nil),
Entry("Invalid value", "INVALID VALUE", nil),
)

View File

@ -18,7 +18,6 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
)
@ -219,7 +218,7 @@ func (r *artistRepository) Exists(id string) (bool, error) {
func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error {
dba := &dbArtist{Artist: a}
dba.CreatedAt = P(time.Now())
dba.CreatedAt = new(time.Now())
dba.UpdatedAt = dba.CreatedAt
_, err := r.put(dba.ID, dba, colsToUpdate...)
return err

View File

@ -3,7 +3,9 @@ package persistence
import (
"errors"
"fmt"
"maps"
"reflect"
"slices"
"strconv"
"strings"
"time"
@ -147,7 +149,7 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz
}
or = append(or, cond)
}
return or, nil
return mergeJsonConds(or), nil
case criteria.Is:
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
return squirrel.Eq(fields)
@ -385,17 +387,173 @@ type roleCond struct {
func (e roleCond) ToSql() (string, []any, error) {
var cond string
var args []any
var err error
if e.cond != nil {
cond, args, err = e.cond.ToSql()
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond)
innerSQL, innerArgs, err := roleCondSQL(e.cond)
if err != nil {
return "", nil, err
}
cond = roleExistsSQL(innerSQL)
args = append([]any{e.role}, innerArgs...)
} else {
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role)
cond = "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)"
args = []any{e.role}
}
if e.not {
cond = "not " + cond
}
return cond, args, err
return cond, args, nil
}
// roleCondSQL extracts SQL from a squirrel condition and rewrites the placeholder column name.
func roleCondSQL(cond squirrel.Sqlizer) (string, []any, error) {
sql, args, err := cond.ToSql()
if err != nil {
return "", nil, err
}
return strings.ReplaceAll(sql, "value", "artist.name"), args, nil
}
// roleExistsSQL wraps a condition fragment in the standard role EXISTS subquery.
func roleExistsSQL(innerCond string) string {
return fmt.Sprintf("exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id "+
"where mfa.media_file_id = media_file.id and mfa.role = ? and %s)", innerCond)
}
// jsonCondBatchSize limits how many conditions are ORed inside a single EXISTS subquery
// to stay within SQLite's expression tree depth limit (max 1000). The EXISTS wrapper
// consumes ~4 levels; each ORed condition adds 1 level. Empirically, 496 is the maximum.
const jsonCondBatchSize = 350
// mergeJsonConds collapses multiple non-negated roleCond or tagCond entries for the same
// field within an OR group into batched EXISTS subqueries with the conditions ORed inside.
// This turns N separate correlated subqueries into ceil(N/batchSize), dramatically
// improving performance for smart playlists with many patterns.
func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer {
type condEntry struct {
index int
cond squirrel.Sqlizer
}
type group struct {
entries []condEntry
isRole bool
numeric bool
tag string
}
groups := make(map[string]*group)
for i, s := range or {
switch c := s.(type) {
case roleCond:
if c.not || c.cond == nil {
continue
}
g, exists := groups["role:"+c.role]
if !exists {
g = &group{isRole: true}
groups["role:"+c.role] = g
}
g.entries = append(g.entries, condEntry{index: i, cond: c.cond})
case tagCond:
if c.not || c.cond == nil {
continue
}
g, exists := groups["tag:"+c.tag]
if !exists {
g = &group{tag: c.tag, numeric: c.numeric}
groups["tag:"+c.tag] = g
}
g.entries = append(g.entries, condEntry{index: i, cond: c.cond})
}
}
merged := false
remove := make(map[int]bool)
var additions []squirrel.Sqlizer
for _, key := range slices.Sorted(maps.Keys(groups)) {
g := groups[key]
if len(g.entries) < 2 {
continue
}
merged = true
for _, e := range g.entries {
remove[e.index] = true
}
conds := make([]squirrel.Sqlizer, len(g.entries))
for i, e := range g.entries {
conds[i] = e.cond
}
if g.isRole {
role := key[len("role:"):]
for batch := range slices.Chunk(conds, jsonCondBatchSize) {
additions = append(additions, roleCondGroup{role: role, conds: batch})
}
} else {
for batch := range slices.Chunk(conds, jsonCondBatchSize) {
additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch})
}
}
}
if !merged {
return or
}
result := make(squirrel.Or, 0, len(or)-len(remove)+len(additions))
for i, s := range or {
if !remove[i] {
result = append(result, s)
}
}
result = append(result, additions...)
return result
}
// roleCondGroup represents multiple role conditions for the same role, merged into
// a single EXISTS subquery for performance.
type roleCondGroup struct {
role string
conds []squirrel.Sqlizer
}
func (g roleCondGroup) ToSql() (string, []any, error) {
innerParts := make([]string, 0, len(g.conds))
allArgs := []any{g.role}
for _, c := range g.conds {
part, args, err := roleCondSQL(c)
if err != nil {
return "", nil, err
}
innerParts = append(innerParts, part)
allArgs = append(allArgs, args...)
}
cond := roleExistsSQL("(" + strings.Join(innerParts, " OR ") + ")")
return cond, allArgs, nil
}
// tagCondGroup represents multiple tag conditions for the same tag, merged into
// a single EXISTS subquery for performance.
type tagCondGroup struct {
tag string
numeric bool
conds []squirrel.Sqlizer
}
func (g tagCondGroup) ToSql() (string, []any, error) {
innerParts := make([]string, 0, len(g.conds))
var allArgs []any
for _, c := range g.conds {
part, args, err := c.ToSql()
if err != nil {
return "", nil, err
}
if g.numeric {
part = strings.ReplaceAll(part, "value", "CAST(value AS REAL)")
}
innerParts = append(innerParts, part)
allArgs = append(allArgs, args...)
}
cond := fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and (%s))",
g.tag, strings.Join(innerParts, " OR "))
return cond, allArgs, nil
}
func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) {

View File

@ -0,0 +1,236 @@
package persistence
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/pocketbase/dbx"
)
const (
benchNumArtists = 1_000
benchNumTracks = 40_000
benchNumPatterns = 500
benchArtistsPerTrack = 3
)
// BenchmarkSmartPlaylistRole compares role-based smart playlist query performance
// between the current implementation (merged join-table via criteria pipeline) and
// the old baseline (unmerged json_tree subqueries).
func BenchmarkSmartPlaylistRole(b *testing.B) {
configtest.SetupConfig()
tmpDir := b.TempDir()
conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl.db")
cleanup := db.Init(context.Background())
defer cleanup()
log.SetLevel(log.LevelFatal)
conn := dbx.NewFromDB(db.Db(), db.Dialect)
ctx := log.NewContext(context.Background())
user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true}
ctx = request.WithUser(ctx, user)
setupBenchData(b, ctx, conn, user)
criteria.AddRoles([]string{"artist"})
// Build the criteria expression: 500 "contains artist" patterns in an OR group
anyExprs := make(criteria.Any, benchNumPatterns)
for i := range benchNumPatterns {
anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist %04d", i)}
}
expr := criteria.Criteria{Expression: anyExprs, Sort: "title", Limit: 500}
b.Run("Current", func(b *testing.B) {
benchmarkCriteriaPipeline(b, ctx, expr)
})
b.Run("Baseline_UnmergedJSONTree", func(b *testing.B) {
benchmarkUnmergedJSONTree(b, ctx)
})
}
// benchmarkCriteriaPipeline runs the criteria through the actual production code path:
// newSmartPlaylistCriteria → Where() → ToSql(), then executes the resulting query.
func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.Criteria) {
b.Helper()
cSQL := newSmartPlaylistCriteria(expr)
// Build the full query matching buildSmartPlaylistQuery + addCriteria
sq := squirrel.Select("media_file.id").From("media_file")
cond, err := cSQL.Where()
if err != nil {
b.Fatal(err)
}
sq = sq.Where(cond)
if expr.Limit > 0 {
sq = sq.Limit(uint64(expr.Limit))
}
if order := cSQL.OrderBy(); order != "" {
sq = sq.OrderBy(order)
}
query, args, err := sq.PlaceholderFormat(squirrel.Question).ToSql()
if err != nil {
b.Fatal(err)
}
runBenchQuery(b, ctx, query, args)
}
// benchmarkUnmergedJSONTree builds the old-style query with N separate json_tree EXISTS
// subqueries (the pre-optimization baseline).
func benchmarkUnmergedJSONTree(b *testing.B, ctx context.Context) {
b.Helper()
var sb strings.Builder
sb.WriteString("SELECT media_file.id FROM media_file WHERE (")
args := make([]any, 0, benchNumPatterns)
for i := range benchNumPatterns {
if i > 0 {
sb.WriteString(" OR ")
}
sb.WriteString("exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)")
args = append(args, fmt.Sprintf("%%Artist %04d%%", i))
}
sb.WriteString(") ORDER BY media_file.title LIMIT 500")
runBenchQuery(b, ctx, sb.String(), args)
}
func runBenchQuery(b *testing.B, ctx context.Context, query string, args []any) {
b.Helper()
sqlDB := db.Db()
b.ResetTimer()
for range b.N {
rows, err := sqlDB.QueryContext(ctx, query, args...)
if err != nil {
b.Fatal(err)
}
for rows.Next() {
var id string
_ = rows.Scan(&id)
}
rows.Close()
if err := rows.Err(); err != nil {
b.Fatal(err)
}
}
}
func setupBenchData(b *testing.B, ctx context.Context, conn *dbx.DB, user model.User) {
b.Helper()
sqlDB := db.Db()
ur := NewUserRepository(ctx, conn)
if err := ur.Put(&user); err != nil {
b.Fatal(err)
}
if err := ur.SetUserLibraries(user.ID, []int{1}); err != nil {
b.Fatal(err)
}
tx, err := sqlDB.Begin()
if err != nil {
b.Fatal(err)
}
// Create artists
artistStmt, err := tx.Prepare("INSERT INTO artist (id, name) VALUES (?, ?)")
if err != nil {
b.Fatal(err)
}
for i := range benchNumArtists {
if _, err := artistStmt.Exec(fmt.Sprintf("artist-%04d", i), fmt.Sprintf("Artist %04d", i)); err != nil {
b.Fatal(err)
}
}
artistStmt.Close()
// Ensure folder exists
folderID := "bench-folder"
if _, err := tx.Exec("INSERT OR IGNORE INTO folder (id, library_id, path, name, parent_id) VALUES (?, 1, '.', '.', '')", folderID); err != nil {
b.Fatal(err)
}
// Create media files with participants JSON, cycling through artists
mfStmt, err := tx.Prepare(`INSERT INTO media_file (id, path, title, album, artist, artist_id, album_id,
duration, year, size, suffix, tags, participants, lyrics, library_id, folder_id, pid, codec)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
b.Fatal(err)
}
// Populate media_file_artists join table
mfaStmt, err := tx.Prepare("INSERT INTO media_file_artists (media_file_id, artist_id, role, sub_role) VALUES (?, ?, ?, ?)")
if err != nil {
b.Fatal(err)
}
for i := range benchNumTracks {
trackID := fmt.Sprintf("track-%05d", i)
// Assign benchArtistsPerTrack artists to each track, cycling through the pool
artistEntries := make([]map[string]string, benchArtistsPerTrack)
for a := range benchArtistsPerTrack {
artistIdx := (i + a) % benchNumArtists
artistEntries[a] = map[string]string{
"id": fmt.Sprintf("artist-%04d", artistIdx),
"name": fmt.Sprintf("Artist %04d", artistIdx),
}
}
primaryArtistIdx := i % benchNumArtists
primaryArtistID := fmt.Sprintf("artist-%04d", primaryArtistIdx)
primaryArtistName := fmt.Sprintf("Artist %04d", primaryArtistIdx)
participants := map[string][]map[string]string{"artist": artistEntries}
participantsJSON, _ := json.Marshal(participants)
if _, err := mfStmt.Exec(
trackID,
fmt.Sprintf("music/%s.mp3", trackID),
fmt.Sprintf("Track %05d", i),
"Bench Album",
primaryArtistName,
primaryArtistID,
"bench-album",
180, 2024, 5000000, "mp3",
"{}",
string(participantsJSON),
"[]",
1, folderID, trackID, "mp3",
); err != nil {
b.Fatal(err)
}
// Insert all artist associations into the join table
for a := range benchArtistsPerTrack {
artistIdx := (i + a) % benchNumArtists
artistID := fmt.Sprintf("artist-%04d", artistIdx)
if _, err := mfaStmt.Exec(trackID, artistID, "artist", ""); err != nil {
b.Fatal(err)
}
}
}
mfStmt.Close()
mfaStmt.Close()
if err := tx.Commit(); err != nil {
b.Fatal(err)
}
b.Logf("Setup complete: %d artists, %d tracks (%d artists/track), %d patterns",
benchNumArtists, benchNumTracks, benchArtistsPerTrack, benchNumPatterns)
}

View File

@ -1,6 +1,8 @@
package persistence
import (
"fmt"
"strings"
"time"
"github.com/navidrome/navidrome/model"
@ -57,9 +59,9 @@ var _ = Describe("Smart playlist criteria SQL", func() {
Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6),
Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"),
Entry("field alias via tag registration", criteria.Is{"recordingdate": "2024-01-01"}, "media_file.date = ?", "2024-01-01"),
Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"),
Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name = ?)", "artist", "u2"),
Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "composer", "%Lennon%"),
Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "artist", "%u2%"),
// ReplayGain fields
Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0),
Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0),
@ -71,9 +73,9 @@ var _ = Describe("Smart playlist criteria SQL", func() {
"exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
// isMissing — roles
Entry("isMissing role [true]", criteria.IsMissing{"artist": true},
"not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"),
"not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"),
Entry("isMissing role [false]", criteria.IsMissing{"artist": false},
"exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"),
"exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"),
// isPresent — tags
Entry("isPresent tag [true]", criteria.IsPresent{"genre": true},
"exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
@ -81,9 +83,9 @@ var _ = Describe("Smart playlist criteria SQL", func() {
"not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
// isPresent — roles
Entry("isPresent role [true]", criteria.IsPresent{"composer": true},
"exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"),
"exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"),
Entry("isPresent role [false]", criteria.IsPresent{"composer": false},
"not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"),
"not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"),
)
Describe("playlist permissions", func() {
@ -205,6 +207,146 @@ var _ = Describe("Smart playlist criteria SQL", func() {
}
})
Describe("JSON condition merging", func() {
It("merges multiple role conditions in an OR group into a single EXISTS", func() {
expr := criteria.Any{
criteria.Contains{"artist": "Beatles"},
criteria.Contains{"artist": "Kraftwerk"},
criteria.Contains{"artist": "Pink Floyd"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal("(exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and (artist.name LIKE ? OR artist.name LIKE ? OR artist.name LIKE ?)))"))
Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%", "%Pink Floyd%"))
})
It("does not merge role conditions from different roles", func() {
expr := criteria.Any{
criteria.Contains{"artist": "Beatles"},
criteria.Contains{"composer": "Lennon"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
Expect(err).ToNot(HaveOccurred())
sql, _, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("mfa.role = ?"))
// Two separate EXISTS since roles differ
Expect(strings.Count(sql, "exists")).To(Equal(2))
})
It("does not merge negated role conditions", func() {
expr := criteria.Any{
criteria.NotContains{"artist": "Beatles"},
criteria.NotContains{"artist": "Kraftwerk"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
Expect(err).ToNot(HaveOccurred())
sql, _, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
// Two separate "not exists" since they are negated
Expect(strings.Count(sql, "not exists")).To(Equal(2))
})
It("batches large groups to avoid SQLite expression tree depth limit", func() {
// Create jsonCondBatchSize + 1 conditions to trigger batching into 2 groups
anyExprs := make(criteria.Any, jsonCondBatchSize+1)
for i := range anyExprs {
anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)}
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).Where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
// Should produce 2 EXISTS subqueries (one batch of jsonCondBatchSize, one of 1)
Expect(strings.Count(sql, "exists")).To(Equal(2))
// First batch has jsonCondBatchSize patterns, second has 1 => total args:
// 2 roles + (jsonCondBatchSize + 1) patterns
Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1))
})
It("merges role conditions while preserving non-role conditions", func() {
expr := criteria.Any{
criteria.Contains{"title": "Love"},
criteria.Contains{"artist": "Beatles"},
criteria.Contains{"artist": "Kraftwerk"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("media_file.title LIKE ?"))
Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?"))
Expect(args).To(HaveExactElements("%Love%", "artist", "%Beatles%", "%Kraftwerk%"))
})
It("merges multiple tag conditions in an OR group into a single EXISTS", func() {
expr := criteria.Any{
criteria.Contains{"genre": "Rock"},
criteria.Contains{"genre": "Metal"},
criteria.Contains{"genre": "Punk"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal("(exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and (value LIKE ? OR value LIKE ? OR value LIKE ?)))"))
Expect(args).To(HaveExactElements("%Rock%", "%Metal%", "%Punk%"))
})
It("does not merge tag conditions from different tags", func() {
expr := criteria.Any{
criteria.Contains{"genre": "Rock"},
criteria.Contains{"mood": "Happy"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
Expect(err).ToNot(HaveOccurred())
sql, _, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(strings.Count(sql, "exists")).To(Equal(2))
})
It("does not merge negated tag conditions", func() {
expr := criteria.Any{
criteria.NotContains{"genre": "Rock"},
criteria.NotContains{"genre": "Metal"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
Expect(err).ToNot(HaveOccurred())
sql, _, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(strings.Count(sql, "not exists")).To(Equal(2))
})
It("merges role and tag conditions independently", func() {
expr := criteria.Any{
criteria.Contains{"artist": "Beatles"},
criteria.Contains{"artist": "Kraftwerk"},
criteria.Contains{"genre": "Rock"},
criteria.Contains{"genre": "Metal"},
}
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
// Two merged EXISTS: one for roles, one for tags
Expect(strings.Count(sql, "exists")).To(Equal(2))
Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?"))
Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?"))
Expect(args).To(HaveLen(2 + 2 + 1)) // 2 tag patterns + 2 role patterns + 1 role name
})
})
Describe("joins", func() {
It("excludes sort-only joins from expression joins", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"}

View File

@ -14,9 +14,8 @@ type genreRepository struct {
}
func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository {
genreFilter := model.TagGenre
return &genreRepository{
baseTagRepository: newBaseTagRepository(ctx, db, &genreFilter),
baseTagRepository: newBaseTagRepository(ctx, db, new(model.TagGenre)),
}
}

View File

@ -13,7 +13,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
@ -103,7 +102,7 @@ var (
songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
AlbumID: "103",
Path: p("kraft/radio/antenna.mp3"),
RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(4.0),
RGAlbumGain: new(1.0), RGAlbumPeak: new(2.0), RGTrackGain: new(3.0), RGTrackPeak: new(4.0),
})
songAntennaWithLyrics = mf(model.MediaFile{
ID: "1005",
@ -162,8 +161,6 @@ func p(path string) string {
return filepath.FromSlash(path)
}
// Initialize test DB
// TODO Load this data setup from file(s)
var _ = BeforeSuite(func() {
conn := GetDBXBuilder()
ctx := log.NewContext(context.TODO())
@ -187,8 +184,7 @@ var _ = BeforeSuite(func() {
alr := NewAlbumRepository(ctx, conn).(*albumRepository)
for i := range testAlbums {
a := testAlbums[i]
err := alr.Put(&a)
err := alr.Put(new(testAlbums[i]))
if err != nil {
panic(err)
}
@ -196,8 +192,7 @@ var _ = BeforeSuite(func() {
arr := NewArtistRepository(ctx, conn)
for i := range testArtists {
a := testArtists[i]
err := arr.Put(&a)
err := arr.Put(new(testArtists[i]))
if err != nil {
panic(err)
}
@ -243,8 +238,7 @@ var _ = BeforeSuite(func() {
rar := NewRadioRepository(ctx, conn)
for i := range testRadios {
r := testRadios[i]
err := rar.Put(&r)
err := rar.Put(new(testRadios[i]))
if err != nil {
panic(err)
}

View File

@ -89,8 +89,7 @@ func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error)
sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId})
var res playQueue
err := r.queryOne(sel, &res)
q := r.toModel(&res)
return &q, err
return new(r.toModel(&res)), err
}
func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue {

View File

@ -11,10 +11,6 @@ import (
. "github.com/onsi/gomega"
)
var (
NewId string = "123-456-789"
)
var _ = Describe("RadioRepository", func() {
var repo model.RadioRepository
@ -34,8 +30,7 @@ var _ = Describe("RadioRepository", func() {
}
for i := range testRadios {
r := testRadios[i]
err := repo.Put(&r)
err := repo.Put(new(testRadios[i]))
if err != nil {
panic(err)
}
@ -140,7 +135,7 @@ var _ = Describe("RadioRepository", func() {
It("returns an existing item", func() {
res, err := repo.Get(radioWithHomePage.ID)
Expect(err).To((BeNil()))
Expect(err).To(BeNil())
Expect(res.ID).To(Equal(radioWithHomePage.ID))
})

View File

@ -37,8 +37,7 @@ var _ = Describe("KVStoreService", func() {
conf.Server.DataFolder = conf.NewDir(tmpDir)
// Create service with 1KB limit for testing
maxSize := "1KB"
service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize})
service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")})
Expect(err).ToNot(HaveOccurred())
})
@ -253,8 +252,7 @@ var _ = Describe("KVStoreService", func() {
// Close and reopen the service (simulating restart)
Expect(service.Close()).To(Succeed())
maxSize := "1KB"
service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize})
service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")})
Expect(err).ToNot(HaveOccurred())
defer service2.Close()
@ -452,8 +450,7 @@ var _ = Describe("KVStoreService", func() {
closeCtx, closeCancel := context.WithCancel(ctx)
defer closeCancel()
maxSize := "1KB"
svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: &maxSize})
svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: new("1KB")})
Expect(err).ToNot(HaveOccurred())
// Insert an expired key so cleanup has work to do

View File

@ -35,8 +35,7 @@ var _ = Describe("LibraryService", Ordered, func() {
Describe("GetLibrary", func() {
It("should return library metadata without filesystem permission", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl)
service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl)
lib := &model.Library{
ID: 1,
@ -67,8 +66,7 @@ var _ = Describe("LibraryService", Ordered, func() {
})
It("should return library metadata with filesystem permission", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl)
service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl)
lib := &model.Library{
ID: 2,
@ -93,8 +91,7 @@ var _ = Describe("LibraryService", Ordered, func() {
})
It("should return error for non-existent library", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason}, nil, true).(*libraryServiceImpl)
service = newLibraryService(ds, &LibraryPermission{Reason: new("test")}, nil, true).(*libraryServiceImpl)
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(model.Libraries{})
@ -107,8 +104,7 @@ var _ = Describe("LibraryService", Ordered, func() {
Describe("GetAllLibraries", func() {
It("should return all libraries without filesystem permission", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl)
service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@ -130,8 +126,7 @@ var _ = Describe("LibraryService", Ordered, func() {
})
It("should return all libraries with filesystem permission", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl)
service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@ -152,10 +147,8 @@ var _ = Describe("LibraryService", Ordered, func() {
})
Describe("Library Access Filtering", func() {
It("should only return libraries in the allowed list", func() {
reason := "test"
// Only allow library ID 2
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
It("should only return libraries in the allowed list", func() { // Only allow library ID 2
service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@ -173,10 +166,8 @@ var _ = Describe("LibraryService", Ordered, func() {
Expect(results[0].Name).To(Equal("Jazz"))
})
It("should return error when getting a library not in the allowed list", func() {
reason := "test"
// Only allow library ID 2
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
It("should return error when getting a library not in the allowed list", func() { // Only allow library ID 2
service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@ -192,10 +183,8 @@ var _ = Describe("LibraryService", Ordered, func() {
Expect(err.Error()).To(ContainSubstring("not accessible"))
})
It("should allow access to a library in the allowed list", func() {
reason := "test"
// Only allow library ID 2
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
It("should allow access to a library in the allowed list", func() { // Only allow library ID 2
service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@ -211,10 +200,8 @@ var _ = Describe("LibraryService", Ordered, func() {
Expect(result.Name).To(Equal("Jazz"))
})
It("should return empty list when no libraries are allowed and allLibraries is false", func() {
reason := "test"
// No libraries allowed
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{}, false).(*libraryServiceImpl)
It("should return empty list when no libraries are allowed and allLibraries is false", func() { // No libraries allowed
service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{}, false).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@ -229,10 +216,8 @@ var _ = Describe("LibraryService", Ordered, func() {
Expect(results).To(HaveLen(0))
})
It("should return all libraries when allLibraries is true regardless of allowed list", func() {
reason := "test"
// allLibraries=true should ignore the allowed list
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{1}, true).(*libraryServiceImpl)
It("should return all libraries when allLibraries is true regardless of allowed list", func() { // allLibraries=true should ignore the allowed list
service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{1}, true).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},

View File

@ -302,8 +302,7 @@ func (s *webSocketServiceImpl) readLoop(ctx context.Context, connectionID string
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
closeCode := websocket.CloseNoStatusReceived
closeReason := ""
var ce *websocket.CloseError
if errors.As(err, &ce) {
if ce, ok := errors.AsType[*websocket.CloseError](err); ok {
closeCode = ce.Code
closeReason = ce.Text
}

View File

@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"strconv"
"time"
"github.com/dustin/go-humanize"
@ -143,7 +144,7 @@ var _ = Describe("purgeCacheBySize", func() {
// Create 5 files, 1MiB each (total 5MiB)
for i := range 5 {
path := filepath.Join(cacheDir, filepath.Join("dir", "file"+string(rune('0'+i))+".bin"))
path := filepath.Join(cacheDir, filepath.Join("dir", "file"+strconv.Itoa(i)+".bin"))
createFileWithSize(path, 1*1024*1024, now.Add(-time.Duration(5-i)*time.Hour))
}

View File

@ -140,11 +140,10 @@ var _ = Describe("Manifest", func() {
})
It("returns true when threads feature has a reason", func() {
reason := "Required for concurrent processing"
m := &Manifest{
Experimental: &Experimental{
Threads: &ThreadsFeature{
Reason: &reason,
Reason: new("Required for concurrent processing"),
},
},
}

View File

@ -135,12 +135,11 @@ var _ = Describe("ndpPackage", func() {
Describe("readManifest", func() {
It("should read only the manifest without loading wasm", func() {
ndpPath := filepath.Join(tmpDir, "test.ndp")
desc := "A test plugin"
manifest := &Manifest{
Name: "Test Plugin",
Author: "Test Author",
Version: "1.0.0",
Description: &desc,
Description: new("A test plugin"),
}
wasmBytes := make([]byte, 1024*1024) // 1MB of zeros

723
resources/i18n/et.json Normal file
View File

@ -0,0 +1,723 @@
{
"languageName": "eesti keel",
"resources": {
"song": {
"name": "Laul |||| Laulud",
"fields": {
"albumArtist": "Albumi esitaja",
"duration": "Kestus",
"trackNumber": "Nr",
"playCount": "Esituskordi",
"title": "Pealkiri",
"artist": "Esitaja",
"album": "Album",
"path": "Faili asukoht",
"genre": "Žanr",
"compilation": "Kogumik",
"year": "Aasta",
"size": "Faili suurus",
"updatedAt": "Uuendatud",
"bitRate": "Bitikiirus",
"discSubtitle": "Plaadi alapealkiri",
"starred": "Märgi lemmikuks",
"comment": "Kommentaar",
"rating": "Hinnang",
"quality": "Kvaliteet",
"bpm": "BPM",
"playDate": "Viimati esitatud",
"channels": "Kanaleid",
"createdAt": "Lisamise kuupäev",
"grouping": "Rühmitamine",
"mood": "Meeleolu",
"participants": "Täiendavad osalejad",
"tags": "Täiendavad sildid",
"mappedTags": "Tuvastatud sildid",
"rawTags": "Sildid töötlemata vaates",
"bitDepth": "Bitisügavus",
"sampleRate": "Diskreetmisagedus",
"missing": "Puudub",
"libraryName": "Kogumik",
"composer": "Helilooja",
"disc": "%{discNumber}. plaat",
"albumGain": "Albumikohane esitusvaljuse tundlikkus",
"trackGain": "Rajakohane esitusvaljuse tundlikkus"
},
"actions": {
"addToQueue": "Esita hiljem",
"playNow": "Esita kohe",
"addToPlaylist": "Lisa esitusloendisse",
"shuffleAll": "Sega kõik",
"download": "Laadi alla",
"playNext": "Esita järgmisena",
"info": "Loo teave",
"showInPlaylist": "Näita esitusloendis",
"instantMix": "Kohene miks"
}
},
"album": {
"name": "Album |||| Albumid",
"fields": {
"albumArtist": "Albumi esitaja",
"artist": "Esitaja",
"duration": "Kestus",
"songCount": "laulu",
"playCount": "Esituskordi",
"name": "Nimi",
"genre": "Žanr",
"compilation": "Kogumik",
"year": "Aasta",
"updatedAt": "Uuendatud",
"comment": "Kommentaar",
"rating": "Hinnangud",
"createdAt": "Lisamise kuupäev",
"size": "Suurus",
"originalDate": "Originaal",
"releaseDate": "Avaldatud",
"releases": "Väljalase ||| Väljalasked",
"released": "Avaldatud",
"recordLabel": "Plaadifirma",
"catalogNum": "Tunnus kataloogides",
"releaseType": "Tüüp",
"grouping": "Grupeerimine",
"media": "Meedium",
"mood": "Meeleolu",
"date": "Salvestuskuupäev",
"missing": "Puudu",
"libraryName": "Kogumik"
},
"actions": {
"playAll": "Esita",
"playNext": "Esita järgmisena",
"addToQueue": "Esita hiljem",
"shuffle": "Sega lood",
"addToPlaylist": "Lisa esitusloendisse",
"download": "Laadi alla",
"info": "Albumi teave",
"share": "Jaga"
},
"lists": {
"all": "Kõik",
"random": "Juhuslik",
"recentlyAdded": "Hiljuti lisatud",
"recentlyPlayed": "Hiljuti esitatud",
"mostPlayed": "Enimesitatud",
"starred": "Lemmikud",
"topRated": "Kõrgeima hinnanguga"
}
},
"artist": {
"name": "Esitaja |||| Esitajad",
"fields": {
"name": "Nimi",
"albumCount": "Albumeid",
"songCount": "Lugusid",
"playCount": "Esituskordi",
"rating": "Hinnang",
"genre": "Žanr",
"size": "Suurus",
"role": "Roll",
"missing": "Puudub"
},
"roles": {
"albumartist": "Albumi esitaja ||| Albumi esitajad",
"artist": "Esitaja ||| Esitajad",
"composer": "Helilooja ||| Heliloojad",
"conductor": "Dirigent ||| Dirigendid",
"lyricist": "Laulusõnade autor ||| Laulusõnade autorid",
"arranger": "Seade autor ||| Seade autorid",
"producer": "Produtsent ||| Produtsendid",
"director": "Lavastaja ||| Lavastajad",
"engineer": "Helirežissöör ||| Helirežissöörid",
"mixer": "Miksija ||| Miksijad",
"remixer": "Remiksija ||| Remiksijad",
"djmixer": "DJ-versiooni remiksija ||| DJ-versiooni remiksijad",
"performer": "Esineja ||| Esinejad",
"maincredit": "Albumi esitaja või Esitaja ||| Albumi esitajad või Esitajad"
},
"actions": {
"shuffle": "Sega",
"radio": "Raadio",
"topSongs": "Populaarsed lood"
}
},
"user": {
"name": "Kasutaja |||| Kasutajad",
"fields": {
"userName": "Kasutajanimi",
"isAdmin": "On peakasutaja",
"lastLoginAt": "Viimane sisselogimine",
"updatedAt": "Uuendatud",
"name": "Nimi",
"password": "Salasõna",
"createdAt": "Loodud",
"changePassword": "Kas soovid salasõna muuta?",
"currentPassword": "Senine salasõna",
"newPassword": "Uus salasõna",
"token": "Tunnusluba",
"lastAccessAt": "Viimasti 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"
},
"notifications": {
"created": "Kasutaja on lisatud",
"updated": "Kasutaja andmed on uuendatud",
"deleted": "Kasutaja on kustutatud"
},
"message": {
"listenBrainzToken": "Sisesta oma ListenBrainzi tunnusluba.",
"clickHereForToken": "Tunnusloa saamiseks klõpsi siin",
"selectAllLibraries": "Vali kõik kogumikud",
"adminAutoLibraries": "Peakasutajatel on automaatselt ligipääs kõikidele kogumikele"
},
"validation": {
"librariesRequired": "Vähemalt üks kogumik peab olema valitud muude, kui peakasutajate jaoks"
}
},
"player": {
"name": "Meediaesitaja |||| Meediaesitajad",
"fields": {
"name": "Nimi",
"transcodingId": "Teisendamine",
"maxBitRate": "Maksimaalne bitikiirus",
"client": "Klient",
"userName": "Kasutajanimi",
"lastSeen": "Viimati nähtud",
"reportRealPath": "Teata tegelikust asukohast",
"scrobbleEnabled": "Saada kraasimisandmed välistesse teenustesse"
}
},
"transcoding": {
"name": "Teisendamine |||| Teisendamised",
"fields": {
"name": "Nimi",
"targetFormat": "Sihtvorming",
"defaultBitRate": "Vaikimisi bitikiirus",
"command": "Käsk"
}
},
"playlist": {
"name": "Esitusloend ||| Esitusloendid",
"fields": {
"name": "Nimi",
"duration": "Kestus",
"ownerName": "Omanik",
"public": "Avalik",
"updatedAt": "Muudetud",
"createdAt": "Loodud",
"songCount": "Lood",
"comment": "Kommentaar",
"sync": "Automaatne import",
"path": "Impordi siit"
},
"actions": {
"selectPlaylist": "Valo esitusloend:",
"addNewPlaylist": "Loo „%{name}\"“",
"export": "Ekspordi",
"makePublic": "Muuda avalikuks",
"makePrivate": "Muuda privaatseks",
"saveQueue": "Salvesta esitusjärjekord esitusloendina",
"searchOrCreate": "Otsi esitusloendeid või uue loomiseks sisesta nimi...",
"pressEnterToCreate": "Uue esitusloendi lisamiseks vajuta sisestusklahvi",
"removeFromSelection": "Eemalda valikust"
},
"message": {
"duplicate_song": "Lisa topeltlood",
"song_exist": "Tundub, et oled esitusloendisse lisamas topeltkirjeid. Kas tahad nii jätkata või soovid topeltkirjed vahele jätta?",
"noPlaylistsFound": "Esitusloendeid ei leidu",
"noPlaylists": "Esitusloendeid pole saadaval"
}
},
"radio": {
"name": "Raadio ||| Raadiod",
"fields": {
"name": "Nimi",
"streamUrl": "Voogedastuse võrguaadress",
"homePageUrl": "Avalehe võrguaadress",
"updatedAt": "Uuendatud",
"createdAt": "Lisatud"
},
"actions": {
"playNow": "Esita kohe"
}
},
"share": {
"name": "Jagamine ||| Jagamised",
"fields": {
"username": "Seda jagas",
"url": "Võrguaadress",
"description": "Kirjeldus",
"contents": "Sisu",
"expiresAt": "Aegub",
"lastVisitedAt": "Viimati vaadatud",
"visitCount": "Külastusi",
"format": "Vorming",
"maxBitRate": "Maksimaalne bitikiirus",
"updatedAt": "Muudetud",
"createdAt": "Lisatud",
"downloadable": "Kas lubad allalaadimised?"
}
},
"missing": {
"name": "Puuduv fail ||| Puuduvad failid",
"fields": {
"path": "Asukoht",
"size": "Suurus",
"updatedAt": "Kadumise aeg",
"libraryName": "Kogumik"
},
"actions": {
"remove": "Eemalda",
"remove_all": "Eemalda kõik"
},
"notifications": {
"removed": "Puuduv(ad) fail(id) on eemaldatud"
},
"empty": "Puuduvaid faile pole"
},
"library": {
"name": "Kogumik ||| Kogumikud",
"fields": {
"name": "Nimi",
"path": "Asukoht",
"remotePath": "Asukoht kaugseadmes",
"lastScanAt": "Viimane skaneerimine",
"songCount": "Lood",
"albumCount": "Albumid",
"artistCount": "Esitajad",
"totalSongs": "Lood",
"totalAlbums": "Albumid",
"totalArtists": "Esitajad",
"totalFolders": "Kaustad",
"totalFiles": "Failid",
"totalMissingFiles": "Puuduvad failid",
"totalSize": "Kogumaht",
"totalDuration": "Kestus",
"defaultNewUsers": "Vaikimisi väärtus uutele kasutajatele",
"createdAt": "Lisatud",
"updatedAt": "Muudetud"
},
"sections": {
"basic": "Põhiteave",
"statistics": "Statistika"
},
"actions": {
"scan": "Skaneeri kogumikku",
"manageUsers": "Halda kasutajate õigusi",
"viewDetails": "Vaata üksikasju",
"quickScan": "Kiirskaneerimine",
"fullScan": "Täismahuline skaneerimine"
},
"notifications": {
"created": "Kogumiku loomine õnnestus",
"updated": "Kogumiku uuendamine õnnestus",
"deleted": "Kogumiku kustutamine õnnestus",
"scanStarted": "Kogumiku skaneerimine algas",
"scanCompleted": "Kogumiku skaneerimine lõppes",
"quickScanStarted": "Kiirskaneerimine algas",
"fullScanStarted": "Täismahuline skaneerimine algas",
"scanError": "Viga skaneerimise käivitamisel. Lisateavet leiad logidest"
},
"validation": {
"nameRequired": "Pead sisestama kogumiku nime",
"pathRequired": "Pead sisestama kogumiku asukoha",
"pathNotDirectory": "Kogumiku asukoht peab olema kaust",
"pathNotFound": "Kogumiku asukoha kausta ei leidu",
"pathNotAccessible": "Kogumiku asukoha kaust pole ligipääsetav",
"pathInvalid": "Vigane kogumiku asukoha kaust"
},
"messages": {
"deleteConfirm": "Kas oled kindel, et soovid selle kogumiku kustutada? Samaga eemaldad ka kõik seotud andmed ja kasutajate ligipääsu.",
"scanInProgress": "Skaneerimine on pooleli...",
"noLibrariesAssigned": "Selle kasutajaga pole veel ühtegi kogumikku seotud"
}
},
"plugin": {
"name": "Lisamoodul |||| Lisamoodulid",
"fields": {
"id": "Tunnus",
"name": "Nimi",
"description": "Kirjeldus",
"version": "Versioon",
"author": "Autor",
"website": "Veebisait",
"permissions": "Õigused",
"enabled": "Kasutusel",
"status": "Olek",
"path": "Asukoht",
"lastError": "Viga",
"hasError": "Viga",
"updatedAt": "Uuendatud",
"createdAt": "Paigaldatud",
"configKey": "Võti",
"configValue": "Väärtus",
"allUsers": "Luba kõiki kasutajaid",
"selectedUsers": "Valitud kasutajad",
"allLibraries": "Luba kõik kogumikud",
"selectedLibraries": "Valitud kogumikud",
"allowWriteAccess": "Luba kirjutusõigused"
},
"sections": {
"status": "Olek",
"info": "Lisamooduli teave",
"configuration": "Seadistus",
"manifest": "Manifest",
"usersPermission": "Kasutajate õigused",
"libraryPermission": "Kogumike õigused"
},
"status": {
"enabled": "Kasutusel",
"disabled": "Pole kasutusel"
},
"actions": {
"enable": "Võta kasutusele",
"disable": "Eemalda kasutuselt",
"disabledDueToError": "Enne kasutuselevõtmist paranda viga",
"disabledUsersRequired": "Enne kasutuselevõtmist vali kasutajad",
"disabledLibrariesRequired": "Enne kasutuselevõtmist vali kogumikud",
"addConfig": "Lisa seadistus",
"rescan": "Skaneeri uuesti"
},
"notifications": {
"enabled": "Lisamoodul on kasutusel",
"disabled": "Lisamoodul pole kasutusel",
"updated": "Lisamoodul on uuendatud",
"error": "Viga lisamooduli uuendamisel"
},
"validation": {
"invalidJson": "Seadistus peab olema koostatud korrektses JSON-vormingus"
},
"messages": {
"configHelp": "Seadista lisamoodulit võti-väärtus paaride abil. Kui lisamoodul seadistamist ei vaja, siis jäta tühjaks.",
"clickPermissions": "Üksikasjade vaatamiseks klõpsa õigust",
"noConfig": "Ühtegi seadistust pole määratud",
"allUsersHelp": "Kasutuselevõetuna on sellel lisamoodulil ligipääs kõikidele kasutajatele, sealhulgas tulevikus loodavatele.",
"noUsers": "Ühtegi kasutajat pole valitud",
"permissionReason": "Põhjus",
"usersRequired": "See lisamoodul vajab ligipääsu kasutajate teabele. Vali kasutajad, millele ta ligi peaks saama või vali „Kõik kasutajad“.",
"allLibrariesHelp": "Kasutuselevõetuna on sellel lisamoodulil ligipääs kõikidele kogumikele, sealhulgas tulevikus loodavatele.",
"noLibraries": "Ühtegi kogumikku pole valitud",
"librariesRequired": "See lisamoodul vajab ligipääsu kogumiku teabele. Vali kogumikud, millele ta ligi peaks saama või vali „Kõik kogumikud“.",
"requiredHosts": "Nõutavad hostid",
"configValidationError": "Seadistuse õigsuse kontrollimine ei õnnestunud:",
"schemaRenderError": "Seadistuste vormi lugemine ja töötlemine ei õnnestunud. Lisamooduli ülesehitus/skeem võib olla vigane.",
"allowWriteAccessHelp": "Kui valik on kasutusel, siis lisamoodul võib muuta vaid faile kogumike kaustades. Vaikimisi on lisamoodulitel vaid lugemisõigus."
},
"placeholders": {
"configKey": "võti",
"configValue": "väärtus"
}
}
},
"ra": {
"auth": {
"welcome1": "Aitäh, et paigaldasite Navidrome'i!",
"welcome2": "Alustamiseks lisa peakasutaja",
"confirmPassword": "Korda salasõna",
"buttonCreateAdmin": "Loo admin",
"auth_check_error": "Jätkamiseks palun logi sisse",
"user_menu": "Profiil",
"username": "Kasutajanimi",
"password": "Salasõna",
"sign_in": "Logi sisse",
"sign_in_error": "Tuvastamine ei toiminud, palun proovi uuesti",
"logout": "Logi välja",
"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",
"passwordDoesNotMatch": "Salasõnad ei kattu",
"required": "Nõutav",
"minLength": "Pikkus peab olema vähemalt %{min} tähemärki",
"maxLength": "Pikkus ei tohi olla üle %{max} tähemärgi",
"minValue": "Väärtus peab olema vähemalt %{min}",
"maxValue": "Väärtus ei tohi olla enam, kui %{max}",
"number": "Sisend peab olema number",
"email": "Sisend peab korrektne e-posti aadress",
"oneOf": "Väärtus peab olema üks järgnevaist: %{options}",
"regex": "Väärtus peab vastama kindlale vormingule (regulaaravaldis): %{pattern}",
"unique": "Sisend peab olema unikaalne",
"url": "Sisend peab olema korrektne võrguaadress"
},
"action": {
"add_filter": "Lisa filter",
"add": "Lisa",
"back": "Mine tagasi",
"bulk_actions": "1 objekt on valitud |||| %{smart_count} objekti on valitud",
"cancel": "Katkesta",
"clear_input_value": "Eemalda väärtus",
"clone": "Klooni",
"confirm": "Kinnita",
"create": "Loo",
"delete": "Kustuta",
"edit": "Muuda",
"export": "Ekspordi",
"list": "Loend",
"refresh": "Uuenda andmed",
"remove_filter": "Eemalda see filter",
"remove": "Eemalda",
"save": "Salvesta",
"search": "Otsi",
"show": "Näita",
"sort": "Järjesta",
"undo": "Võta tegevus tagasi",
"expand": "Laienda",
"close": "Sulge",
"open_menu": "Ava menüü",
"close_menu": "Sulge menüü",
"unselect": "Eemalda valik",
"skip": "Jäta vahele",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Jaga",
"download": "Laadi alla"
},
"boolean": {
"true": "Jah",
"false": "Ei"
},
"page": {
"create": "Loo %{name}",
"dashboard": "Töölaud",
"edit": "%{name} #%{id}",
"error": "Midagi läks valesti",
"list": "%{name}",
"loading": "Laadin",
"not_found": "Ei leidu",
"show": "%{name} #%{id}",
"empty": "Nimi on veel puudu - %{name}.",
"invite": "Kas sa sooviksid ühe sellise lisada?"
},
"input": {
"file": {
"upload_several": "Lohista üleslaadimiseks mõned failid või vali üks failivalijast.",
"upload_single": "Lohista üleslaadimiseks fail või vali ta failivalijast."
},
"image": {
"upload_several": "Lohista üleslaadimiseks mõned pildid või vali üks failivalijast.",
"upload_single": "Lohista üleslaadimiseks pilt või vali ta failivalijast."
},
"references": {
"all_missing": "Viitenumbrite andmeid ei leidu.",
"many_missing": "Vähemalt üks seotud viide ei tundu enam olema saadaval.",
"single_missing": "Seotud viide ei tundu enam olema saadaval."
},
"password": {
"toggle_visible": "Peida salasõna",
"toggle_hidden": "Näita salasõna"
}
},
"message": {
"about": "Teave",
"are_you_sure": "Kas oled kindel?",
"bulk_delete_content": "Kas sa oled kindel, et soovid kustutada selle objekti - %{name}? |||| Kas sa oled kindel, et soovid kustutada need %{smart_count} objekti?",
"bulk_delete_title": "Kustuta %{name} |||| Kustuta %{name} - %{smart_count} kirjet",
"delete_content": "Kas oled kindel, et soovid selle objekti kustutada?",
"delete_title": "Kustuta %{name} #%{id}",
"details": "Üksikasjad",
"error": "Tekkis klientrakenduse viga ja päringut polnud võimalik lõpetada.",
"invalid_form": "Vormi andmed pole õiged. Palun kontrolli sisestusi",
"loading": "Leht on just laadimisel, palun oota hetke",
"no": "Ei",
"not_found": "Sa kas sisestasid vigase võrguaadressi või klõpsisid vigast linki.",
"yes": "Jah",
"unsaved_changes": "Mõned sinu muudatused pole salvestatud. Kas sa soovid neist loobuda?"
},
"navigation": {
"no_results": "Tulemusi ei leidu",
"no_more_results": "Lehe number %{page} on väljaspool etteantud piire. Proovi eelmist lehte.",
"page_out_of_boundaries": "Lehe number %{page} on väljaspool etteantud piire",
"page_out_from_end": "Viimasest lehest ei saa edasi minna",
"page_out_from_begin": "Esimese lehe ette ei saa minna",
"page_range_info": "%{offsetBegin}-%{offsetEnd} - kokku %{total}",
"page_rows_per_page": "Kirjeid lehel:",
"next": "Edasi",
"prev": "Tagasi",
"skip_nav": "Mine sisu juurde"
},
"notification": {
"updated": "Objekt on uuendatud |||| %{smart_count} objekti on uuendatud",
"created": "Objekt on loodud",
"deleted": "Objekt on kustutatud |||| %{smart_count} objekti on kustutatud",
"bad_item": "Vigane objekt",
"item_doesnt_exist": "Objekti pole olemas",
"http_error": "Viga suhtlemisel serveriga",
"data_provider_error": "Andmeteenusepakkuja viga. Lisateavet leiad brauseri konsoolist.",
"i18n_error": "Vastava keele tõlget ei saa laadida",
"canceled": "Tegevus on tühistatud",
"logged_out": "Sinu sessioon on lõppenud, palun ühenda uuesti.",
"new_version": "Uus versioon on saadaval! Palun laadi see vaade uuesti."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Kuvatavad veerud",
"layout": "Paigutus",
"grid": "Ruudustik",
"table": "Tabel"
}
},
"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.",
"songsAddedToPlaylist": "Lisasin ühe loo esitusloendisse |||| Lisasin %{smart_count} lugu esitusloendisse",
"noPlaylistsAvailable": "Pole saadaval",
"delete_user_title": "Kustuta kasutaja „%{name}“",
"delete_user_content": "Kas oled kindel, et soovid selle kasutaja ja kõik tema andmed (sh esitusloendid ja eelistused) kustutada?",
"notifications_blocked": "Sa oled selle saidi teavitused veebibrauseri seadistusest keelanud",
"notifications_not_available": "See veebibrauser kas ei toeta töölauateavitusi või sa ei kasuta Navidrome'i üle https-protokolli",
"lastfmLinkSuccess": "Last.fm-i seos on lisatud ja kraasimine on lülitatud sisse",
"lastfmLinkFailure": "Last.fm-i seose lisamine ei õnnestunud",
"lastfmUnlinkSuccess": "Last.fm-i seos on eemaldatud ja kraasimine on lülitatud välja",
"lastfmUnlinkFailure": "Last.fm-i seose eemaldamine ei õnnestunud",
"openIn": {
"lastfm": "Ava Last.fm-is",
"musicbrainz": "Ava MusicBrainzis"
},
"lastfmLink": "Lisateave...",
"listenBrainzLinkSuccess": "ListenBrainzi seos on lisatud ja kraasimine on lülitatud sisse kasutajana: %{user}",
"listenBrainzLinkFailure": "ListenBrainzi seose lisamine ei õnnestunud: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainzi seos on eemaldatud ja kraasimine on lülitatud välja",
"listenBrainzUnlinkFailure": "ListenBrainzi seose eemaldamine ei õnnestunud",
"downloadOriginalFormat": "Laadi alla algses vormingus",
"shareOriginalFormat": "Jaga algses vormingus",
"shareDialogTitle": "Jaga - %{resource} „%{name}“",
"shareBatchDialogTitle": "Jaga - %{resource} |||| Jaga %{smart_count} kirjet - %{resource}",
"shareSuccess": "Võrguaadress on kopeeritud lõikelauale: %{url}",
"shareFailure": "Viga %{url} võrguaadressi kopeerimisel lõikelauale",
"downloadDialogTitle": "Laadi alla - %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopeeri lõikelauale: Ctrl+C, sisestusklahv",
"remove_missing_title": "Eemalda puuduvad failid",
"remove_missing_content": "Kas sa oled kindel, et soovid valitud puuduvate failide andmed andmebaasist eemaldada? Sellega kaovad kõik viited neile, sealhulgas esituskordade ja hinnangute märked.",
"remove_all_missing_title": "Eemalda kõik puuduvad failid",
"remove_all_missing_content": "Kas sa oled kindel, et soovid kõik puuduvate failide andmed andmebaasist eemaldada? Sellega kaovad kõik viited neile, sealhulgas esituskordade ja hinnangute märked.",
"noSimilarSongsFound": "Sarnaseid lugusid ei leidu",
"noTopSongsFound": "Populaarsemaid lugusid ei leidu",
"startingInstantMix": "Laadin kohest miksi...",
"uploadCover": "Laadi kaanepilt üles",
"removeCover": "Eemalda kaanepilt",
"coverUploaded": "Kaanepilt on uuendatud",
"coverRemoved": "Kaanepilt on eemaldatud",
"coverUploadError": "Viga kaanepildi üleslaadimisel",
"coverRemoveError": "Viga kaanepildi eemaldamisel"
},
"menu": {
"library": "Kogumik",
"settings": "Seaded",
"version": "Versioon",
"theme": "Teema",
"personal": {
"name": "Isiklik",
"options": {
"theme": "Teema",
"language": "Keel",
"defaultView": "Vaikimisi vaade",
"desktop_notifications": "Teavitused töölaual",
"lastfmScrobbling": "Kraasi Last.fm-i teenusesse",
"listenBrainzScrobbling": "Kraasi ListenBrainzi teenusesse",
"replaygain": "Esitusvaljuse tundlikkuse režiim",
"preAmp": "Esitusvaljuse tundlikkuse eelvõimendus (dB)",
"gain": {
"none": "Pole kasutusel",
"album": "Kasuta albumikohast esitusvaljuse tundlikkust",
"track": "Kasuta lookohast esitusvaljuse tundlikkust"
},
"lastfmNotConfigured": "Last.fm-i API-võti pole seadistatud"
}
},
"albumList": "Albumid",
"about": "Rakenduse teave",
"playlists": "Esitusloendid",
"sharedPlaylists": "Jagatud esitusloendid",
"librarySelector": {
"allLibraries": "Kõik kogumikud (%{count})",
"multipleLibraries": "%{selected} / %{total} kogumikest",
"selectLibraries": "Vali kogumikud",
"none": "Puudub"
}
},
"player": {
"playListsText": "Esitusjärjekord",
"openText": "Ava",
"closeText": "Sulge",
"notContentText": "Muusikat pole",
"clickToPlayText": "Klõpsa esitamiseks",
"clickToPauseText": "Klõpsa peatamiseks",
"nextTrackText": "Järgmine lugu",
"previousTrackText": "Eelmine lugu",
"reloadText": "Laadi uuesti",
"volumeText": "Helivaljus",
"toggleLyricText": "Näita/peida laulusõnad",
"toggleMiniModeText": "Minimeeri",
"destroyText": "Hävita",
"downloadText": "Laadi alla",
"removeAudioListsText": "Kustuta heliloendid",
"clickToDeleteText": "„%{name}“ kustutamiseks klõpsa",
"emptyLyricText": "Laulusõnu pole",
"playModeText": {
"order": "Oma järjekorras",
"orderLoop": "Korda",
"singleLoop": "Korda üks kord",
"shufflePlay": "Sega lood"
}
},
"about": {
"links": {
"homepage": "Avaleht",
"source": "Lähtekood",
"featureRequests": "Arendusettepanekud",
"lastInsightsCollection": "Viimati kogutud statistika",
"insights": {
"disabled": "Pole kasutusel",
"waiting": "Ootel"
}
},
"tabs": {
"about": "Teave",
"config": "Seadistus"
},
"config": {
"configName": "Seadistuse nimi",
"environmentVariable": "Keskkonnamuutuja",
"currentValue": "Praegune väärtus",
"configurationFile": "Seadistusfail",
"exportToml": "Ekspordi seadistused (TOML-failina)",
"exportSuccess": "Seadistused on eksporditud lõikelauale TOML-failina",
"exportFailed": "Seadistuse kopeerimine ei õnnestunud",
"devFlagsHeader": "Arendusparameetrid (võivad muutuda või sootuks kaduda)",
"devFlagsComment": "Need on katselised seadistused, mis võivad tulevastest versioonidest kaduda",
"downloadToml": "Laadi seadistused alla (TOML-failina)"
}
},
"activity": {
"title": "Tegevus",
"totalScanned": "Kokku skaneeritud kaustu",
"quickScan": "Kiirskaneerimine",
"fullScan": "Täisskaneerimine",
"serverUptime": "Serveri katkematu tööaeg",
"serverDown": "POLE VÕRGUS",
"scanType": "Tüüp",
"status": "Skaneerimisviga",
"elapsedTime": "Möödunud aeg",
"selectiveScan": "Valikuline"
},
"help": {
"title": "Navidrome'i kiirklahvid",
"hotkeys": {
"show_help": "Näita seda abiteavet",
"toggle_menu": "Lülita menüü külgriba sisse/välja",
"toggle_play": "Esita / Peata esitus",
"prev_song": "Eelmine lugu",
"next_song": "Järgmine lugu",
"vol_up": "Heli valjemaks",
"vol_down": "Heli vaiksemaks",
"toggle_love": "Lisa see lugu lemmikute hulka",
"current_song": "Mine esitamisel loo juurde"
}
},
"nowPlaying": {
"title": "Hetkel esitamisel",
"empty": "Mitte midagi pole hetkel esitamisel",
"minutesAgo": "%{smart_count} minut tagasi |||| %{smart_count} minutit tagasi"
}
}

View File

@ -4,45 +4,54 @@
"song": {
"name": "Песма |||| Песме",
"fields": {
"album": "Албум",
"albumArtist": "Уметник албума",
"artist": "Уметник",
"bitDepth": "Битова",
"bitRate": "Битски проток",
"bpm": "BPM",
"channels": "Канала",
"comment": "Коментар",
"compilation": "Компилација",
"createdAt": "Датум додавања",
"discSubtitle": "Поднаслов диска",
"duration": "Трајање",
"trackNumber": "#",
"playCount": "Пуштано",
"title": "Наслов",
"artist": "Уметник",
"composer": "Композитор",
"album": "Албум",
"path": "Путања фајла",
"libraryName": "Библиотека",
"genre": "Жанр",
"compilation": "Компилација",
"year": "Година",
"size": "Величина фајла",
"updatedAt": "Ажурирано",
"bitRate": "Битски проток",
"bitDepth": "Битска дубина",
"sampleRate": "Учестаност узорковања",
"albumGain": "Појачање албума",
"trackGain": "Појачање нумере",
"channels": "Канали",
"disc": "Диск %{discNumber}",
"discSubtitle": "Поднаслов диска",
"starred": "Омиљено",
"comment": "Коментар",
"rating": "Рејтинг",
"quality": "Квалитет",
"bpm": "BPM",
"playDate": "Последње пуштано",
"createdAt": "Датум додавања",
"grouping": "Груписање",
"mappedTags": "Мапиране ознаке",
"mood": "Расположење",
"participants": "Додатни учесници",
"path": "Путања фајла",
"playCount": "Пуштано",
"playDate": "Последње пуштано",
"quality": "Квалитет",
"rating": "Рејтинг",
"rawTags": "Сирове ознаке",
"size": "Величина фајла",
"starred": "Омиљено",
"tags": "Додатне ознаке",
"title": "Наслов",
"trackNumber": "#",
"updatedAt": "Ажурирано",
"year": "Година"
"mappedTags": "Мапиране ознаке",
"rawTags": "Сирове ознаке",
"missing": "Недостаје"
},
"actions": {
"addToPlaylist": "Додај у плејлисту",
"addToQueue": "Пусти касније",
"download": "Преузми",
"info": "Прикажи инфо",
"playNext": "Пусти наредно",
"playNow": "Пусти одмах",
"shuffleAll": "Измешај све"
"addToPlaylist": "Додај у плејлисту",
"showInPlaylist": "Прикажи у плејлисти",
"shuffleAll": "Измешај све",
"download": "Преузми",
"playNext": "Пусти наредно",
"info": "Прикажи инфо",
"instantMix": "Инстант микс"
}
},
"album": {
@ -50,46 +59,48 @@
"fields": {
"albumArtist": "Уметник албума",
"artist": "Уметник",
"catalogNum": "Каталошки број",
"comment": "Коментар",
"compilation": "Компилација",
"createdAt": "Датум додавања",
"date": "Датум снимања",
"duration": "Трајање",
"songCount": "Песме",
"playCount": "Пуштано",
"size": "Величина",
"name": "Назив",
"libraryName": "Библиотека",
"genre": "Жанр",
"compilation": "Компилација",
"year": "Година",
"date": "Датум снимања",
"originalDate": "Оригинално",
"releaseDate": "Објављено",
"releases": "Издање|||| Издања",
"released": "Објављено",
"updatedAt": "Ажурирано",
"comment": "Коментар",
"rating": "Рејтинг",
"createdAt": "Датум додавања",
"recordLabel": "Издавачка кућа",
"catalogNum": "Каталошки број",
"releaseType": "Тип",
"grouping": "Груписање",
"media": "Медијум",
"mood": "Расположење",
"name": "Назив",
"originalDate": "Оригинално",
"playCount": "Пуштано",
"rating": "Рејтинг",
"recordLabel": "Издавачка кућа",
"releaseDate": "Објављено",
"releaseType": "Тип",
"released": "Објављено",
"releases": "Издање|||| Издања",
"size": "Величина",
"songCount": "Песме",
"updatedAt": "Ажурирано",
"year": "Година"
"missing": "Недостаје"
},
"actions": {
"addToPlaylist": "Додај у плејлисту",
"addToQueue": "Пусти касније",
"download": "Преузми",
"info": "Прикажи инфо",
"playAll": "Пусти",
"playNext": "Пусти наредно",
"addToQueue": "Пусти касније",
"share": "Дели",
"shuffle": "Измешај"
"shuffle": "Измешај",
"addToPlaylist": "Додај у плејлисту",
"download": "Преузми",
"info": "Прикажи инфо"
},
"lists": {
"all": "Све",
"mostPlayed": "Најчешће пуштано",
"random": "Насумично",
"recentlyAdded": "Додато недавно",
"recentlyPlayed": "Пуштано недавно",
"mostPlayed": "Најчешће пуштано",
"starred": "Омиљено",
"topRated": "Најбоље рангирано"
}
@ -97,116 +108,136 @@
"artist": {
"name": "Уметник |||| Уметници",
"fields": {
"albumCount": "Број албума",
"genre": "Жанр",
"name": "Назив",
"albumCount": "Број албума",
"songCount": "Број песама",
"size": "Величина",
"playCount": "Пуштано",
"rating": "Рејтинг",
"genre": "Жанр",
"role": "Улога",
"size": "Величина",
"songCount": "Број песама"
"missing": "Недостаје"
},
"roles": {
"albumartist": "Уметник албума |||| Уметници албума",
"arranger": "Аранжер |||| Аранжери",
"artist": "Уметник |||| Уметници",
"composer": "Композитор |||| Композитори",
"conductor": "Диригент |||| Диригенти",
"director": "Режисер |||| Режисери",
"djmixer": "Ди-џеј миксер |||| Ди-џеј миксер",
"engineer": "Инжењер |||| Инжењери",
"lyricist": "Текстописац |||| Текстописци",
"mixer": "Миксер |||| Миксери",
"performer": "Извођач |||| Извођачи",
"arranger": "Аранжер |||| Аранжери",
"producer": "Продуцент |||| Продуценти",
"remixer": "Ремиксер |||| Ремиксери"
"director": "Режисер |||| Режисери",
"engineer": "Инжењер |||| Инжењери",
"mixer": "Миксер |||| Миксери",
"remixer": "Ремиксер |||| Ремиксери",
"djmixer": "Ди-џеј миксер |||| Ди-џеј миксер",
"performer": "Извођач |||| Извођачи",
"maincredit": "Уметник албума или уметник |||| Уметници албума или уметници"
},
"actions": {
"topSongs": "Најбоље песме",
"shuffle": "Измешај",
"radio": "Радио"
}
},
"user": {
"name": "Корисник |||| Корисници",
"fields": {
"changePassword": "Измени лозинку?",
"createdAt": "Креирана",
"currentPassword": "Текућа лозинка",
"userName": "Корисничко име",
"isAdmin": "Да ли је Админ",
"lastAccessAt": "Последњи приступ",
"lastLoginAt": "Последња пријава",
"name": "Назив",
"newPassword": "Нова лозинка",
"password": "Лозинка",
"token": "Жетон",
"lastAccessAt": "Последњи приступ",
"updatedAt": "Ажурирано",
"userName": "Корисничко име"
"name": "Назив",
"password": "Лозинка",
"createdAt": "Креирана",
"changePassword": "Измени лозинку?",
"currentPassword": "Текућа лозинка",
"newPassword": "Нова лозинка",
"token": "Жетон",
"libraries": "Библиотеке"
},
"helperTexts": {
"name": "Измене вашег имена ће постати видљиве након следеће пријаве"
"name": "Измене вашег имена ће постати видљиве након следеће пријаве",
"libraries": "Изаберите одређене библиотеке за овог корисника, или оставите празно да се користе подразумеване библиотеке"
},
"notifications": {
"created": "Корисник креиран",
"deleted": "Корисник обрисан",
"updated": "Корисник ажуриран"
"updated": "Корисник ажуриран",
"deleted": "Корисник обрисан"
},
"validation": {
"librariesRequired": "Барем једна библиотека мора да буде изабрана за кориснике који нису администратори"
},
"message": {
"listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон.",
"clickHereForToken": "Кликните овде да преузмете свој жетон",
"listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон."
"selectAllLibraries": "Изабери све библиотеке",
"adminAutoLibraries": "Администратори аутоматски имају приступ свим библиотекама"
}
},
"player": {
"name": "Плејер |||| Плејери",
"fields": {
"client": "Клијент",
"lastSeen": "Последњи пут виђен",
"maxBitRate": "Макс. битски проток",
"name": "Назив",
"reportRealPath": "Пријављуј реалну путању",
"scrobbleEnabled": "Шаљи скроблове на спољне сервисе",
"transcodingId": "Транскодирање",
"userName": "Корисничко име"
"maxBitRate": "Макс. битски проток",
"client": "Клијент",
"userName": "Корисничко име",
"lastSeen": "Последњи пут виђен",
"reportRealPath": "Пријављуј реалну путању",
"scrobbleEnabled": "Шаљи скроблове на спољне сервисе"
}
},
"transcoding": {
"name": "Транскодирање |||| Транскодирања",
"fields": {
"command": "Команда",
"defaultBitRate": "Подразумевани битски проток",
"name": "Назив",
"targetFormat": "Циљни формат"
"targetFormat": "Циљни формат",
"defaultBitRate": "Подразумевани битски проток",
"command": "Команда"
}
},
"playlist": {
"name": "Плејлиста |||| Плејлисте",
"fields": {
"comment": "Коментар",
"createdAt": "Креирана",
"duration": "Трајање",
"name": "Назив",
"duration": "Трајање",
"ownerName": "Власник",
"path": "Увоз из",
"public": "Јавна",
"updatedAt": "Ажурирано",
"createdAt": "Креирана",
"songCount": "Песме",
"comment": "Коментар",
"sync": "Ауто-увоз",
"updatedAt": "Ажурирано"
"path": "Увоз из"
},
"actions": {
"selectPlaylist": "Изабери плејлисту",
"addNewPlaylist": "Креирај „%{name}”",
"export": "Извези",
"makePrivate": "Учини приватном",
"saveQueue": "Сачувај ред у плејлисту",
"makePublic": "Учини јавном",
"selectPlaylist": "Изабери плејлисту"
"makePrivate": "Учини приватном",
"searchOrCreate": "Претражите плејлисте или унесите назив за нову…",
"pressEnterToCreate": "Притисните Ентер да креирате нову плејлисту",
"removeFromSelection": "Уклони из избора"
},
"message": {
"duplicate_song": "Додај дуплиране песме",
"song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?"
"song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?",
"noPlaylistsFound": "Нема пронађених плејлиста",
"noPlaylists": "Нема доступних плејлиста"
}
},
"radio": {
"name": "Радио |||| Радији",
"name": "Радио |||| Радио-станице",
"fields": {
"createdAt": "Креирана",
"homePageUrl": "URL почетне странице",
"name": "Назив",
"streamUrl": "URL тока",
"updatedAt": "Ажурирано"
"homePageUrl": "URL почетне странице",
"updatedAt": "Ажурирано",
"createdAt": "Креирана"
},
"actions": {
"playNow": "Пусти одмах"
@ -215,18 +246,18 @@
"share": {
"name": "Дељење |||| Дељења",
"fields": {
"contents": "Садржај",
"createdAt": "Креирано",
"username": "Поделио",
"url": "URL",
"description": "Опис",
"downloadable": "Допушта се преузимање?",
"contents": "Садржај",
"expiresAt": "Истиче",
"format": "Формат",
"lastVisitedAt": "Последњи пут посећено",
"visitCount": "Број посета",
"format": "Формат",
"maxBitRate": "Макс. битски проток",
"updatedAt": "Ажурирано",
"url": "URL",
"username": "Поделио",
"visitCount": "Број посета"
"createdAt": "Креирано"
},
"notifications": {},
"actions": {}
@ -237,111 +268,246 @@
"fields": {
"path": "Путања",
"size": "Величина",
"libraryName": "Библиотека",
"updatedAt": "Нестао дана"
},
"actions": {
"remove": "Уклони"
"remove": "Уклони",
"remove_all": "Уклони све"
},
"notifications": {
"removed": "Фајл који недостаје, или више њих, је уклоњен"
}
},
"library": {
"name": "Библиотека |||| Библиотеке",
"fields": {
"name": "Назив",
"path": "Путања",
"remotePath": "Удаљена путања",
"lastScanAt": "Последње скенирање",
"songCount": "Песме",
"albumCount": "Албуми",
"artistCount": "Уметници",
"totalSongs": "Песме",
"totalAlbums": "Албуми",
"totalArtists": "Уметници",
"totalFolders": "Фасцикле",
"totalFiles": "Фајлови",
"totalMissingFiles": "Фајлови који недостају",
"totalSize": "Укупна величина",
"totalDuration": "Трајање",
"defaultNewUsers": "Подразумевано за нове кориснике",
"createdAt": "Креирана",
"updatedAt": "Ажурирана"
},
"sections": {
"basic": "Основне информације",
"statistics": "Статистика"
},
"actions": {
"scan": "Скенирај библиотеку",
"quickScan": "Брзо скенирање",
"fullScan": "Комплетно скенирање",
"manageUsers": "Управљај приступом корисника",
"viewDetails": "Прикажи детаље"
},
"notifications": {
"created": "Библиотека је успешно креирана",
"updated": "Библиотека је успешно ажурирана",
"deleted": "Библиотека је успешно обрисана",
"scanStarted": "Скенирање библиотеке је покренуто",
"quickScanStarted": "Брзо скенирање је покренуто",
"fullScanStarted": "Комплетно скенирање је покренуто",
"scanError": "Грешка при покретању скенирања. Проверите дневнике.",
"scanCompleted": "Скенирање библиотеке је завршено"
},
"validation": {
"nameRequired": "Назив библиотеке је обавезан",
"pathRequired": "Путања библиотеке је обавезна",
"pathNotDirectory": "Путања библиотеке мора да буде фасцикла",
"pathNotFound": "Путања библиотеке није пронађена",
"pathNotAccessible": "Путања библиотеке није доступна",
"pathInvalid": "Неисправна путања библиотеке"
},
"messages": {
"deleteConfirm": "Да ли сте сигурни да желите да обришете ову библиотеку? Ово ће да уклони све повезане податке и приступ корисника.",
"scanInProgress": "Скенирање је у току…",
"noLibrariesAssigned": "Овом кориснику нема додељених библиотека"
}
},
"plugin": {
"name": "Додатак |||| Додаци",
"fields": {
"id": "ИД",
"name": "Назив",
"description": "Опис",
"version": "Верзија",
"author": "Аутор",
"website": "Веб-сајт",
"permissions": "Дозволе",
"enabled": "Омогућено",
"status": "Статус",
"path": "Путања",
"lastError": "Грешка",
"hasError": "Грешка",
"updatedAt": "Ажурирано",
"createdAt": "Инсталирано",
"configKey": "Кључ",
"configValue": "Вредност",
"allUsers": "Дозволи свим корисницима",
"selectedUsers": "Изабрани корисници",
"allLibraries": "Дозволи све библиотеке",
"selectedLibraries": "Изабране библиотеке",
"allowWriteAccess": "Дозволи приступ за упис"
},
"sections": {
"status": "Статус",
"info": "Информације о додатку",
"configuration": "Конфигурација",
"manifest": "Манифест",
"usersPermission": "Дозволе корисника",
"libraryPermission": "Дозволе библиотеке"
},
"status": {
"enabled": "Омогућено",
"disabled": "Онемогућено"
},
"actions": {
"enable": "Омогући",
"disable": "Онемогући",
"disabledDueToError": "Поправите грешку пре омогућавања",
"disabledUsersRequired": "Изаберите кориснике пре омогућавања",
"disabledLibrariesRequired": "Изаберите библиотеке пре омогућавања",
"addConfig": "Додај конфигурацију",
"rescan": "Поново скенирај"
},
"notifications": {
"enabled": "Додатак је омогућен",
"disabled": "Додатак је онемогућен",
"updated": "Додатак је ажуриран",
"error": "Грешка при ажурирању додатка"
},
"validation": {
"invalidJson": "Конфигурација мора да буде исправан JSON"
},
"messages": {
"configHelp": "Конфигуришите додатак користећи парове кључ-вредност. Оставите празно ако додатак не захтева конфигурацију.",
"configValidationError": "Провера исправности конфигурације није успела:",
"schemaRenderError": "Не може да се прикаже образац за конфигурацију. Шема додатка можда није исправна.",
"clickPermissions": "Кликните на дозволу за детаље",
"noConfig": "Конфигурација није постављена",
"allUsersHelp": "Када је омогућено, додатак ће имати приступ свим корисницима, укључујући оне који буду креирани у будућности.",
"noUsers": "Нема изабраних корисника",
"permissionReason": "Разлог",
"usersRequired": "Овај додатак захтева приступ информацијама о корисницима. Изаберите којим корисницима додатак може да приступи, или омогућите „Дозволи свим корисницима”.",
"allLibrariesHelp": "Када је омогућено, додатак ће имати приступ свим библиотекама, укључујући оне које буду креиране у будућности.",
"noLibraries": "Нема изабраних библиотека",
"librariesRequired": "Овај додатак захтева приступ информацијама о библиотекама. Изаберите којим библиотекама додатак може да приступи, или омогућите „Дозволи све библиотеке”.",
"allowWriteAccessHelp": "Када је омогућено, додатак може да мења фајлове у фасциклама библиотеке. Подразумевано, додаци имају приступ само за читање.",
"requiredHosts": "Потребни хостови"
},
"placeholders": {
"configKey": "кључ",
"configValue": "вредност"
}
}
},
"ra": {
"auth": {
"auth_check_error": "Ако желите да наставите, молимо вас да се пријавите",
"buttonCreateAdmin": "Креирај админа",
"welcome1": "Хвала што сте инсталирали Navidrome!",
"welcome2": "За почетак, креирајте админ корисника",
"confirmPassword": "Потврдите лозинку",
"insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите",
"logout": "Одјави се",
"buttonCreateAdmin": "Креирај админа",
"auth_check_error": "Ако желите да наставите, молимо вас да се пријавите",
"user_menu": "Профил",
"username": "Корисничко име",
"password": "Лозинка",
"sign_in": "Пријави се",
"sign_in_error": "Потврда идентитета није успела, покушајте поново",
"user_menu": "Профил",
"username": "Корисничко име",
"welcome1": "Хвала што сте инсталирали Navidrome!",
"welcome2": "За почетак, креирајте админ корисника"
"logout": "Одјави се",
"insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите"
},
"validation": {
"email": "Мора да буде исправна и-мејл адреса",
"invalidChars": "Молимо вас да користите само слова и цифре",
"maxLength": "Мора да буде %{max} карактера или мање",
"maxValue": "Мора да буде %{max} или мање",
"minLength": "Мора да буде барем %{min} карактера",
"minValue": "Мора да буде барем %{min}",
"number": "Мора да буде број",
"oneOf": "Мора да буде једно од: %{options}",
"passwordDoesNotMatch": "Лозинка се не подудара",
"regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}",
"required": "Неопходно",
"minLength": "Мора да буде барем %{min} карактера",
"maxLength": "Мора да буде %{max} карактера или мање",
"minValue": "Мора да буде барем %{min}",
"maxValue": "Мора да буде %{max} или мање",
"number": "Мора да буде број",
"email": "Мора да буде исправна и-мејл адреса",
"oneOf": "Мора да буде једно од: %{options}",
"regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}",
"unique": "Мора да буде јединствено",
"url": "Мора да буде исправна URL адреса"
},
"action": {
"add": "Додај",
"add_filter": "Додај филтер",
"add": "Додај",
"back": "Иди назад",
"bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Откажи",
"clear_input_value": "Обриши вредност",
"clone": "Клонирај",
"close": "Затвори",
"close_menu": "Затвори мени",
"confirm": "Потврди",
"create": "Креирај",
"delete": "Обриши",
"download": "Преузми",
"edit": "Уреди",
"expand": "Развиј",
"export": "Извези",
"list": "Листа",
"open_menu": "Отвори мени",
"refresh": "Освежи",
"remove": "Уклони",
"remove_filter": "Уклони овај филтер",
"remove": "Уклони",
"save": "Сачувај",
"search": "Тражи",
"share": "Дели",
"show": "Прикажи",
"skip": "Прескочи",
"sort": "Сортирај",
"undo": "Поништи",
"unselect": "Уклони избор"
"expand": "Развиј",
"close": "Затвори",
"open_menu": "Отвори мени",
"close_menu": "Затвори мени",
"unselect": "Уклони избор",
"skip": "Прескочи",
"share": "Дели",
"download": "Преузми"
},
"boolean": {
"false": "Не",
"true": "Да"
"true": "Да",
"false": "Не"
},
"page": {
"create": "Креирај %{name}",
"dashboard": "Контролна табла",
"edit": "%{name} #%{id}",
"empty": "Још увек нема %{name}.",
"error": "Нешто је пошло наопако",
"invite": "Желите ли да се дода?",
"list": "%{name}",
"loading": "Учитава се",
"not_found": "Није пронађено",
"show": "%{name} #%{id}"
"show": "%{name} #%{id}",
"empty": "Још увек нема %{name}.",
"invite": "Желите ли да се дода?"
},
"input": {
"file": {
"upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.",
"upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете."
"upload_several": "Превуците фајлове да се отпреме, или кликните да их изаберете.",
"upload_single": "Превуците фајл да се отпреми, или кликните да га изаберете."
},
"image": {
"upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.",
"upload_single": "Упустите слику да се отпреми, или кликните да је изаберете."
},
"password": {
"toggle_hidden": "Прикажи лозинку",
"toggle_visible": "Сакриј лозинку"
"upload_several": "Превуците слике да се отпреме, или кликните да их изаберете.",
"upload_single": "Превуците слику да се отпреми, или кликните да је изаберете."
},
"references": {
"all_missing": "Не могу да се нађу подаци референци.",
"many_missing": "Изгледа да барем једна од придружених референци више није доступна.",
"single_missing": "Изгледа да придружена референца више није доступна."
},
"password": {
"toggle_visible": "Сакриј лозинку",
"toggle_hidden": "Прикажи лозинку"
}
},
"message": {
@ -357,161 +523,203 @@
"loading": "Страница се учитава, сачекајте мало",
"no": "Не",
"not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.",
"unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?",
"yes": "Да"
"yes": "Да",
"unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?"
},
"navigation": {
"next": "Наредна",
"no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.",
"no_results": "Није пронађен ниједан резултат",
"page_out_from_begin": "Не може да се иде испред странице 1",
"page_out_from_end": "Не може да се иде након последње странице",
"no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.",
"page_out_of_boundaries": "Број странице %{page} је ван опсега",
"page_out_from_end": "Не може да се иде након последње странице",
"page_out_from_begin": "Не може да се иде испред странице 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}",
"page_rows_per_page": "Ставки по страници:",
"prev": "Претход",
"next": "Наредна",
"prev": "Претх.",
"skip_nav": "Прескочи на садржај"
},
"notification": {
"bad_item": "Неисправни елемент",
"canceled": "Акција је отказана",
"updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано",
"created": "Елемент је креиран",
"data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.",
"deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано",
"http_error": "Грешка у комуникацији са сервером",
"i18n_error": "Не могу да се учитају преводи за наведени језик",
"bad_item": "Неисправни елемент",
"item_doesnt_exist": "Елемент не постоји",
"http_error": "Грешка у комуникацији са сервером",
"data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.",
"i18n_error": "Не могу да се учитају преводи за наведени језик",
"canceled": "Акција је отказана",
"logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.",
"new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор.",
"updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано"
"new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Колоне за приказ",
"grid": "Мрежа",
"layout": "Распоред",
"grid": "Мрежа",
"table": "Табела"
}
},
"message": {
"delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?",
"delete_user_title": "Брисање корисника %{name}",
"downloadDialogTitle": "Преузимање %{resource} %{name} (%{size})",
"downloadOriginalFormat": "Преузми у оригиналном формату",
"lastfmLink": "Прочитај још...",
"lastfmLinkFailure": "Last.fm није могао да се повеже",
"lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање",
"lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm",
"lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено",
"listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}",
"listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}",
"listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz",
"listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено",
"noPlaylistsAvailable": "Није доступна ниједна",
"uploadCover": "Отпреми омот",
"removeCover": "Уклони омот",
"coverUploaded": "Омот је ажуриран",
"coverRemoved": "Омот је уклоњен",
"coverUploadError": "Грешка при отпремању омота",
"coverRemoveError": "Грешка при уклањању омота",
"note": "НАПОМЕНА",
"transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.",
"transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања.",
"songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама",
"noSimilarSongsFound": "Нису пронађене сличне песме",
"startingInstantMix": "Учитава се инстант микс…",
"noTopSongsFound": "Нису пронађене најбоље песме",
"noPlaylistsAvailable": "Није доступна ниједна",
"delete_user_title": "Брисање корисника %{name}",
"delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?",
"remove_missing_title": "Уклони фајлове који недостају",
"remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.",
"remove_all_missing_title": "Уклони све фајлове који недостају",
"remove_all_missing_content": "Да ли сте сигурни да желите да из базе података уклоните све фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.",
"notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења",
"notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола",
"lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање",
"lastfmLinkFailure": "Last.fm није могао да се повеже",
"lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено",
"lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm",
"listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}",
"listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено",
"listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz",
"openIn": {
"lastfm": "Отвори у Last.fm",
"musicbrainz": "Отвори у MusicBrainz"
},
"remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.",
"remove_missing_title": "Уклони фајлове који недостају",
"lastfmLink": "Прочитај још...",
"shareOriginalFormat": "Подели у оригиналном формату",
"shareDialogTitle": "Подели %{resource} %{name}",
"shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}",
"shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер",
"shareDialogTitle": "Подели %{resource} %{name}",
"shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд",
"shareOriginalFormat": "Подели у оригиналном формату",
"shareSuccess": "URL је копиран у клипборд: %{url}",
"songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама",
"transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.",
"transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања."
"shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд",
"downloadDialogTitle": "Преузимање %{resource} %{name} (%{size})",
"downloadOriginalFormat": "Преузми у оригиналном формату"
},
"menu": {
"about": "О",
"albumList": "Албуми",
"library": "Библиотека",
"librarySelector": {
"allLibraries": "Све библиотеке (%{count})",
"multipleLibraries": "%{selected} од %{total} библиотека",
"selectLibraries": "Изабери библиотеке",
"none": "Ниједна"
},
"settings": "Подешавања",
"version": "Верзија",
"theme": "Тема",
"personal": {
"name": "Лична",
"options": {
"theme": "Тема",
"language": "Језик",
"defaultView": "Подразумевани поглед",
"desktop_notifications": "Десктоп обавештења",
"gain": {
"album": "Користи Album појачање",
"none": "Искључено",
"track": "Користи Track појачање"
},
"language": "Језик",
"lastfmNotConfigured": "Није подешен Last.fm API-кључ",
"lastfmScrobbling": "Скроблуј на Last.fm",
"listenBrainzScrobbling": "Скроблуј на ListenBrainz",
"preAmp": "ReplayGain претпојачање (dB)",
"replaygain": "ReplayGain режим",
"theme": "Тема"
"preAmp": "ReplayGain претпојачање (dB)",
"gain": {
"none": "Искључено",
"album": "Користи Album појачање",
"track": "Користи Track појачање"
}
}
},
"albumList": "Албуми",
"playlists": "Плејлисте",
"settings": "Подешавања",
"sharedPlaylists": "Дељене плејлисте",
"theme": "Тема",
"version": "Верзија"
"about": "О"
},
"player": {
"clickToDeleteText": "Кликните да обришете %{name}",
"clickToPauseText": "Кликни за паузирање",
"clickToPlayText": "Кликни за пуштање",
"playListsText": "Ред за пуштање",
"openText": "Отвори",
"closeText": "Затвори",
"notContentText": "Нема музике",
"clickToPlayText": "Кликните за пуштање",
"clickToPauseText": "Кликните за паузирање",
"nextTrackText": "Наредна нумера",
"previousTrackText": "Претходна нумера",
"reloadText": "Поново учитај",
"volumeText": "Јачина",
"toggleLyricText": "Укљ./Искљ. стихове",
"toggleMiniModeText": "Умањи",
"destroyText": "Уништи",
"downloadText": "Преузми",
"removeAudioListsText": "Обриши аудио листе",
"clickToDeleteText": "Кликните да обришете %{name}",
"emptyLyricText": "Нема стихова",
"nextTrackText": "Наредна нумера",
"notContentText": "Нема музике",
"openText": "Отвори",
"playListsText": "Ред за пуштање",
"playModeText": {
"order": "По редоследу",
"orderLoop": "Понови",
"shufflePlay": "Измешај",
"singleLoop": "Понови једну"
},
"previousTrackText": "Претходна нумера",
"reloadText": "Поново учитај",
"removeAudioListsText": "Обриши аудио листе",
"toggleLyricText": "Укљ./Искљ. стихове",
"toggleMiniModeText": "Умањи",
"volumeText": "Јачина"
"singleLoop": "Понови једну",
"shufflePlay": "Измешај"
}
},
"about": {
"links": {
"featureRequests": "Захтеви за функцијама",
"homepage": "Почетна страница",
"source": "Изворни кôд",
"featureRequests": "Захтеви за функције",
"lastInsightsCollection": "Последња колекција увида",
"insights": {
"disabled": "Искључено",
"waiting": "Чека се"
},
"lastInsightsCollection": "Последња колекција увида",
"source": "Изворни кôд"
}
},
"tabs": {
"about": "О програму",
"config": "Конфигурација"
},
"config": {
"configName": "Назив конфигурације",
"environmentVariable": "Променљива окружења",
"currentValue": "Тренутна вредност",
"configurationFile": "Конфигурациони фајл",
"exportToml": "Извези конфигурацију (TOML)",
"downloadToml": "Преузми конфигурацију (TOML)",
"exportSuccess": "Конфигурација је извезена у клипборд у TOML формату",
"exportFailed": "Копирање конфигурације није успело",
"devFlagsHeader": "Развојне заставице (подложне промени или уклањању)",
"devFlagsComment": "Ово су експерименталне поставке и могу бити уклоњене у будућим верзијама"
}
},
"activity": {
"fullScan": "Комплетно скенирање",
"quickScan": "Брзо скенирање",
"serverDown": "ВАН МРЕЖЕ",
"serverUptime": "Сервер се извршава",
"title": "Активност",
"totalScanned": "Укупан број скенираних фолдера"
"totalScanned": "Укупан број скенираних фолдера",
"quickScan": "Брзо скенирање",
"fullScan": "Комплетно скенирање",
"selectiveScan": "Селективно",
"serverUptime": "Сервер се извршава",
"serverDown": "ВАН МРЕЖЕ",
"scanType": "Последње скенирање",
"status": "Грешка скенирања",
"elapsedTime": "Протекло време"
},
"nowPlaying": {
"title": "Сада се пушта",
"empty": "Ништа се не пушта",
"minutesAgo": "Пре %{smart_count} минут |||| Пре %{smart_count} минута"
},
"help": {
"title": "Navidrome пречице",
"hotkeys": {
"current_song": "Иди на текућу песму",
"next_song": "Наредна песма",
"prev_song": "Претходна песма",
"show_help": "Прикажи ову помоћ",
"toggle_love": "Додај ову нумеру у омиљене",
"toggle_menu": "Укљ./Искљ. бочну траку менија",
"toggle_play": "Пусти / Паузирај",
"prev_song": "Претходна песма",
"next_song": "Наредна песма",
"current_song": "Иди на текућу песму",
"vol_up": "Појачај",
"vol_down": "Утишај",
"vol_up": "Појачај"
"toggle_love": "Додај ову нумеру у омиљене"
}
}
}

View File

@ -17,7 +17,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/pl"
"golang.org/x/time/rate"
)
@ -38,7 +37,7 @@ func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, br
devExternalScanner: conf.Server.DevExternalScanner,
}
if !c.devExternalScanner {
c.limiter = P(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate})
c.limiter = new(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate})
}
return c
}

View File

@ -97,8 +97,7 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod
func (s *scannerExternal) wait(cmd *exec.Cmd, out *io.PipeWriter) {
if err := cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
_ = out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %w", cmd, exitErr))
} else {
_ = out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", cmd, err))

View File

@ -93,7 +93,7 @@ var _ = Describe("Tags", func() {
var t *Tags
BeforeEach(func() {
t = &Tags{Tags: map[string][]string{
"fbpm": []string{"141.7"},
"fbpm": {"141.7"},
}}
})

View File

@ -135,7 +135,6 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request
func createAdminUser(ctx context.Context, ds model.DataStore, username, password string) error {
log.Warn(ctx, "Creating initial user", "user", username)
now := time.Now()
caser := cases.Title(language.Und)
initialUser := model.User{
ID: id.NewRandom(),
@ -144,7 +143,7 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password
Email: "",
NewPassword: password,
IsAdmin: true,
LastLoginAt: &now,
LastLoginAt: new(time.Now()),
}
err := ds.User(ctx).Put(&initialUser)
if err != nil {

View File

@ -60,15 +60,23 @@ var _ = Describe("Multi-User Isolation", Ordered, func() {
})
})
Describe("getUsers for regular user", func() {
It("returns only the requesting user's info", func() {
resp := doReqWithUser(regularUser, "getUsers")
Describe("getUsers authorization", func() {
It("succeeds for admin user", func() {
resp := doReqWithUser(adminUser, "getUsers")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Users).ToNot(BeNil())
Expect(resp.Users.User).To(HaveLen(1))
Expect(resp.Users.User[0].Username).To(Equal("regular"))
Expect(resp.Users.User[0].AdminRole).To(BeFalse())
Expect(resp.Users.User[0].Username).To(Equal(adminUser.UserName))
Expect(resp.Users.User[0].AdminRole).To(BeTrue())
})
It("fails for regular user because getUsers is admin-only", func() {
resp := doReqWithUser(regularUser, "getUsers")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail))
})
})
})

View File

@ -46,6 +46,30 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() {
Expect(radioID).ToNot(BeEmpty())
})
It("getInternetRadioStations remains available to regular users", func() {
resp := doReqWithUser(regularUser, "getInternetRadioStations")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.InternetRadioStations).ToNot(BeNil())
Expect(resp.InternetRadioStations.Radios).To(HaveLen(1))
Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Test Radio"))
})
It("createInternetRadioStation requires admin user", func() {
resp := doReqWithUser(regularUser, "createInternetRadioStation",
"streamUrl", "https://stream.example.com/hacked",
"name", "Hacked Radio",
)
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail))
resp = doReq("getInternetRadioStations")
Expect(resp.InternetRadioStations.Radios).To(HaveLen(1))
Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Test Radio"))
})
It("updateInternetRadioStation modifies the station", func() {
resp := doReq("updateInternetRadioStation",
"id", radioID,
@ -64,6 +88,35 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() {
Expect(resp.InternetRadioStations.Radios[0].HomepageUrl).To(Equal("https://updated.example.com"))
})
It("updateInternetRadioStation requires admin user", func() {
resp := doReqWithUser(regularUser, "updateInternetRadioStation",
"id", radioID,
"streamUrl", "https://stream.example.com/hacked",
"name", "Hacked Radio",
)
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail))
resp = doReq("getInternetRadioStations")
Expect(resp.InternetRadioStations.Radios).To(HaveLen(1))
Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Updated Radio"))
Expect(resp.InternetRadioStations.Radios[0].StreamUrl).To(Equal("https://stream.example.com/radio-v2"))
})
It("deleteInternetRadioStation requires admin user", func() {
resp := doReqWithUser(regularUser, "deleteInternetRadioStation", "id", radioID)
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail))
resp = doReq("getInternetRadioStations")
Expect(resp.InternetRadioStations.Radios).To(HaveLen(1))
Expect(resp.InternetRadioStations.Radios[0].ID).To(Equal(radioID))
})
It("deleteInternetRadioStation removes it", func() {
resp := doReq("deleteInternetRadioStation", "id", radioID)

View File

@ -45,8 +45,7 @@ func (api *Router) uploadArtistImage() http.HandlerFunc {
return err
}
ar.UploadedImage = filename
now := time.Now()
ar.UpdatedAt = &now
ar.UpdatedAt = new(time.Now())
return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at")
})
}
@ -65,8 +64,7 @@ func (api *Router) deleteArtistImage() http.HandlerFunc {
return err
}
ar.UploadedImage = ""
now := time.Now()
ar.UpdatedAt = &now
ar.UpdatedAt = new(time.Now())
return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at")
})
}

View File

@ -9,7 +9,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -32,7 +31,7 @@ var _ = Describe("Queue Endpoints", func() {
Describe("POST /queue", func() {
It("saves the queue", func() {
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1), Position: gg.P(int64(10))}
payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1), Position: new(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
ctx := request.WithUser(req.Context(), user)
@ -50,7 +49,7 @@ var _ = Describe("Queue Endpoints", func() {
})
It("saves an empty queue", func() {
payload := updateQueuePayload{Ids: gg.P([]string{}), Current: gg.P(0), Position: gg.P(int64(0))}
payload := updateQueuePayload{Ids: new([]string{}), Current: new(0), Position: new(int64(0))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@ -63,7 +62,7 @@ var _ = Describe("Queue Endpoints", func() {
})
It("returns bad request for invalid current index (negative)", func() {
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(-1), Position: gg.P(int64(10))}
payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(-1), Position: new(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@ -75,7 +74,7 @@ var _ = Describe("Queue Endpoints", func() {
})
It("returns bad request for invalid current index (too large)", func() {
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(2), Position: gg.P(int64(10))}
payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(2), Position: new(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@ -97,7 +96,7 @@ var _ = Describe("Queue Endpoints", func() {
It("returns internal server error when store fails", func() {
repo.Err = true
payload := updateQueuePayload{Ids: gg.P([]string{"s1"}), Current: gg.P(0), Position: gg.P(int64(10))}
payload := updateQueuePayload{Ids: new([]string{"s1"}), Current: new(0), Position: new(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@ -166,7 +165,7 @@ var _ = Describe("Queue Endpoints", func() {
Describe("PUT /queue", func() {
It("updates the queue fields", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}, {ID: "s2"}, {ID: "s3"}}}
payload := updateQueuePayload{Current: gg.P(2), Position: gg.P(int64(20))}
payload := updateQueuePayload{Current: new(2), Position: new(int64(20))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
ctx := request.WithUser(req.Context(), user)
@ -184,7 +183,7 @@ var _ = Describe("Queue Endpoints", func() {
It("updates only ids", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 1}
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})}
payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@ -198,7 +197,7 @@ var _ = Describe("Queue Endpoints", func() {
It("updates ids and current", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID}
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1)}
payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1)}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@ -213,7 +212,7 @@ var _ = Describe("Queue Endpoints", func() {
It("returns bad request when new ids invalidate current", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 2}
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})}
payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@ -225,7 +224,7 @@ var _ = Describe("Queue Endpoints", func() {
It("returns bad request when current out of bounds", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}}
payload := updateQueuePayload{Current: gg.P(3)}
payload := updateQueuePayload{Current: new(3)}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
@ -246,7 +245,7 @@ var _ = Describe("Queue Endpoints", func() {
It("returns internal server error when store fails", func() {
repo.Err = true
payload := updateQueuePayload{Position: gg.P(int64(10))}
payload := updateQueuePayload{Position: new(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))

View File

@ -12,7 +12,6 @@ import (
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -89,7 +88,7 @@ var _ = Describe("encodeMediafileShare", func() {
})
It("includes the share ID in the token", func() {
exp := P(time.Now().Add(time.Hour))
exp := new(time.Now().Add(time.Hour))
s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp}
token := encodeMediafileShare(s, "mf-999")
info, err := decodeStreamInfo(token)
@ -164,8 +163,7 @@ var _ = Describe("handleStream", func() {
It("returns 410 when share has been set to expired", func() {
shareRepo.ID = "share123"
expired := time.Now().Add(-time.Hour)
shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: &expired}
shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: new(time.Now().Add(-time.Hour))}
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
token, _ := auth.CreatePublicToken(claims)

View File

@ -171,12 +171,12 @@ func (api *Router) routes() http.Handler {
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "getUser", api.GetUser)
h(r, "getUsers", api.GetUsers)
h(r.With(adminOnly), "getUsers", api.GetUsers)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "getScanStatus", api.GetScanStatus)
h(r, "startScan", api.StartScan)
h(r.With(adminOnly), "startScan", api.StartScan)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
@ -195,10 +195,13 @@ func (api *Router) routes() http.Handler {
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "createInternetRadioStation", api.CreateInternetRadio)
h(r, "deleteInternetRadioStation", api.DeleteInternetRadio)
h(r, "getInternetRadioStations", api.GetInternetRadios)
h(r, "updateInternetRadioStation", api.UpdateInternetRadio)
r.Group(func(r chi.Router) {
r.Use(adminOnly)
h(r, "createInternetRadioStation", api.CreateInternetRadio)
h(r, "deleteInternetRadioStation", api.DeleteInternetRadio)
h(r, "updateInternetRadioStation", api.UpdateInternetRadio)
})
})
if conf.Server.EnableSharing {
r.Group(func(r chi.Router) {
@ -375,6 +378,10 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
}
if _, err := w.Write(response); err != nil { //nolint:gosec
log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err)
if log.IsGreaterOrEqualTo(log.LevelTrace) {
log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err)
} else {
log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, err)
}
}
}

View File

@ -9,7 +9,6 @@ import (
"strings"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"golang.org/x/net/context"
@ -136,7 +135,7 @@ var _ = Describe("sendResponse", func() {
It("should return a fail response", func() {
payload.Song = &responses.Child{OpenSubsonicChild: &responses.OpenSubsonicChild{}}
// An +Inf value will cause an error when marshalling to JSON
payload.Song.ReplayGain = responses.ReplayGain{TrackGain: gg.P(math.Inf(1))}
payload.Song.ReplayGain = responses.ReplayGain{TrackGain: new(math.Inf(1))}
q := r.URL.Query()
q.Add("f", "json")
r.URL.RawQuery = q.Encode()

View File

@ -256,8 +256,7 @@ func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) {
}
response := newResponse()
child := childFromMediaFile(ctx, *mf)
response.Song = &child
response.Song = new(childFromMediaFile(ctx, *mf))
return response, nil
}

View File

@ -18,7 +18,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/number"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
@ -217,7 +216,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
child.Path = fakePath(mf)
}
child.DiscNumber = int32(mf.DiscNumber)
child.Created = P(mf.BirthTime)
child.Created = new(mf.BirthTime)
child.AlbumId = mf.AlbumID
child.ArtistId = mf.ArtistID
child.Type = "music"
@ -346,7 +345,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear))
child.Genre = al.Genre
child.CoverArt = al.CoverArtID().String()
child.Created = P(albumCreatedAt(al))
child.Created = new(albumCreatedAt(al))
child.Parent = al.AlbumArtistID
child.ArtistId = al.AlbumArtistID
child.Duration = int32(al.Duration)
@ -442,7 +441,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
dir.PlayCount = album.PlayCount
dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear))
dir.Genre = album.Genre
dir.Created = P(albumCreatedAt(album))
dir.Created = albumCreatedAt(album)
if album.Starred {
dir.Starred = album.StarredAt
}

View File

@ -576,30 +576,27 @@ var _ = Describe("helpers", func() {
t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
al := model.Album{ID: "a1", Name: "A", CreatedAt: t}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created).ToNot(BeNil())
Expect(*dir.Created).To(Equal(t))
Expect(dir.Created).To(Equal(t))
})
It("falls back to UpdatedAt when CreatedAt is zero", func() {
updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC)
al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created).ToNot(BeNil())
Expect(*dir.Created).To(Equal(updated))
Expect(dir.Created).To(Equal(updated))
})
It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() {
imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC)
al := model.Album{ID: "a3", Name: "A", ImportedAt: imported}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created).ToNot(BeNil())
Expect(*dir.Created).To(Equal(imported))
Expect(dir.Created).To(Equal(imported))
})
It("never leaves Created nil even when all timestamps are zero", func() {
It("leaves Created as zero time when all timestamps are zero", func() {
al := model.Album{ID: "a4", Name: "A"}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created).ToNot(BeNil())
Expect(dir.Created.IsZero()).To(BeTrue())
})
})

View File

@ -40,10 +40,6 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) {
return nil, newError(responses.ErrorGeneric, "Internal error")
}
if !loggedUser.IsAdmin {
return nil, newError(responses.ErrorAuthorizationFail)
}
p := req.Params(r)
fullScan := p.BoolOr("fullScan", false)

View File

@ -23,29 +23,6 @@ var _ = Describe("LibraryScanning", func() {
})
Describe("StartScan", func() {
It("requires admin authentication", func() {
// Create non-admin user
ctx := request.WithUser(context.Background(), model.User{
ID: "user-id",
IsAdmin: false,
})
// Create request
r := httptest.NewRequest("GET", "/rest/startScan", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.StartScan(r)
// Should return authorization error
Expect(err).To(HaveOccurred())
Expect(response).To(BeNil())
var subErr subError
ok := errors.As(err, &subErr)
Expect(ok).To(BeTrue())
Expect(subErr.code).To(Equal(responses.ErrorAuthorizationFail))
})
It("triggers a full scan with no parameters", func() {
// Create admin user
ctx := request.WithUser(context.Background(), model.User{

View File

@ -294,7 +294,6 @@ var _ = Describe("MediaRetrievalController", func() {
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
offset := int64(-100)
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
@ -312,7 +311,7 @@ var _ = Describe("MediaRetrievalController", func() {
Value: "You know the rules and so do I",
},
},
Offset: &offset,
Offset: new(int64(-100)),
},
},
})

View File

@ -155,6 +155,23 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
}
}
func adminOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
loggedUser, ok := request.UserFrom(r.Context())
if !ok {
sendError(w, r, newError(responses.ErrorGeneric, "Internal error"))
return
}
if !loggedUser.IsAdmin {
sendError(w, r, newError(responses.ErrorAuthorizationFail))
return
}
next.ServeHTTP(w, r)
})
}
func validateCredentials(user *model.User, pass, token, salt, jwt string) error {
valid := false

View File

@ -308,6 +308,36 @@ var _ = Describe("Middlewares", func() {
})
})
Describe("AdminOnly", func() {
It("passes admin users", func() {
r := newGetRequest()
r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin-id", IsAdmin: true}))
adminOnly(next).ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
})
It("rejects non-admin users", func() {
r := newGetRequest()
r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "user-id", IsAdmin: false}))
adminOnly(next).ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="50"`))
Expect(next.called).To(BeFalse())
})
It("returns an internal error when user is missing from context", func() {
r := newGetRequest()
adminOnly(next).ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="0"`))
Expect(next.called).To(BeFalse())
})
})
Describe("GetPlayer", func() {
var mockedPlayers *mockPlayers
var r *http.Request

View File

@ -12,7 +12,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
)
@ -169,7 +168,7 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso
pls.Readonly = true
if p.EvaluatedAt != nil {
pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay))
pls.ValidUntil = new(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay))
}
} else {
user, ok := request.UserFrom(ctx)

View File

@ -8,7 +8,9 @@
"id": "1",
"name": "album",
"artist": "artist",
"songCount": 0,
"duration": 292,
"created": "0001-01-01T00:00:00Z",
"genre": "rock",
"userRating": 4,
"genres": [

View File

@ -1,5 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<album id="1" name="album" artist="artist" duration="292" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 &amp; artist2" explicitStatus="clean" version="Deluxe Edition">
<album id="1" name="album" artist="artist" songCount="0" duration="292" created="0001-01-01T00:00:00Z" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 &amp; artist2" explicitStatus="clean" version="Deluxe Edition">
<genres name="rock"></genres>
<genres name="progressive"></genres>
<discTitles disc="1" title="disc 1"></discTitles>

View File

@ -7,6 +7,8 @@
"album": {
"id": "",
"name": "",
"duration": 0
"songCount": 0,
"duration": 0,
"created": "0001-01-01T00:00:00Z"
}
}

View File

@ -1,3 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<album id="" name="" duration="0"></album>
<album id="" name="" songCount="0" duration="0" created="0001-01-01T00:00:00Z"></album>
</subsonic-response>

View File

@ -7,7 +7,9 @@
"album": {
"id": "",
"name": "",
"songCount": 0,
"duration": 0,
"created": "0001-01-01T00:00:00Z",
"userRating": 0,
"genres": [],
"musicBrainzId": "",

View File

@ -1,3 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<album id="" name="" duration="0"></album>
<album id="" name="" songCount="0" duration="0" created="0001-01-01T00:00:00Z"></album>
</subsonic-response>

View File

@ -251,10 +251,10 @@ type AlbumID3 struct {
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
SongCount int32 `xml:"songCount,attr" json:"songCount"`
Duration int32 `xml:"duration,attr" json:"duration"`
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
Created time.Time `xml:"created,attr" json:"created"`
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"`
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`

View File

@ -8,7 +8,6 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -94,11 +93,10 @@ var _ = Describe("Responses", func() {
Context("with data", func() {
BeforeEach(func() {
artists := make([]Artist, 1)
t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)
artists[0] = Artist{
Id: "111",
Name: "aaa",
Starred: &t,
Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)),
UserRating: 3,
ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
}
@ -133,11 +131,10 @@ var _ = Describe("Responses", func() {
Context("with data", func() {
BeforeEach(func() {
artists := make([]ArtistID3, 1)
t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)
artists[0] = ArtistID3{
Id: "111",
Name: "aaa",
Starred: &t,
Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)),
UserRating: 3,
AlbumCount: 2,
ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
@ -158,11 +155,10 @@ var _ = Describe("Responses", func() {
Context("with OpenSubsonic data", func() {
BeforeEach(func() {
artists := make([]ArtistID3, 1)
t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)
artists[0] = ArtistID3{
Id: "111",
Name: "aaa",
Starred: &t,
Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)),
UserRating: 3,
AlbumCount: 2,
ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
@ -211,12 +207,11 @@ var _ = Describe("Responses", func() {
BeforeEach(func() {
response.Directory = &Directory{Id: "1", Name: "N"}
child := make([]Child, 2)
t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)
child[0] = Child{
Id: "1", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1,
Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac",
Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3",
Duration: 146, BitRate: 320, Starred: &t,
Duration: 146, BitRate: 320, Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)),
}
child[0].OpenSubsonicChild = &OpenSubsonicChild{
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
@ -225,7 +220,7 @@ var _ = Describe("Responses", func() {
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
Moods: []string{"happy", "sad"},
Groupings: []string{"Soundtrack", "Live"},
ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)},
ReplayGain: ReplayGain{TrackGain: new(1.0), AlbumGain: new(2.0), TrackPeak: new(3.0), AlbumPeak: new(4.0), BaseGain: new(5.0), FallbackGain: new(6.0)},
DisplayArtist: "artist 1 & artist 2",
Artists: []ArtistID3Ref{
{Id: "1", Name: "artist1"},
@ -246,7 +241,7 @@ var _ = Describe("Responses", func() {
ExplicitStatus: "clean",
}
child[1].OpenSubsonicChild = &OpenSubsonicChild{
ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)},
ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)},
}
response.Directory.Child = child
})
@ -322,7 +317,7 @@ var _ = Describe("Responses", func() {
Isrc: []string{"ISRC-1"},
Moods: []string{"happy", "sad"},
Groupings: []string{"Soundtrack", "Live"},
ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)},
ReplayGain: ReplayGain{TrackGain: new(1.0), AlbumGain: new(2.0), TrackPeak: new(3.0), AlbumPeak: new(4.0), BaseGain: new(5.0), FallbackGain: new(6.0)},
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
DisplayArtist: "artist1 & artist2",
Artists: []ArtistID3Ref{
@ -342,7 +337,7 @@ var _ = Describe("Responses", func() {
ExplicitStatus: "clean",
}
songs[1].OpenSubsonicChild = &OpenSubsonicChild{
ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)},
ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)},
}
response.AlbumWithSongsID3.AlbumID3 = album
response.AlbumWithSongsID3.Song = songs
@ -804,7 +799,7 @@ var _ = Describe("Responses", func() {
Context("with data", func() {
BeforeEach(func() {
response.PlayQueueByIndex.Username = "user1"
response.PlayQueueByIndex.CurrentIndex = gg.P(0)
response.PlayQueueByIndex.CurrentIndex = new(0)
response.PlayQueueByIndex.Position = 243
response.PlayQueueByIndex.Changed = time.Time{}
response.PlayQueueByIndex.ChangedBy = "a_client"

View File

@ -58,12 +58,10 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) {
}
description, _ := p.String("description")
expires := p.TimeOr("expires", time.Time{})
repo := api.share.NewRepository(r.Context())
share := &model.Share{
Description: description,
ExpiresAt: &expires,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ResourceIDs: strings.Join(ids, ","),
}
@ -90,13 +88,11 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) {
}
description, _ := p.String("description")
expires := p.TimeOr("expires", time.Time{})
repo := api.share.NewRepository(r.Context())
share := &model.Share{
ID: id,
Description: description,
ExpiresAt: &expires,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
}
err = repo.(rest.Persistable).Update(id, share)

View File

@ -46,8 +46,7 @@ func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) {
return nil, newError(responses.ErrorAuthorizationFail)
}
response := newResponse()
user := buildUserResponse(loggedUser)
response.User = &user
response.User = new(buildUserResponse(loggedUser))
return response, nil
}

View File

@ -7,7 +7,6 @@ import (
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/gg"
)
func CreateMockUserRepo() *MockedUserRepo {
@ -84,7 +83,7 @@ func (u *MockedUserRepo) GetAll(options ...model.QueryOptions) (model.Users, err
func (u *MockedUserRepo) UpdateLastLoginAt(id string) error {
for _, usr := range u.Data {
if usr.ID == id {
usr.LastLoginAt = gg.P(time.Now())
usr.LastLoginAt = new(time.Now())
return nil
}
}
@ -94,7 +93,7 @@ func (u *MockedUserRepo) UpdateLastLoginAt(id string) error {
func (u *MockedUserRepo) UpdateLastAccessAt(id string) error {
for _, usr := range u.Data {
if usr.ID == id {
usr.LastAccessAt = gg.P(time.Now())
usr.LastAccessAt = new(time.Now())
return nil
}
}

View File

@ -192,6 +192,11 @@ export default {
paddingBottom: '1rem',
},
},
RaConfirm: {
confirmPrimary: {
color: '#fff',
},
},
RaDeleteWithConfirmButton: {
deleteButton: {
color: '#fff !important',

View File

@ -75,8 +75,7 @@ func (c *HTTPClient) serializeReq(req *http.Request) string {
}
if req.Body != nil {
bodyData, _ := io.ReadAll(req.Body)
bodyStr := base64.StdEncoding.EncodeToString(bodyData)
data.Body = &bodyStr
data.Body = new(base64.StdEncoding.EncodeToString(bodyData))
}
j, _ := json.Marshal(&data)
return string(j)

View File

@ -9,7 +9,6 @@ import (
"time"
"github.com/jellydator/ttlcache/v3"
. "github.com/navidrome/navidrome/utils/gg"
)
type SimpleCache[K comparable, V any] interface {
@ -119,7 +118,7 @@ func (c *simpleCache[K, V]) GetWithLoader(key K, loader func(key K) (V, time.Dur
func (c *simpleCache[K, V]) evictExpired() {
if c.evictionDeadline.Load() == nil || c.evictionDeadline.Load().Before(time.Now()) {
c.data.DeleteExpired()
c.evictionDeadline.Store(P(time.Now().Add(evictionTimeout)))
c.evictionDeadline.Store(new(time.Now().Add(evictionTimeout)))
}
}

View File

@ -2,8 +2,6 @@ package chrono
import (
"time"
. "github.com/navidrome/navidrome/utils/gg"
)
// Meter is a simple stopwatch
@ -13,7 +11,7 @@ type Meter struct {
}
func (m *Meter) Start() {
m.mark = P(time.Now())
m.mark = new(time.Now())
}
func (m *Meter) Stop() time.Duration {

View File

@ -1,11 +1,6 @@
// Package gg implements simple "extensions" to Go language. Based on https://github.com/icza/gog
package gg
// P returns a pointer to the input value
func P[T any](v T) *T {
return &v
}
// V returns the value of the input pointer, or a zero value if the input pointer is nil.
func V[T any](p *T) T {
if p == nil {

View File

@ -16,22 +16,9 @@ func TestGG(t *testing.T) {
}
var _ = Describe("GG", func() {
Describe("P", func() {
It("returns a pointer to the input value", func() {
v := 123
Expect(gg.P(123)).To(Equal(&v))
})
It("returns nil if the input value is zero", func() {
v := 0
Expect(gg.P(0)).To(Equal(&v))
})
})
Describe("V", func() {
It("returns the value of the input pointer", func() {
v := 123
Expect(gg.V(&v)).To(Equal(123))
Expect(gg.V(new(123))).To(Equal(123))
})
It("returns a zero value if the input pointer is nil", func() {

View File

@ -38,8 +38,7 @@ func (r *Values) String(param string) (string, error) {
func (r *Values) StringPtr(param string) *string {
var v *string
if _, exists := r.URL.Query()[param]; exists {
s := r.URL.Query().Get(param)
v = &s
v = new(r.URL.Query().Get(param))
}
return v
}
@ -48,8 +47,7 @@ func (r *Values) BoolPtr(param string) *bool {
var v *bool
if _, exists := r.URL.Query()[param]; exists {
s := r.URL.Query().Get(param)
b := strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/")
v = &b
v = new(strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/"))
}
return v
}

View File

@ -134,7 +134,7 @@ var _ = Describe("Slice Utils", func() {
count := 0
file, _ := os.Open(path)
defer file.Close()
for _ = range slice.LinesFrom(file) {
for range slice.LinesFrom(file) {
count++
}
Expect(count).To(Equal(expected))