mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Merge remote-tracking branch 'origin/master' into fork/Metalhearf/theme-tokyo-night
# Conflicts: # ui/src/themes/index.js
This commit is contained in:
commit
5598052280
3
.gitignore
vendored
3
.gitignore
vendored
@ -37,5 +37,6 @@ AGENTS.md
|
||||
*.wasm
|
||||
*.ndp
|
||||
openspec/
|
||||
.agents
|
||||
go.work*
|
||||
.worktrees/
|
||||
.worktrees/
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
bytes "bytes"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -77,6 +77,13 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
resp["status"] = key != ""
|
||||
linkToken, err := createLinkToken(u.ID)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Could not create LastFM link token", "userId", u.ID, err)
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
resp["linkToken"] = linkToken
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@ -97,11 +104,17 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
|
||||
return
|
||||
}
|
||||
uid, err := p.String("uid")
|
||||
linkToken, err := p.String("uid")
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
|
||||
return
|
||||
}
|
||||
uid, err := verifyLinkToken(linkToken)
|
||||
if err != nil {
|
||||
log.Warn(r.Context(), "Rejected LastFM callback with invalid link token", "requestId", middleware.GetReqID(r.Context()), err)
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "invalid link token")
|
||||
return
|
||||
}
|
||||
|
||||
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
|
||||
// automatically contain any user info
|
||||
|
||||
218
adapters/lastfm/auth_router_test.go
Normal file
218
adapters/lastfm/auth_router_test.go
Normal file
@ -0,0 +1,218 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("auth_router", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
userProps *tests.MockedUserPropsRepo
|
||||
httpClient *tests.FakeHttpClient
|
||||
router *Router
|
||||
)
|
||||
|
||||
const (
|
||||
victimID = "victim-user-id"
|
||||
attackerID = "attacker-user-id"
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
userProps = &tests.MockedUserPropsRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
MockedUserProps: userProps,
|
||||
}
|
||||
auth.Init(ds)
|
||||
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
router = &Router{
|
||||
ds: ds,
|
||||
apiKey: "API_KEY",
|
||||
secret: "SECRET",
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
router.client = newClient(router.apiKey, router.secret, httpClient)
|
||||
router.Handler = router.routes()
|
||||
})
|
||||
|
||||
storedSessionKey := func(userID string) string {
|
||||
key, _ := userProps.Get(userID, sessionKeyProperty)
|
||||
return key
|
||||
}
|
||||
|
||||
stubGetSessionOK := func(sessionKey string) {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
}
|
||||
|
||||
Describe("getLinkStatus", func() {
|
||||
It("includes a signed linkToken for the authenticated user", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/link", nil)
|
||||
ctx := request.WithUser(req.Context(), model.User{ID: victimID})
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.getLinkStatus(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
var body map[string]any
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed())
|
||||
Expect(body["apiKey"]).To(Equal("API_KEY"))
|
||||
Expect(body["status"]).To(Equal(false))
|
||||
token, ok := body["linkToken"].(string)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(token).ToNot(BeEmpty())
|
||||
|
||||
verified, err := verifyLinkToken(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(verified).To(Equal(victimID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("callback", func() {
|
||||
It("stores the session key under the user encoded in the signed token", func() {
|
||||
stubGetSessionOK("LEGIT_SESSION")
|
||||
linkToken, err := createLinkToken(victimID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION"))
|
||||
})
|
||||
|
||||
It("rejects a raw (unsigned) uid value", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest).To(BeNil())
|
||||
})
|
||||
|
||||
It("rejects an expired link token", func() {
|
||||
expiredToken, err := auth.EncodeToken(map[string]any{
|
||||
"uid": victimID,
|
||||
"scope": linkTokenScope,
|
||||
"exp": time.Now().Add(-1 * time.Minute).UTC().Unix(),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest).To(BeNil())
|
||||
})
|
||||
|
||||
It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() {
|
||||
sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(storedSessionKey(attackerID)).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest).To(BeNil())
|
||||
})
|
||||
|
||||
It("writes only under the user encoded in the token, regardless of query manipulation", func() {
|
||||
// An attacker holds a legitimate link token for their own account.
|
||||
// They attempt to call the callback hoping to overwrite the victim's
|
||||
// session key — but the handler must derive the user ID from the
|
||||
// signed token, not from any other input.
|
||||
stubGetSessionOK("ATTACKER_SESSION")
|
||||
attackerToken, err := createLinkToken(attackerID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION"))
|
||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns 400 when uid is missing", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns 400 when token is missing", func() {
|
||||
linkToken, err := createLinkToken(victimID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("link token helpers", func() {
|
||||
It("round-trips a freshly issued token", func() {
|
||||
token, err := createLinkToken(victimID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
uid, err := verifyLinkToken(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(uid).To(Equal(victimID))
|
||||
})
|
||||
|
||||
It("rejects garbage", func() {
|
||||
_, err := verifyLinkToken("not-a-jwt")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects a token whose scope claim is wrong", func() {
|
||||
wrongScopeToken, err := auth.EncodeToken(map[string]any{
|
||||
"uid": victimID,
|
||||
"scope": "some-other-scope",
|
||||
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = verifyLinkToken(wrongScopeToken)
|
||||
Expect(err).To(MatchError("invalid link token scope"))
|
||||
})
|
||||
|
||||
It("rejects a scoped token that has no expiration", func() {
|
||||
nonExpiringToken, err := auth.EncodeToken(map[string]any{
|
||||
"uid": victimID,
|
||||
"scope": linkTokenScope,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = verifyLinkToken(nonExpiringToken)
|
||||
Expect(err).To(MatchError("link token missing expiration"))
|
||||
})
|
||||
})
|
||||
})
|
||||
50
adapters/lastfm/link_token.go
Normal file
50
adapters/lastfm/link_token.go
Normal file
@ -0,0 +1,50 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
linkTokenScope = "lastfm-link"
|
||||
linkTokenTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
// createLinkToken issues a signed token binding the Last.fm callback to the
|
||||
// user who initiated the OAuth flow. It travels back through Last.fm via the
|
||||
// `cb` URL in place of the previously-trusted raw `uid` query parameter.
|
||||
func createLinkToken(userID string) (string, error) {
|
||||
claims := map[string]any{
|
||||
"uid": userID,
|
||||
"scope": linkTokenScope,
|
||||
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
|
||||
}
|
||||
return auth.EncodeToken(claims)
|
||||
}
|
||||
|
||||
// verifyLinkToken validates a signed link token and returns the encoded user ID.
|
||||
// It enforces both the signature/expiry (via the underlying JWT verifier) and a
|
||||
// dedicated scope claim, preventing tokens minted for other purposes (e.g. a
|
||||
// regular session JWT) from being accepted here.
|
||||
func verifyLinkToken(tokenStr string) (string, error) {
|
||||
token, err := auth.DecodeAndVerifyToken(tokenStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// jwtauth treats a token without `exp` as non-expiring; require it
|
||||
// explicitly so an accidental regression cannot mint permanent tokens.
|
||||
if exp, ok := token.Expiration(); !ok || exp.IsZero() {
|
||||
return "", errors.New("link token missing expiration")
|
||||
}
|
||||
var scope string
|
||||
if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope {
|
||||
return "", errors.New("invalid link token scope")
|
||||
}
|
||||
var uid string
|
||||
if err := token.Get("uid", &uid); err != nil || uid == "" {
|
||||
return "", errors.New("invalid link token user ID")
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
@ -32,17 +32,17 @@ var inspectCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var marshalers = map[string]func(interface{}) ([]byte, error){
|
||||
var marshalers = map[string]func(any) ([]byte, error){
|
||||
"pretty": prettyMarshal,
|
||||
"toml": toml.Marshal,
|
||||
"yaml": yaml.Marshal,
|
||||
"json": json.Marshal,
|
||||
"jsonindent": func(v interface{}) ([]byte, error) {
|
||||
"jsonindent": func(v any) ([]byte, error) {
|
||||
return json.MarshalIndent(v, "", " ")
|
||||
},
|
||||
}
|
||||
|
||||
func prettyMarshal(v interface{}) ([]byte, error) {
|
||||
func prettyMarshal(v any) ([]byte, error) {
|
||||
out := v.([]core.InspectOutput)
|
||||
var res strings.Builder
|
||||
for i := range out {
|
||||
|
||||
@ -47,7 +47,6 @@ type configOptions struct {
|
||||
UIWelcomeMessage string
|
||||
MaxSidebarPlaylists int
|
||||
EnableTranscodingConfig bool
|
||||
EnableTranscodingCancellation bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableM3UExternalAlbumArt bool
|
||||
@ -113,6 +112,7 @@ type configOptions struct {
|
||||
PID pidOptions `json:",omitzero"`
|
||||
Inspect inspectOptions `json:",omitzero"`
|
||||
Subsonic subsonicOptions `json:",omitzero"`
|
||||
Transcoding transcodingOptions `json:",omitzero"`
|
||||
LastFM lastfmOptions `json:",omitzero"`
|
||||
Deezer deezerOptions `json:",omitzero"`
|
||||
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
||||
@ -165,6 +165,12 @@ type scannerOptions struct {
|
||||
PurgeMissing string // Values: "never", "always", "full"
|
||||
}
|
||||
|
||||
type transcodingOptions struct {
|
||||
MaxConcurrent int
|
||||
MaxConcurrentPerUser int
|
||||
EnableCancellation bool
|
||||
}
|
||||
|
||||
type subsonicOptions struct {
|
||||
AppendSubtitle bool
|
||||
AppendAlbumVersion bool
|
||||
@ -324,6 +330,7 @@ func Load(noConfigDump bool) {
|
||||
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
|
||||
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||
mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
|
||||
|
||||
err := viper.Unmarshal(&Server, viper.DecodeHook(
|
||||
mapstructure.ComposeDecodeHookFunc(
|
||||
@ -449,6 +456,7 @@ func Load(noConfigDump bool) {
|
||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
|
||||
logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||
logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
|
||||
|
||||
// Removed options
|
||||
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
||||
@ -737,7 +745,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("uiwelcomemessage", "")
|
||||
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
|
||||
viper.SetDefault("enabletranscodingconfig", false)
|
||||
viper.SetDefault("enabletranscodingcancellation", false)
|
||||
viper.SetDefault("transcodingcachesize", "100MB")
|
||||
viper.SetDefault("imagecachesize", "100MB")
|
||||
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
||||
@ -822,6 +829,9 @@ func setViperDefaults() {
|
||||
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||
viper.SetDefault("subsonic.minimalclients", "SubMusic")
|
||||
viper.SetDefault("transcoding.maxconcurrent", 0)
|
||||
viper.SetDefault("transcoding.maxconcurrentperuser", 0)
|
||||
viper.SetDefault("transcoding.enablecancellation", false)
|
||||
viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
|
||||
|
||||
47
conf/dir.go
47
conf/dir.go
@ -1,20 +1,20 @@
|
||||
package conf
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Dir wraps a directory path and lazily creates the directory on first use.
|
||||
// The directory is created at most once; if creation fails, the error is
|
||||
// permanently cached (sync.Once semantics). Dir is not safe for mutation
|
||||
// after Path() has been called.
|
||||
// Dir wraps a directory path and creates the directory on demand. Dir is a
|
||||
// plain value type — safe to copy, compare, and print via reflection-based
|
||||
// formatters (pretty.Sprintf("%# v", ...)) without any concurrency hazards.
|
||||
// Directory creation is delegated to os.MkdirAll on every Path() call;
|
||||
// MkdirAll is idempotent, so repeated calls cost one stat syscall when the
|
||||
// directory already exists.
|
||||
type Dir struct {
|
||||
path string
|
||||
perm os.FileMode
|
||||
once sync.Once
|
||||
err error
|
||||
}
|
||||
|
||||
// NewDir creates a new Dir with the given path and default permissions (os.ModePerm).
|
||||
@ -23,31 +23,32 @@ func NewDir(path string) Dir {
|
||||
}
|
||||
|
||||
// NewDirWithPerm creates a new Dir with the given path and permissions.
|
||||
// A perm of 0 is treated as "default" and resolves to os.ModePerm at
|
||||
// directory-creation time; pass an explicit non-zero mode to constrain the
|
||||
// permissions.
|
||||
func NewDirWithPerm(path string, perm os.FileMode) Dir {
|
||||
return Dir{path: path, perm: perm}
|
||||
}
|
||||
|
||||
// String returns the raw path without creating the directory. Satisfies fmt.Stringer.
|
||||
func (d *Dir) String() string {
|
||||
func (d Dir) String() string {
|
||||
return d.path
|
||||
}
|
||||
|
||||
// Path creates the directory on first call (via sync.Once) and returns the path.
|
||||
func (d *Dir) Path() (string, error) {
|
||||
d.once.Do(func() {
|
||||
if d.path == "" {
|
||||
return
|
||||
}
|
||||
d.err = os.MkdirAll(d.path, d.perm)
|
||||
if d.err != nil {
|
||||
d.err = fmt.Errorf("creating directory %q: %w", d.path, d.err)
|
||||
}
|
||||
})
|
||||
return d.path, d.err
|
||||
// Path ensures the directory exists and returns its path. Safe to call
|
||||
// repeatedly; an empty path is returned as-is with no error.
|
||||
func (d Dir) Path() (string, error) {
|
||||
if d.path == "" {
|
||||
return "", nil
|
||||
}
|
||||
if err := os.MkdirAll(d.path, cmp.Or(d.perm, os.ModePerm)); err != nil {
|
||||
return d.path, fmt.Errorf("creating directory %q: %w", d.path, err)
|
||||
}
|
||||
return d.path, nil
|
||||
}
|
||||
|
||||
// MustPath calls Path() and calls logFatal on error.
|
||||
func (d *Dir) MustPath() string {
|
||||
func (d Dir) MustPath() string {
|
||||
path, err := d.Path()
|
||||
if err != nil {
|
||||
logFatal("creating directory:", err)
|
||||
@ -57,12 +58,12 @@ 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 {
|
||||
return fmt.Sprintf("%q", d.path)
|
||||
}
|
||||
|
||||
// MarshalText returns the raw path bytes. No side effects.
|
||||
func (d *Dir) MarshalText() ([]byte, error) {
|
||||
func (d Dir) MarshalText() ([]byte, error) {
|
||||
return []byte(d.path), nil
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,9 @@ package conf_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/kr/pretty"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -35,9 +37,9 @@ var _ = Describe("Dir", func() {
|
||||
Expect(target).To(BeADirectory())
|
||||
})
|
||||
|
||||
It("returns the same result on subsequent calls (sync.Once)", func() {
|
||||
It("is idempotent on subsequent calls", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
target := dir + "/once"
|
||||
target := dir + "/idempotent"
|
||||
d := conf.NewDir(target)
|
||||
|
||||
path1, err1 := d.Path()
|
||||
@ -45,6 +47,7 @@ var _ = Describe("Dir", func() {
|
||||
Expect(err1).ToNot(HaveOccurred())
|
||||
Expect(err2).ToNot(HaveOccurred())
|
||||
Expect(path1).To(Equal(path2))
|
||||
Expect(target).To(BeADirectory())
|
||||
})
|
||||
|
||||
It("returns an error when directory cannot be created", func() {
|
||||
@ -124,4 +127,38 @@ var _ = Describe("Dir", func() {
|
||||
Expect(d2.String()).To(Equal(d1.String()))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GoString", func() {
|
||||
// Regression: pretty.Sprintf("%# v", ...) is used by the
|
||||
// configuration dump. It must render Dir as a quoted path via
|
||||
// GoString, not dump the internal struct fields.
|
||||
It("renders Dir as a quoted path under pretty.Sprintf", func() {
|
||||
type host struct {
|
||||
DataFolder conf.Dir
|
||||
}
|
||||
h := host{DataFolder: conf.NewDir("./data")}
|
||||
out := pretty.Sprintf("%# v", h)
|
||||
Expect(out).To(ContainSubstring(`DataFolder: "./data"`))
|
||||
Expect(out).ToNot(ContainSubstring("perm:"))
|
||||
Expect(out).ToNot(ContainSubstring("path:"))
|
||||
})
|
||||
|
||||
It("is safe to copy and use concurrently", func() {
|
||||
// Regression for the Windows "sync: unlock of unlocked mutex"
|
||||
// crash that was caused by copying a Dir embedding sync.Once.
|
||||
// Dir is a plain value type now, but keep the concurrent stress
|
||||
// test to lock in the property.
|
||||
dir := GinkgoT().TempDir()
|
||||
d := conf.NewDir(dir + "/race")
|
||||
var wg sync.WaitGroup
|
||||
for range 10 {
|
||||
wg.Go(func() {
|
||||
copy1 := d
|
||||
_ = pretty.Sprintf("%# v", copy1)
|
||||
_, _ = copy1.Path()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@ -60,7 +61,15 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
||||
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
||||
for _, mf := range album {
|
||||
file := a.albumFilename(mf, format, isMultiDisc)
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Stop iterating: continuing would just rack up more
|
||||
// rejections from the limiter. Close finalises whatever
|
||||
// tracks were already written; the rejected one is not
|
||||
// present in the archive (addFileToZip aborts before
|
||||
// writing its entry header).
|
||||
_ = z.Close()
|
||||
return addErr
|
||||
}
|
||||
}
|
||||
}
|
||||
err = z.Close()
|
||||
@ -120,7 +129,12 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||
for idx, mf := range mfs {
|
||||
file := a.playlistFilename(mf, format, idx)
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Abort the whole archive: continuing would silently emit
|
||||
// empty zip entries since the headers are already written.
|
||||
_ = z.Close()
|
||||
return addErr
|
||||
}
|
||||
mf.Path = file
|
||||
zippedMfs[idx] = mf
|
||||
}
|
||||
@ -162,6 +176,27 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
|
||||
|
||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
||||
path := mf.AbsolutePath()
|
||||
|
||||
// Open the source before writing the zip entry header so a rejection
|
||||
// (limiter, missing file, etc.) does not leave an empty entry in the
|
||||
// archive.
|
||||
var r io.ReadCloser
|
||||
var err error
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
w, err := z.CreateHeader(&zip.FileHeader{
|
||||
Name: filename,
|
||||
Modified: mf.UpdatedAt,
|
||||
@ -172,23 +207,6 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||
return err
|
||||
}
|
||||
|
||||
var r io.ReadCloser
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(w, r)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error zipping file", "file", path, err)
|
||||
|
||||
@ -89,6 +89,32 @@ var _ = Describe("Archiver", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("when the transcode limiter rejects a file", func() {
|
||||
It("aborts the archive instead of continuing with empty entries", func() {
|
||||
mfs := model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
||||
}
|
||||
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{
|
||||
Filters: squirrel.Eq{"album_id": "1"},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
||||
Return(nil, stream.ErrTooManyTranscodes).Once()
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
||||
Expect(err).To(MatchError(stream.ErrTooManyTranscodes))
|
||||
// NewStream should only have been called once: the loop must bail
|
||||
// out on the rejection instead of trying every remaining track.
|
||||
ms.AssertNumberOfCalls(GinkgoT(), "NewStream", 1)
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipShare", func() {
|
||||
It("zips a share correctly", func() {
|
||||
mfs := model.MediaFiles{
|
||||
|
||||
@ -169,7 +169,7 @@ func BenchmarkArtworkGetE2EConcurrent(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for g := 0; g < n; g++ {
|
||||
for range n {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r, _, err := aw.Get(context.Background(), artID, 300, true)
|
||||
|
||||
@ -35,8 +35,8 @@ func generatePNG(t testing.TB, width, height int) []byte {
|
||||
// generateGradientImage creates an RGBA image with a diagonal gradient pattern.
|
||||
func generateGradientImage(width, height int) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
r := uint8((x * 255) / width)
|
||||
g := uint8((y * 255) / height)
|
||||
b := uint8(((x + y) * 255) / (width + height))
|
||||
|
||||
@ -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() {
|
||||
|
||||
4
core/external/provider.go
vendored
4
core/external/provider.go
vendored
@ -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,
|
||||
|
||||
6
core/external/provider_artistimage_test.go
vendored
6
core/external/provider_artistimage_test.go
vendored
@ -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")
|
||||
|
||||
@ -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})
|
||||
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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
|
||||
@ -497,8 +496,8 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
||||
// Pre-input seeking: ffmpeg seeks at the demuxer level (fast)
|
||||
// instead of decoding all frames up to the offset (slow).
|
||||
insertAt := len(args)
|
||||
for i := len(args) - 1; i >= 0; i-- {
|
||||
if args[i] == "-i" {
|
||||
for i, arg := range slices.Backward(args) {
|
||||
if arg == "-i" {
|
||||
insertAt = i
|
||||
break
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
sync "sync"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
// --- REST adapter (follows Share/Library pattern) ---
|
||||
@ -34,8 +36,8 @@ func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
|
||||
return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Update(id string, entity any, _ ...string) error {
|
||||
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist))
|
||||
func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
|
||||
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Delete(id string) error {
|
||||
@ -79,7 +81,15 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri
|
||||
|
||||
// updatePlaylistEntity updates playlist metadata with permission checks.
|
||||
// Used by the REST API wrapper.
|
||||
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error {
|
||||
//
|
||||
// cols names the fields the client actually sent in the JSON body (extracted by
|
||||
// rest.Put). When non-empty, fields outside cols are not considered changed and
|
||||
// are left untouched — this prevents partial requests like bulk "Make Public"
|
||||
// (body: {"public": true}) from wiping fields that just happen to be zero in
|
||||
// the deserialized entity (see issue #5541). An empty cols means "treat the
|
||||
// entity as a complete record" — preserved for callers that don't use the REST
|
||||
// wrapper.
|
||||
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
|
||||
current, err := s.checkWritable(ctx, id)
|
||||
if err != nil {
|
||||
switch {
|
||||
@ -91,41 +101,92 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
sent := sentFields(cols)
|
||||
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
|
||||
ownerChanged := sent("ownerId") && entity.OwnerID != "" && entity.OwnerID != current.OwnerID
|
||||
if !usr.IsAdmin && ownerChanged {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
contentChanged := entity.Name != current.Name ||
|
||||
entity.Comment != current.Comment ||
|
||||
(entity.OwnerID != "" && entity.OwnerID != current.OwnerID) ||
|
||||
!rulesEqual(current.Rules, entity.Rules)
|
||||
nameChanged := sent("name") && entity.Name != current.Name
|
||||
commentChanged := sent("comment") && entity.Comment != current.Comment
|
||||
rulesChanged := sent("rules") && !rulesEqual(current.Rules, entity.Rules)
|
||||
|
||||
if contentChanged {
|
||||
if entity.OwnerID != "" {
|
||||
current.OwnerID = entity.OwnerID
|
||||
}
|
||||
if nameChanged || commentChanged || ownerChanged || rulesChanged {
|
||||
return s.applyContentUpdate(ctx, current, entity, sent,
|
||||
nameChanged, commentChanged, ownerChanged, rulesChanged)
|
||||
}
|
||||
return s.applyFlagsOnly(ctx, current, entity, sent)
|
||||
}
|
||||
|
||||
// applyContentUpdate handles updates that change at least one of name/comment/
|
||||
// owner/rules. It goes through updateMetadata, which always bumps updatedAt
|
||||
// (invalidating cached cover-art URLs). namePtr/commentPtr are nil when the
|
||||
// field is absent from the request OR present-but-unchanged (so updateMetadata
|
||||
// skips them); publicPtr is nil only when public is absent from the request
|
||||
// (an idempotent public value is still forwarded).
|
||||
func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *model.Playlist,
|
||||
sent func(string) bool, nameChanged, commentChanged, ownerChanged, rulesChanged bool,
|
||||
) error {
|
||||
if ownerChanged {
|
||||
current.OwnerID = entity.OwnerID
|
||||
}
|
||||
if rulesChanged {
|
||||
current.Rules = entity.Rules
|
||||
if current.Path != "" && current.Sync != entity.Sync {
|
||||
current.Sync = entity.Sync
|
||||
}
|
||||
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
|
||||
}
|
||||
|
||||
// Only sync/public changed — skip updatedAt so cover art URLs stay stable
|
||||
var cols []string
|
||||
if current.Path != "" && current.Sync != entity.Sync {
|
||||
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
|
||||
current.Sync = entity.Sync
|
||||
cols = append(cols, "sync")
|
||||
}
|
||||
if current.Public != entity.Public {
|
||||
var namePtr, commentPtr *string
|
||||
var publicPtr *bool
|
||||
if nameChanged {
|
||||
namePtr = &entity.Name
|
||||
}
|
||||
if commentChanged {
|
||||
commentPtr = &entity.Comment
|
||||
}
|
||||
if sent("public") {
|
||||
publicPtr = &entity.Public
|
||||
}
|
||||
return s.updateMetadata(ctx, s.ds, current, namePtr, commentPtr, publicPtr)
|
||||
}
|
||||
|
||||
// applyFlagsOnly handles updates that only toggle sync/public — skips
|
||||
// updatedAt so cover art URLs stay stable.
|
||||
func (s *playlists) applyFlagsOnly(ctx context.Context, current, entity *model.Playlist,
|
||||
sent func(string) bool,
|
||||
) error {
|
||||
var updateCols []string
|
||||
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
|
||||
current.Sync = entity.Sync
|
||||
updateCols = append(updateCols, "sync")
|
||||
}
|
||||
if sent("public") && current.Public != entity.Public {
|
||||
current.Public = entity.Public
|
||||
cols = append(cols, "public")
|
||||
updateCols = append(updateCols, "public")
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
if len(updateCols) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(current, cols...)
|
||||
return s.ds.Playlist(ctx).Put(current, updateCols...)
|
||||
}
|
||||
|
||||
// sentFields returns a predicate that reports whether a JSON field was present
|
||||
// in the request body. Matching is case-insensitive to mirror Go's json
|
||||
// decoder, which populates struct fields from case-variant keys like
|
||||
// {"Name":"x"} or {"OWNERID":"y"}. An empty cols list means "treat the entity
|
||||
// as a full record" — every field is considered sent.
|
||||
func sentFields(cols []string) func(string) bool {
|
||||
if len(cols) == 0 {
|
||||
return func(string) bool { return true }
|
||||
}
|
||||
set := slice.ToMap(cols, func(c string) (string, struct{}) { return strings.ToLower(c), struct{}{} })
|
||||
return func(field string) bool {
|
||||
_, ok := set[strings.ToLower(field)]
|
||||
return ok
|
||||
}
|
||||
}
|
||||
|
||||
func rulesEqual(a, b *criteria.Criteria) bool {
|
||||
|
||||
@ -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())
|
||||
@ -126,6 +125,25 @@ var _ = Describe("REST Adapter", func() {
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
DescribeTable("denies regular user from changing ownership under any case-variant JSON key",
|
||||
func(colName string) {
|
||||
// rest.Put's field-name extraction is case-sensitive, but Go's
|
||||
// json decoder is case-insensitive on struct fields, so any
|
||||
// {"OwnerId":"x"} / {"OWNERID":"x"} / {"ownerid":"x"} populates
|
||||
// entity.OwnerID. sentFields normalizes both sides so the
|
||||
// permission gate fires regardless of casing.
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{OwnerID: "other-user"}
|
||||
err := repo.Update("pls-1", pls, colName)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
},
|
||||
Entry("canonical camelCase", "ownerId"),
|
||||
Entry("PascalCase", "OwnerId"),
|
||||
Entry("all upper", "OWNERID"),
|
||||
Entry("all lower", "ownerid"),
|
||||
)
|
||||
|
||||
It("updates smart playlist rules", func() {
|
||||
mockPlsRepo.Data["smart-1"] = &model.Playlist{
|
||||
ID: "smart-1",
|
||||
@ -219,6 +237,156 @@ var _ = Describe("REST Adapter", func() {
|
||||
err := repo.Update("nonexistent", pls)
|
||||
Expect(err).To(Equal(rest.ErrNotFound))
|
||||
})
|
||||
|
||||
// Regression tests for #5541: partial REST updates (e.g. bulk "Make Public")
|
||||
// must only touch the fields the client actually sent. The cols list from
|
||||
// rest.Put names those fields; fields outside it must be left alone, even
|
||||
// when the deserialized entity has zero values for them.
|
||||
Context("with partial updates (cols)", func() {
|
||||
BeforeEach(func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
mockPlsRepo.Data["partial"] = &model.Playlist{
|
||||
ID: "partial",
|
||||
Name: "Original Name",
|
||||
Comment: "Original comment",
|
||||
OwnerID: "user-1",
|
||||
Public: false,
|
||||
}
|
||||
})
|
||||
|
||||
It("preserves name and comment when only public is sent (bulk Make Public)", func() {
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Public: true}, "public")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Original Name"))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
|
||||
Expect(mockPlsRepo.Last.Public).To(BeTrue())
|
||||
})
|
||||
|
||||
It("preserves name when only sync is sent for a file-backed playlist", func() {
|
||||
mockPlsRepo.Data["file-partial"] = &model.Playlist{
|
||||
ID: "file-partial",
|
||||
Name: "Keep Me",
|
||||
OwnerID: "user-1",
|
||||
Path: "/music/p.m3u",
|
||||
Sync: true,
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("file-partial", &model.Playlist{Sync: false}, "sync")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Keep Me"))
|
||||
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
|
||||
})
|
||||
|
||||
It("renames the playlist when only name is sent", func() {
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "name")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Renamed"))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
|
||||
Expect(mockPlsRepo.Last.Public).To(BeFalse())
|
||||
})
|
||||
|
||||
It("clears the comment when an empty comment is sent explicitly", func() {
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Comment: ""}, "comment")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Comment).To(BeEmpty())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Original Name"))
|
||||
})
|
||||
|
||||
It("updates rules-only on a smart playlist (Feishin-style edit)", func() {
|
||||
mockPlsRepo.Data["smart-partial"] = &model.Playlist{
|
||||
ID: "smart-partial",
|
||||
Name: "Smart Original",
|
||||
Comment: "smart comment",
|
||||
OwnerID: "user-1",
|
||||
Public: true,
|
||||
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"}
|
||||
err := repo.Update("smart-partial", &model.Playlist{Rules: newRules}, "rules")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Original"))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment"))
|
||||
Expect(mockPlsRepo.Last.Public).To(BeTrue())
|
||||
})
|
||||
|
||||
It("updates name and rules together (smart-playlist Edit form)", func() {
|
||||
mockPlsRepo.Data["smart-edit"] = &model.Playlist{
|
||||
ID: "smart-edit",
|
||||
Name: "Smart Original",
|
||||
Comment: "smart comment",
|
||||
OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"}
|
||||
err := repo.Update("smart-edit",
|
||||
&model.Playlist{Name: "Smart Renamed", Rules: newRules},
|
||||
"name", "rules")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Renamed"))
|
||||
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment"))
|
||||
})
|
||||
|
||||
It("does not bump the saved rules on an idempotent rules-only PUT", func() {
|
||||
rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
|
||||
mockPlsRepo.Data["smart-idempotent"] = &model.Playlist{
|
||||
ID: "smart-idempotent",
|
||||
Name: "Smart Idempotent",
|
||||
OwnerID: "user-1",
|
||||
Rules: rules,
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
// Same rules sent back — rulesEqual should report no change and
|
||||
// the request should no-op (no Put call).
|
||||
sameRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
|
||||
err := repo.Update("smart-idempotent", &model.Playlist{Rules: sameRules}, "rules")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).To(BeNil()) // no Put happened
|
||||
})
|
||||
|
||||
It("preserves rules when only public is sent (smart playlist + bulk Make Public)", func() {
|
||||
rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
|
||||
mockPlsRepo.Data["smart-public"] = &model.Playlist{
|
||||
ID: "smart-public",
|
||||
Name: "Smart Public",
|
||||
OwnerID: "user-1",
|
||||
Public: false,
|
||||
Rules: rules,
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("smart-public", &model.Playlist{Public: true}, "public")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Public).To(BeTrue())
|
||||
Expect(mockPlsRepo.Last.Rules).To(Equal(rules))
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Public"))
|
||||
})
|
||||
|
||||
It("does not treat a missing ownerId as an ownership transfer attempt", func() {
|
||||
// A non-admin user sending only {public:true} should not be blocked
|
||||
// just because OwnerID is the zero value in the deserialized entity.
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Public: true}, "public")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("matches cols case-insensitively (mirrors json decoder behavior)", func() {
|
||||
// Go's json decoder populates struct fields from case-variant keys
|
||||
// like {"Name":"x"}, but rest.Put's field-name extraction is
|
||||
// case-sensitive. sentFields normalizes both sides so a request
|
||||
// with {"Name":"Renamed"} is honored, not silently ignored.
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "Name")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Renamed"))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
|
||||
@ -371,7 +371,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
|
||||
}
|
||||
|
||||
if !params.IgnoreScrobble && player.ScrobbleEnabled &&
|
||||
// NowPlaying gating, by design distinct from scrobble submission:
|
||||
// - IgnoreScrobble=true -> still send NowPlaying (suppresses only the
|
||||
// scrobble submission/play-count above), mirroring the legacy scrobble
|
||||
// endpoint's submission=false behavior.
|
||||
// - player.ScrobbleEnabled=false -> never send NowPlaying.
|
||||
// External agents here are the active scrobblers (Last.fm, ListenBrainz, and
|
||||
// scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying.
|
||||
if player.ScrobbleEnabled &&
|
||||
(params.State == StateStarting || params.State == StatePlaying) {
|
||||
if info, err := p.playMap.Get(clientId); err == nil {
|
||||
p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000))
|
||||
|
||||
@ -521,6 +521,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
|
||||
It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() {
|
||||
fake.ScrobbleCalled.Store(false)
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
@ -531,6 +532,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(track.PlayCount).To(Equal(int64(0)))
|
||||
Consistently(func() bool { return fake.ScrobbleCalled.Load() }).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() {
|
||||
@ -715,14 +717,14 @@ var _ = Describe("PlayTracker", func() {
|
||||
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("does NOT dispatch when ignoreScrobble=true", func() {
|
||||
It("still dispatches NowPlaying when ignoreScrobble=true", func() {
|
||||
fake.nowPlayingCalled.Store(false)
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
IgnoreScrobble: true,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
|
||||
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("does NOT dispatch when ScrobbleEnabled=false", func() {
|
||||
@ -1133,8 +1135,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
|
||||
}
|
||||
|
||||
@ -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,10 +95,10 @@ 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]
|
||||
firstId, _, _ := strings.Cut(s.ResourceIDs, ",")
|
||||
v, err := model.GetEntityByID(r.ctx, r.ds, firstId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
135
core/stream/limiter.go
Normal file
135
core/stream/limiter.go
Normal file
@ -0,0 +1,135 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// ErrTooManyTranscodes is returned by TranscodeLimiter.Acquire when the
|
||||
// configured concurrency cap has been reached. Callers should translate this
|
||||
// into an HTTP 429 response so well-behaved clients back off and retry.
|
||||
var ErrTooManyTranscodes = errors.New("too many concurrent transcodes")
|
||||
|
||||
// RetryAfterSeconds is the value returned in the HTTP Retry-After header when
|
||||
// a request is rejected with ErrTooManyTranscodes. Most transcodes finish well
|
||||
// within this window, so retrying after this delay typically succeeds.
|
||||
const RetryAfterSeconds = 5
|
||||
|
||||
// TranscodeLimiter gates the number of concurrent ffmpeg transcodes. It enforces
|
||||
// both a global cap (to protect the host from process exhaustion) and an optional
|
||||
// per-user cap (to keep one client from starving the others). Acquire never
|
||||
// blocks: it either reserves a slot or returns ErrTooManyTranscodes immediately.
|
||||
type TranscodeLimiter interface {
|
||||
// Acquire reserves a slot for the given user. On success it returns a release
|
||||
// function that must be called exactly once when the transcode is done.
|
||||
// Calling release more than once is safe and idempotent.
|
||||
Acquire(ctx context.Context, user string) (release func(), err error)
|
||||
|
||||
// Enabled reports whether the limiter actually enforces any cap. Callers
|
||||
// can use it to decide whether to bind ffmpeg's lifetime to the request
|
||||
// context so disconnects free slots quickly, rather than letting the
|
||||
// process drain to completion in the background.
|
||||
Enabled() bool
|
||||
}
|
||||
|
||||
// NewTranscodeLimiter returns a limiter enforcing the given caps. Each cap is
|
||||
// independent: a value of zero or less disables that cap. When both caps are
|
||||
// disabled the limiter is a no-op.
|
||||
func NewTranscodeLimiter(maxConcurrent, maxPerUser int) TranscodeLimiter {
|
||||
if maxConcurrent <= 0 && maxPerUser <= 0 {
|
||||
return noopLimiter{}
|
||||
}
|
||||
l := &transcodeLimiter{maxPerUser: maxPerUser}
|
||||
if maxConcurrent > 0 {
|
||||
l.global = make(chan struct{}, maxConcurrent)
|
||||
}
|
||||
if maxPerUser > 0 {
|
||||
l.perUser = make(map[string]int)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// releasingReadCloser wraps an io.ReadCloser so that closing it also releases
|
||||
// the limiter slot exactly once. release must be the function returned by
|
||||
// TranscodeLimiter.Acquire; its own idempotency makes double-Close safe too.
|
||||
type releasingReadCloser struct {
|
||||
io.ReadCloser
|
||||
release func()
|
||||
}
|
||||
|
||||
func (r *releasingReadCloser) Close() error {
|
||||
err := r.ReadCloser.Close()
|
||||
r.release()
|
||||
return err
|
||||
}
|
||||
|
||||
type noopLimiter struct{}
|
||||
|
||||
func (noopLimiter) Acquire(context.Context, string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
func (noopLimiter) Enabled() bool { return false }
|
||||
|
||||
type transcodeLimiter struct {
|
||||
maxPerUser int
|
||||
global chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
perUser map[string]int
|
||||
}
|
||||
|
||||
func (*transcodeLimiter) Enabled() bool { return true }
|
||||
|
||||
func (l *transcodeLimiter) Acquire(_ context.Context, user string) (func(), error) {
|
||||
// Reserve a per-user slot first so a noisy user can't burn through
|
||||
// global slots only to be rejected later. An empty user key means
|
||||
// "anonymous" (e.g. public share viewers); we skip the per-user cap
|
||||
// entirely so unrelated anonymous clients do not share a bucket.
|
||||
perUserActive := l.maxPerUser > 0 && user != ""
|
||||
if perUserActive {
|
||||
l.mu.Lock()
|
||||
if l.perUser[user] >= l.maxPerUser {
|
||||
l.mu.Unlock()
|
||||
return nil, ErrTooManyTranscodes
|
||||
}
|
||||
l.perUser[user]++
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
if l.global != nil {
|
||||
select {
|
||||
case l.global <- struct{}{}:
|
||||
default:
|
||||
if perUserActive {
|
||||
l.releasePerUser(user)
|
||||
}
|
||||
return nil, ErrTooManyTranscodes
|
||||
}
|
||||
}
|
||||
|
||||
var released atomic.Bool
|
||||
return func() {
|
||||
if !released.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
if l.global != nil {
|
||||
<-l.global
|
||||
}
|
||||
if perUserActive {
|
||||
l.releasePerUser(user)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *transcodeLimiter) releasePerUser(user string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.perUser[user]--
|
||||
if l.perUser[user] <= 0 {
|
||||
delete(l.perUser, user)
|
||||
}
|
||||
}
|
||||
186
core/stream/limiter_test.go
Normal file
186
core/stream/limiter_test.go
Normal file
@ -0,0 +1,186 @@
|
||||
package stream_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TranscodeLimiter", func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
Describe("Disabled (both caps <= 0)", func() {
|
||||
It("never blocks and never returns ErrTooManyTranscodes", func() {
|
||||
lim := stream.NewTranscodeLimiter(0, 0)
|
||||
for range 100 {
|
||||
rel, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(rel).ToNot(BeNil())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Per-user cap only (no global cap)", func() {
|
||||
It("still enforces the per-user limit when MaxConcurrent is disabled", func() {
|
||||
lim := stream.NewTranscodeLimiter(0, 2)
|
||||
|
||||
rel1, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rel2, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
|
||||
// Other users have their own buckets.
|
||||
rel3, err := lim.Acquire(ctx, "bob")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel1()
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel2()
|
||||
rel3()
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Global cap", func() {
|
||||
It("rejects requests beyond MaxConcurrent with ErrTooManyTranscodes", func() {
|
||||
lim := stream.NewTranscodeLimiter(2, 0)
|
||||
|
||||
rel1, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rel2, err := lim.Acquire(ctx, "bob")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = lim.Acquire(ctx, "carol")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
|
||||
rel1()
|
||||
_, err = lim.Acquire(ctx, "carol")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel2()
|
||||
})
|
||||
|
||||
It("releases a slot only once even if release is called multiple times", func() {
|
||||
lim := stream.NewTranscodeLimiter(1, 0)
|
||||
|
||||
rel, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel()
|
||||
rel()
|
||||
rel()
|
||||
|
||||
// After releases, exactly one slot should be available.
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Per-user cap", func() {
|
||||
It("rejects a user beyond MaxConcurrentPerUser even if global slots remain", func() {
|
||||
lim := stream.NewTranscodeLimiter(10, 2)
|
||||
|
||||
rel1, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rel2, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
|
||||
// A different user is unaffected.
|
||||
rel3, err := lim.Acquire(ctx, "bob")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel1()
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel2()
|
||||
rel3()
|
||||
})
|
||||
|
||||
It("skips the per-user cap for anonymous users (empty key)", func() {
|
||||
// Anonymous requests (e.g. public share viewers) deliberately
|
||||
// bypass the per-user cap so unrelated anonymous clients are not
|
||||
// collapsed into a single shared bucket. The global cap remains
|
||||
// the only ceiling on anonymous traffic.
|
||||
lim := stream.NewTranscodeLimiter(10, 1)
|
||||
|
||||
rels := make([]func(), 0, 5)
|
||||
for range 5 {
|
||||
rel, err := lim.Acquire(ctx, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rels = append(rels, rel)
|
||||
}
|
||||
for _, rel := range rels {
|
||||
rel()
|
||||
}
|
||||
})
|
||||
|
||||
It("still applies the global cap to anonymous users", func() {
|
||||
lim := stream.NewTranscodeLimiter(2, 1)
|
||||
|
||||
rel1, err := lim.Acquire(ctx, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rel2, err := lim.Acquire(ctx, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = lim.Acquire(ctx, "")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
|
||||
rel1()
|
||||
rel2()
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Concurrent safety", func() {
|
||||
It("survives parallel Acquire/release with consistent counts", func() {
|
||||
lim := stream.NewTranscodeLimiter(5, 0)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var acquired int64
|
||||
var rejected int64
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := range 50 {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
rel, err := lim.Acquire(ctx, "alice")
|
||||
mu.Lock()
|
||||
if err == nil {
|
||||
acquired++
|
||||
mu.Unlock()
|
||||
rel()
|
||||
} else {
|
||||
rejected++
|
||||
mu.Unlock()
|
||||
}
|
||||
_ = i
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
Expect(acquired + rejected).To(Equal(int64(50)))
|
||||
// After all releases, all 5 slots should be free again.
|
||||
for range 5 {
|
||||
_, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
_, err := lim.Acquire(ctx, "alice")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2,6 +2,7 @@ package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
@ -28,13 +29,19 @@ type MediaStreamer interface {
|
||||
type TranscodingCache cache.FileCache
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds, transcoder: t, cache: cache}
|
||||
return &mediaStreamer{
|
||||
ds: ds,
|
||||
transcoder: t,
|
||||
cache: cache,
|
||||
limiter: NewTranscodeLimiter(conf.Server.Transcoding.MaxConcurrent, conf.Server.Transcoding.MaxConcurrentPerUser),
|
||||
}
|
||||
}
|
||||
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
transcoder ffmpeg.FFmpeg
|
||||
cache cache.FileCache
|
||||
limiter TranscodeLimiter
|
||||
}
|
||||
|
||||
type streamJob struct {
|
||||
@ -104,7 +111,12 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req
|
||||
}
|
||||
r, err := ms.cache.Get(ctx, job)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
|
||||
// Rate-limit rejections are already logged at warn level by the
|
||||
// producer; treating them as cache failures here would both
|
||||
// double-log and mask actual cache problems.
|
||||
if !errors.Is(err, ErrTooManyTranscodes) {
|
||||
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
cached = r.Cached
|
||||
@ -217,15 +229,31 @@ func NewTranscodingCache() TranscodingCache {
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
|
||||
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
|
||||
// This is where we decide whether transcoding processes should be cancellable or not.
|
||||
release, err := job.ms.limiter.Acquire(ctx, limiterKey(ctx))
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Refusing transcode: concurrent transcode limit reached",
|
||||
"id", job.mf.ID, "user", userName(ctx),
|
||||
"maxConcurrent", conf.Server.Transcoding.MaxConcurrent,
|
||||
"maxPerUser", conf.Server.Transcoding.MaxConcurrentPerUser)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Choose the context that drives the ffmpeg process.
|
||||
//
|
||||
// When the limiter is enabled, force the request context so a
|
||||
// client disconnect cancels ffmpeg and frees the slot promptly.
|
||||
// Otherwise a client could open many transcodes, disconnect
|
||||
// immediately, and still leave the configured cap's worth of
|
||||
// ffmpeg processes draining in the background — which is exactly
|
||||
// the DoS the limiter is meant to prevent.
|
||||
//
|
||||
// When the limiter is disabled, preserve the legacy behavior
|
||||
// governed by Transcoding.EnableCancellation so unchanged configs
|
||||
// keep their previous observable behavior.
|
||||
var transcodingCtx context.Context
|
||||
if conf.Server.EnableTranscodingCancellation {
|
||||
// Use the request context directly, allowing cancellation when client disconnects
|
||||
if job.ms.limiter.Enabled() || conf.Server.Transcoding.EnableCancellation {
|
||||
transcodingCtx = ctx
|
||||
} else {
|
||||
// Use background context with request values preserved.
|
||||
// This prevents cancellation but maintains request metadata (user, client, etc.)
|
||||
transcodingCtx = request.AddValues(context.Background(), ctx)
|
||||
}
|
||||
|
||||
@ -240,10 +268,14 @@ func NewTranscodingCache() TranscodingCache {
|
||||
Offset: job.offset,
|
||||
})
|
||||
if err != nil {
|
||||
release()
|
||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
return out, nil
|
||||
// Tie the slot to the ffmpeg process: copyAndClose calls Close
|
||||
// on this reader after io.Copy returns, which is exactly when
|
||||
// ffmpeg has exited (either EOF or context cancellation).
|
||||
return &releasingReadCloser{ReadCloser: out, release: release}, nil
|
||||
})
|
||||
}
|
||||
|
||||
@ -255,3 +287,16 @@ func userName(ctx context.Context) string {
|
||||
return user.UserName
|
||||
}
|
||||
}
|
||||
|
||||
// limiterKey returns the per-user bucket key used by the transcode limiter.
|
||||
// For anonymous requests (e.g. public shares) it returns the empty string,
|
||||
// which signals the limiter to skip the per-user cap entirely — otherwise
|
||||
// every anonymous viewer of a public share would collide on the same key
|
||||
// and starve each other within MaxConcurrentPerUser slots. The global cap
|
||||
// still applies and remains the protection against runaway anonymous load.
|
||||
func limiterKey(ctx context.Context) string {
|
||||
if user, ok := request.UserFrom(ctx); ok {
|
||||
return user.UserName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package stream_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
@ -10,6 +11,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -61,6 +63,70 @@ var _ = Describe("MediaStreamer", func() {
|
||||
Expect(s.Seekable()).To(BeFalse())
|
||||
Expect(s.Duration()).To(Equal(float32(257.0)))
|
||||
})
|
||||
It("rejects transcode requests beyond MaxConcurrent with ErrTooManyTranscodes", func() {
|
||||
// Use an ffmpeg whose Read blocks indefinitely so the cache's
|
||||
// background copy can't drain the source and release the slot —
|
||||
// keeping the single transcode slot pinned for this test.
|
||||
pr, pw := io.Pipe()
|
||||
DeferCleanup(func() { _ = pw.Close() })
|
||||
blockingFFmpeg := tests.NewMockFFmpeg("")
|
||||
blockingFFmpeg.Reader = pr
|
||||
|
||||
conf.Server.Transcoding.MaxConcurrent = 1
|
||||
conf.Server.Transcoding.MaxConcurrentPerUser = 0
|
||||
tightCache := stream.NewTranscodingCache()
|
||||
Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
tightStreamer := stream.NewMediaStreamer(ds, blockingFFmpeg, tightCache)
|
||||
|
||||
userCtx := request.WithUsername(ctx, "alice")
|
||||
s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer s1.Close()
|
||||
|
||||
// Different cache key so it doesn't dedupe with the first request.
|
||||
_, err = tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96})
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("releases the slot once the stream is closed", func() {
|
||||
conf.Server.Transcoding.MaxConcurrent = 1
|
||||
conf.Server.Transcoding.MaxConcurrentPerUser = 0
|
||||
tightCache := stream.NewTranscodingCache()
|
||||
Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache)
|
||||
|
||||
userCtx := request.WithUsername(ctx, "alice")
|
||||
s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, _ = io.ReadAll(s1)
|
||||
_ = s1.Close()
|
||||
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
|
||||
|
||||
// Slot should now be free for a different transcode.
|
||||
s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer s2.Close()
|
||||
})
|
||||
|
||||
It("does not consume a slot for raw streams", func() {
|
||||
conf.Server.Transcoding.MaxConcurrent = 1
|
||||
conf.Server.Transcoding.MaxConcurrentPerUser = 0
|
||||
tightCache := stream.NewTranscodingCache()
|
||||
Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache)
|
||||
|
||||
userCtx := request.WithUsername(ctx, "alice")
|
||||
// First, saturate the single transcode slot.
|
||||
s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer s1.Close()
|
||||
|
||||
// Raw stream must still succeed.
|
||||
s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "raw"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer s2.Close()
|
||||
})
|
||||
|
||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
||||
s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32})
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
@ -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;
|
||||
18
go.mod
18
go.mod
@ -20,7 +20,7 @@ require (
|
||||
github.com/extism/go-sdk v1.7.1
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/gen2brain/webp v0.5.5
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0
|
||||
@ -39,8 +39,8 @@ require (
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.28.3
|
||||
github.com/onsi/gomega v1.40.0
|
||||
github.com/onsi/ginkgo/v2 v2.29.0
|
||||
github.com/onsi/gomega v1.41.0
|
||||
github.com/pelletier/go-toml/v2 v2.3.1
|
||||
github.com/pmezard/go-difflib v1.0.0
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
@ -59,10 +59,10 @@ require (
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||
go.senan.xyz/taglib v0.11.1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/image v0.40.0
|
||||
golang.org/x/net v0.54.0
|
||||
golang.org/x/image v0.41.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.44.0
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/time v0.15.0
|
||||
@ -81,7 +81,7 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
@ -115,7 +115,7 @@ require (
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sanity-io/litter v1.5.8 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
@ -133,7 +133,7 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
|
||||
36
go.sum
36
go.sum
@ -54,8 +54,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
|
||||
github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
|
||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
@ -73,8 +73,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ
|
||||
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
|
||||
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
|
||||
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||
@ -193,10 +193,10 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||
github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4=
|
||||
github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc=
|
||||
github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
|
||||
github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag=
|
||||
github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA=
|
||||
github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
@ -224,8 +224,8 @@ github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4Ug
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||
@ -316,10 +316,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@ -338,8 +338,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@ -364,8 +364,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE=
|
||||
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
|
||||
|
||||
@ -36,6 +36,6 @@ func (f *journalFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
||||
if !ok {
|
||||
priority = 6 // default to info for unknown levels
|
||||
}
|
||||
prefix := []byte(fmt.Sprintf("<%d>", priority))
|
||||
prefix := fmt.Appendf(nil, "<%d>", priority)
|
||||
return append(prefix, formatted...), nil
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
package criteria
|
||||
|
||||
var StartOfPeriod = startOfPeriod
|
||||
|
||||
type UnmarshalConjunctionType = unmarshalConjunctionType
|
||||
|
||||
@ -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
|
||||
@ -142,10 +140,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) {
|
||||
|
||||
@ -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"},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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},
|
||||
}
|
||||
|
||||
@ -684,6 +684,26 @@ var _ = Describe("Participants", func() {
|
||||
Expect(composers[2].Name).To(Equal("The Album Artist"))
|
||||
})
|
||||
})
|
||||
|
||||
// Sibling fix to https://github.com/navidrome/navidrome/issues/5065: when
|
||||
// multiple frames map to the same role tag (e.g. TIPL producer entries),
|
||||
// the configured split separator must still apply to each value.
|
||||
When("the tag has multiple values", func() {
|
||||
It("should split each value individually", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"COMPOSER": {"John Doe/Jane Doe", "Someone Else"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
|
||||
composers := participants[model.RoleComposer]
|
||||
Expect(composers).To(ConsistOf(
|
||||
HaveField("Name", "John Doe"),
|
||||
HaveField("Name", "Jane Doe"),
|
||||
HaveField("Name", "Someone Else"),
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MBID tags", func() {
|
||||
|
||||
@ -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"
|
||||
)
|
||||
@ -130,6 +129,21 @@ var _ = Describe("Metadata", func() {
|
||||
|
||||
Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"}))
|
||||
})
|
||||
|
||||
// Regression test for https://github.com/navidrome/navidrome/issues/5065
|
||||
//
|
||||
// MP3s with both an ID3v2 TMOO frame and a TXXX:MOOD frame are surfaced by
|
||||
// TagLib's PropertyMap as a single "mood" key with multiple values. The split
|
||||
// configuration must still apply to each value individually.
|
||||
It("should split values from multiple frames mapping to the same tag", func() {
|
||||
props.Tags = model.RawTags{
|
||||
// Same shape as the bug report: two frames, comma-separated content.
|
||||
"mood": {"Love, Emotional, Ballad", "Love; Emotional; Ballad"},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.Strings(model.TagMood)).To(ConsistOf("Love", "Emotional", "Ballad"))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("Date",
|
||||
@ -274,8 +288,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 +299,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 +313,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),
|
||||
)
|
||||
|
||||
@ -34,23 +34,25 @@ type TagConf struct {
|
||||
SplitRx *regexp.Regexp `yaml:"-"`
|
||||
}
|
||||
|
||||
// SplitTagValue splits a tag value by the split separators, but only if it has a single value.
|
||||
// SplitTagValue splits tag values by the configured split separators.
|
||||
// Each value in the input slice is individually split and trimmed.
|
||||
func (c TagConf) SplitTagValue(values []string) []string {
|
||||
// If there's not exactly one value or no separators, return early.
|
||||
if len(values) != 1 || c.SplitRx == nil {
|
||||
if c.SplitRx == nil || len(values) == 0 {
|
||||
return values
|
||||
}
|
||||
tag := values[0]
|
||||
|
||||
// Replace all occurrences of any separator with the zero-width space.
|
||||
tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp)
|
||||
var result []string
|
||||
for _, tag := range values {
|
||||
// Replace all occurrences of any separator with the zero-width space.
|
||||
tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp)
|
||||
|
||||
// Split by the zero-width space and trim each substring.
|
||||
parts := strings.Split(tag, consts.Zwsp)
|
||||
for i, part := range parts {
|
||||
parts[i] = strings.TrimSpace(part)
|
||||
// Split by the zero-width space and trim each substring.
|
||||
parts := strings.SplitSeq(tag, consts.Zwsp)
|
||||
for part := range parts {
|
||||
result = append(result, strings.TrimSpace(part))
|
||||
}
|
||||
}
|
||||
return parts
|
||||
return result
|
||||
}
|
||||
|
||||
type TagType string
|
||||
|
||||
64
model/tag_mappings_test.go
Normal file
64
model/tag_mappings_test.go
Normal file
@ -0,0 +1,64 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TagConf", func() {
|
||||
Describe("SplitTagValue", func() {
|
||||
var conf TagConf
|
||||
|
||||
BeforeEach(func() {
|
||||
conf = TagConf{Split: []string{";", "/", ","}}
|
||||
conf.SplitRx = compileSplitRegex("test", conf.Split)
|
||||
})
|
||||
|
||||
It("splits a single value on configured separators", func() {
|
||||
Expect(conf.SplitTagValue([]string{"Rock/Pop;Punk"})).To(Equal([]string{"Rock", "Pop", "Punk"}))
|
||||
})
|
||||
|
||||
It("trims whitespace around split values", func() {
|
||||
Expect(conf.SplitTagValue([]string{"Love, Emotional, Ballad"})).To(Equal([]string{"Love", "Emotional", "Ballad"}))
|
||||
})
|
||||
|
||||
// Regression test for https://github.com/navidrome/navidrome/issues/5065
|
||||
//
|
||||
// When multiple ID3v2 frames map to the same logical tag (e.g. TMOO + TXXX:MOOD),
|
||||
// TagLib's PropertyMap merges them into a slice with several entries. Previously
|
||||
// SplitTagValue had a `len(values) != 1` guard that skipped splitting in this case.
|
||||
It("splits each value individually when given multiple inputs", func() {
|
||||
input := []string{"Love, Emotional, Ballad", "Love; Emotional; Ballad"}
|
||||
Expect(conf.SplitTagValue(input)).To(Equal([]string{
|
||||
"Love", "Emotional", "Ballad",
|
||||
"Love", "Emotional", "Ballad",
|
||||
}))
|
||||
})
|
||||
|
||||
It("matches separators case-insensitively when the split pattern allows", func() {
|
||||
c := TagConf{Split: []string{" AND "}}
|
||||
c.SplitRx = compileSplitRegex("test", c.Split)
|
||||
Expect(c.SplitTagValue([]string{"foo and bar AND baz"})).To(Equal([]string{"foo", "bar", "baz"}))
|
||||
})
|
||||
|
||||
It("returns values unchanged when no separators are configured", func() {
|
||||
c := TagConf{}
|
||||
Expect(c.SplitTagValue([]string{"Foo, Bar"})).To(Equal([]string{"Foo, Bar"}))
|
||||
Expect(c.SplitTagValue([]string{"a", "b"})).To(Equal([]string{"a", "b"}))
|
||||
})
|
||||
|
||||
It("returns an empty slice for empty input", func() {
|
||||
Expect(conf.SplitTagValue([]string{})).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("handles a value with no separator as a single-element result", func() {
|
||||
Expect(conf.SplitTagValue([]string{"JustOneMood"})).To(Equal([]string{"JustOneMood"}))
|
||||
})
|
||||
|
||||
It("produces empty strings when separators are adjacent (dedup happens downstream)", func() {
|
||||
// SplitTagValue itself does not filter empties; that is the job of
|
||||
// filterDuplicatedOrEmptyValues in the metadata pipeline.
|
||||
Expect(conf.SplitTagValue([]string{"Rock//Pop"})).To(Equal([]string{"Rock", "", "Pop"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
@ -381,17 +383,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) {
|
||||
|
||||
236
persistence/criteria_sql_benchmark_test.go
Normal file
236
persistence/criteria_sql_benchmark_test.go
Normal 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)
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -56,9 +58,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),
|
||||
@ -70,9 +72,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')"),
|
||||
@ -80,9 +82,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() {
|
||||
@ -204,6 +206,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"}
|
||||
|
||||
@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -104,6 +104,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
|
||||
"missing": booleanFilter,
|
||||
"artists_id": artistFilter,
|
||||
"library_id": libraryIdFilter,
|
||||
"path": startsWithFilter("media_file.path"),
|
||||
}
|
||||
// Add all album tags as filters
|
||||
for tag := range model.TagMappings() {
|
||||
|
||||
@ -524,6 +524,34 @@ var _ = Describe("MediaRepository", func() {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("path", func() {
|
||||
It("matches files whose path starts with the given prefix", func() {
|
||||
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"path": "test/"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
files := res.(model.MediaFiles)
|
||||
|
||||
var found bool
|
||||
for _, f := range files {
|
||||
Expect(f.Path).To(HavePrefix("test/"))
|
||||
if f.ID == mfWithoutAnnotation.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
Expect(found).To(BeTrue(), "MediaFile with matching path prefix should be included")
|
||||
})
|
||||
|
||||
It("excludes files whose path does not start with the given prefix", func() {
|
||||
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"path": "no-such-prefix/"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
files := res.(model.MediaFiles)
|
||||
Expect(files).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Search", func() {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -62,18 +62,6 @@ func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBu
|
||||
return s.Where(r.addRestriction())
|
||||
}
|
||||
|
||||
func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer {
|
||||
s := And{}
|
||||
if len(sql) > 0 {
|
||||
s = append(s, sql[0])
|
||||
}
|
||||
u := loggedUser(r.ctx)
|
||||
if u.IsAdmin {
|
||||
return s
|
||||
}
|
||||
return append(s, Eq{"user_id": u.ID})
|
||||
}
|
||||
|
||||
func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[string]int64, error) {
|
||||
sel := r.newSelect(options...).
|
||||
Columns(
|
||||
@ -125,6 +113,10 @@ func (r *playerRepository) NewInstance() any {
|
||||
return &model.Player{}
|
||||
}
|
||||
|
||||
// isPermitted authorizes creating a new record, based on the owner declared in the request body.
|
||||
// This is only safe for inserts: there is no stored row yet, and a non-admin may only create a
|
||||
// player they own. Updates must not use this (the body owner is attacker-controlled); they go
|
||||
// through updateOwned, which authorizes against the persisted user_id in the WHERE clause.
|
||||
func (r *playerRepository) isPermitted(p *model.Player) bool {
|
||||
u := loggedUser(r.ctx)
|
||||
return u.IsAdmin || p.UserId == u.ID
|
||||
@ -145,23 +137,11 @@ func (r *playerRepository) Save(entity any) (string, error) {
|
||||
func (r *playerRepository) Update(id string, entity any, cols ...string) error {
|
||||
t := entity.(*model.Player)
|
||||
t.ID = id
|
||||
if !r.isPermitted(t) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
_, err := r.put(id, t, cols...)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
return r.updateOwned(id, t, cols...)
|
||||
}
|
||||
|
||||
func (r *playerRepository) Delete(id string) error {
|
||||
filter := r.addRestriction(And{Eq{"player.id": id}})
|
||||
err := r.delete(filter)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
return r.deleteOwned(id)
|
||||
}
|
||||
|
||||
var _ model.PlayerRepository = (*playerRepository)(nil)
|
||||
|
||||
@ -110,33 +110,43 @@ var _ = Describe("PlayerRepository", func() {
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
DescribeTable("item type", func(player model.Player) {
|
||||
err := repo.Delete(player.ID)
|
||||
It("deletes a player owned by the current user", func() {
|
||||
err := repo.Delete(userPlayer.ID)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
isReal := player.UserId != ""
|
||||
canDelete := admin || player.UserId == userPlayer.UserId
|
||||
|
||||
count, err := repo.Count()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(count).To(Equal(baseCount - 1))
|
||||
|
||||
if isReal && canDelete {
|
||||
Expect(count).To(Equal(baseCount - 1))
|
||||
} else {
|
||||
Expect(count).To(Equal(baseCount))
|
||||
}
|
||||
_, err = repo.Get(userPlayer.ID)
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
item, err := repo.Get(player.ID)
|
||||
if !isReal || canDelete {
|
||||
It("does not delete another user's player when not admin", func() {
|
||||
err := repo.Delete(otherPlayer.ID)
|
||||
|
||||
if admin {
|
||||
// Admins may delete any player.
|
||||
Expect(err).To(BeNil())
|
||||
Expect(repo.Count()).To(Equal(baseCount - 1))
|
||||
_, err = repo.Get(otherPlayer.ID)
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
} else {
|
||||
Expect(*item).To(Equal(player))
|
||||
// The ownership-restricted delete matches no owned row, so it reports
|
||||
// permission-denied and leaves the other user's player untouched.
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
Expect(repo.Count()).To(Equal(baseCount))
|
||||
item, err := repo.Get(otherPlayer.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(*item).To(Equal(otherPlayer))
|
||||
}
|
||||
},
|
||||
Entry("same user", userPlayer),
|
||||
Entry("other item", otherPlayer),
|
||||
Entry("fake item", model.Player{}),
|
||||
)
|
||||
})
|
||||
|
||||
It("returns not-found for a nonexistent player", func() {
|
||||
err := repo.Delete("i don't exist")
|
||||
Expect(err).To(Equal(rest.ErrNotFound))
|
||||
Expect(repo.Count()).To(Equal(baseCount))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Read", func() {
|
||||
@ -215,9 +225,12 @@ var _ = Describe("PlayerRepository", func() {
|
||||
clone.MaxBitRate = 10000
|
||||
err := repo.Update(clone.ID, &clone, "ip")
|
||||
|
||||
if clone.UserId == "" {
|
||||
if player.UserId == "" {
|
||||
Expect(err).To(HaveOccurred())
|
||||
} else if !admin && player.Username == adminPlayer1.Username {
|
||||
// A non-admin cannot target another user's player: the ownership-restricted
|
||||
// update matches no owned row, so it reports permission-denied rather than
|
||||
// touching it.
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
clone.IP = player.IP
|
||||
} else {
|
||||
@ -244,4 +257,86 @@ var _ = Describe("PlayerRepository", func() {
|
||||
Entry("admin context", true, players, adminPlayer1, regularPlayer),
|
||||
Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1),
|
||||
)
|
||||
|
||||
Describe("Ownership enforcement (cross-tenant write protection)", func() {
|
||||
var regularRepo *playerRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, regularUser)
|
||||
regularRepo = NewPlayerRepository(ctx, database).(*playerRepository)
|
||||
})
|
||||
|
||||
It("does not let a regular user hijack another user's player by spoofing userId in the body", func() {
|
||||
// Attacker (regularUser) targets the victim's (adminUser) player by URL id,
|
||||
// but sets userId in the body to their own id to try to pass the permission check.
|
||||
spoofed := model.Player{
|
||||
ID: adminPlayer1.ID,
|
||||
Name: "HIJACKED",
|
||||
UserId: regularUser.ID, // attacker's own id, spoofed in the body
|
||||
MaxBitRate: 1,
|
||||
}
|
||||
|
||||
// The ownership-restricted update matches no row owned by the attacker, so the write
|
||||
// targets nothing and reports permission-denied rather than overwriting the victim's row.
|
||||
err := regularRepo.Update(adminPlayer1.ID, &spoofed, "name", "user_id", "max_bit_rate")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
|
||||
// The victim's player must remain untouched.
|
||||
stored, err := adminRepo.Get(adminPlayer1.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(*stored).To(Equal(adminPlayer1))
|
||||
})
|
||||
|
||||
It("does not let a regular user reassign their own player to another user", func() {
|
||||
// Owner updates their own player but tries to give it away to the admin. The update
|
||||
// succeeds for the other fields, but user_id is never written, so ownership stays put.
|
||||
reassign := regularPlayer
|
||||
reassign.UserId = adminUser.ID
|
||||
reassign.Name = "given-away"
|
||||
|
||||
err := regularRepo.Update(regularPlayer.ID, &reassign, "name", "user_id")
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
// Ownership must not have changed.
|
||||
stored, err := adminRepo.Get(regularPlayer.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(stored.UserId).To(Equal(regularUser.ID))
|
||||
})
|
||||
|
||||
It("does not let an admin reassign a player to another user", func() {
|
||||
// Even an admin cannot change a player's owner via update.
|
||||
reassign := regularPlayer
|
||||
reassign.UserId = adminUser.ID
|
||||
reassign.Name = "admin-renamed"
|
||||
|
||||
err := adminRepo.Update(regularPlayer.ID, &reassign, "name", "user_id")
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
// The name change applies, but ownership must not have moved.
|
||||
stored, err := adminRepo.Get(regularPlayer.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(stored.Name).To(Equal("admin-renamed"))
|
||||
Expect(stored.UserId).To(Equal(regularUser.ID))
|
||||
})
|
||||
|
||||
It("lets the owner update their own player", func() {
|
||||
update := regularPlayer
|
||||
update.Name = "renamed-by-owner"
|
||||
|
||||
err := regularRepo.Update(regularPlayer.ID, &update, "name")
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
stored, err := adminRepo.Get(regularPlayer.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(stored.Name).To(Equal("renamed-by-owner"))
|
||||
Expect(stored.UserId).To(Equal(regularUser.ID))
|
||||
})
|
||||
|
||||
It("returns not found when updating a nonexistent player", func() {
|
||||
ghost := model.Player{ID: "does-not-exist", Name: "ghost", UserId: regularUser.ID}
|
||||
err := regularRepo.Update("does-not-exist", &ghost, "name")
|
||||
Expect(err).To(Equal(rest.ErrNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
})
|
||||
|
||||
|
||||
@ -30,47 +30,18 @@ func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareReposito
|
||||
return r
|
||||
}
|
||||
|
||||
// TODO: Ownership checks should be moved to the service layer (core/share.go)
|
||||
func (r *shareRepository) checkOwnership(id string) error {
|
||||
usr := loggedUser(r.ctx)
|
||||
if usr.IsAdmin || usr.ID == invalidUserId {
|
||||
return nil
|
||||
}
|
||||
sel := r.newSelect().Columns("user_id").Where(Eq{"id": id})
|
||||
var share struct {
|
||||
UserID string `db:"user_id"`
|
||||
}
|
||||
err := r.queryOne(sel, &share)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if share.UserID != usr.ID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *shareRepository) Delete(id string) error {
|
||||
if err := r.checkOwnership(id); err != nil {
|
||||
return err
|
||||
}
|
||||
err := r.delete(Eq{"id": id})
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
return r.deleteOwned(id)
|
||||
}
|
||||
|
||||
func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelect(options...).Join("user u on u.id = share.user_id").
|
||||
Columns("share.*", "user_name as username")
|
||||
Columns("share.*", "user_name as username").
|
||||
Where(r.addRestriction())
|
||||
}
|
||||
|
||||
func (r *shareRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Eq{"id": id})
|
||||
return r.exists(r.addRestriction(And{Eq{"id": id}}))
|
||||
}
|
||||
|
||||
func (r *shareRepository) Get(id string) (*model.Share, error) {
|
||||
@ -166,17 +137,12 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles {
|
||||
|
||||
func (r *shareRepository) Update(id string, entity any, cols ...string) error {
|
||||
s := entity.(*model.Share)
|
||||
if err := r.checkOwnership(id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.ID = id
|
||||
s.UpdatedAt = time.Now()
|
||||
cols = append(cols, "updated_at")
|
||||
_, err := r.put(id, s, cols...)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
if len(cols) > 0 {
|
||||
cols = append(cols, "updated_at")
|
||||
}
|
||||
return err
|
||||
return r.updateOwned(id, s, cols...)
|
||||
}
|
||||
|
||||
func (r *shareRepository) Save(entity any) (string, error) {
|
||||
|
||||
@ -20,7 +20,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = request.WithUser(log.NewContext(context.TODO()), adminUser)
|
||||
ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
||||
repo = NewShareRepository(ctx, GetDBXBuilder())
|
||||
|
||||
// Insert the admin user into the database (required for foreign key constraint)
|
||||
@ -38,7 +38,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
Context("Repository creation and basic operations", func() {
|
||||
It("should create repository successfully with no user context", func() {
|
||||
// Create repository with no user context (headless)
|
||||
headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder())
|
||||
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
Expect(headlessRepo).ToNot(BeNil())
|
||||
})
|
||||
|
||||
@ -60,7 +60,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Headless process should see all shares
|
||||
headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder())
|
||||
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
shares, err := headlessRepo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -92,7 +92,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Headless process should be able to get the share
|
||||
headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder())
|
||||
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
share, err := headlessRepo.Get(shareID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(share.ID).To(Equal(shareID))
|
||||
@ -155,7 +155,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
Describe("Delete", func() {
|
||||
It("allows a non-admin user to delete their own share", func() {
|
||||
insertShare("own-share-del", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
|
||||
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Delete("own-share-del")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -163,15 +163,21 @@ var _ = Describe("ShareRepository", func() {
|
||||
|
||||
It("denies a non-admin user from deleting another user's share", func() {
|
||||
insertShare("other-share-del", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), otherUser)
|
||||
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Delete("other-share-del")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
|
||||
// The share was not deleted: the owner can still read it.
|
||||
ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
|
||||
ownerRepo := NewShareRepository(ownerCtx, GetDBXBuilder())
|
||||
_, err = ownerRepo.(rest.Repository).Read("other-share-del")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows an admin to delete any user's share", func() {
|
||||
insertShare("admin-del-share", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), adminUser)
|
||||
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Delete("admin-del-share")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -179,7 +185,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
|
||||
It("allows headless context (no user) to delete a share", func() {
|
||||
insertShare("headless-del-share", ownerUser.ID)
|
||||
repo := NewShareRepository(context.Background(), GetDBXBuilder())
|
||||
repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Delete("headless-del-share")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
@ -188,7 +194,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
Describe("Update", func() {
|
||||
It("allows a non-admin user to update their own share", func() {
|
||||
insertShare("own-share-upd", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
|
||||
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("own-share-upd", &model.Share{Description: "Updated"}, "description")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -196,7 +202,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
|
||||
It("denies a non-admin user from updating another user's share", func() {
|
||||
insertShare("other-share-upd", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), otherUser)
|
||||
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("other-share-upd", &model.Share{Description: "Hacked"}, "description")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
@ -204,7 +210,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
|
||||
It("allows an admin to update any user's share", func() {
|
||||
insertShare("admin-upd-share", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), adminUser)
|
||||
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("admin-upd-share", &model.Share{Description: "Admin Updated"}, "description")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -212,10 +218,178 @@ var _ = Describe("ShareRepository", func() {
|
||||
|
||||
It("allows headless context (no user) to update a share", func() {
|
||||
insertShare("headless-upd-share", ownerUser.ID)
|
||||
repo := NewShareRepository(context.Background(), GetDBXBuilder())
|
||||
repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("headless-upd-share", &model.Share{Description: "Headless"}, "description")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns not found when updating a nonexistent share", func() {
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("does-not-exist", &model.Share{Description: "Ghost"}, "description")
|
||||
Expect(err).To(Equal(rest.ErrNotFound))
|
||||
})
|
||||
|
||||
It("updates all columns when no specific columns are given", func() {
|
||||
insertShare("all-cols-share", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
// No cols: the update must write every column, not just updated_at.
|
||||
err := repo.(rest.Persistable).Update("all-cols-share",
|
||||
&model.Share{Description: "All Updated", MaxBitRate: 192, ResourceType: "album", ResourceIDs: "2002"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
got, err := repo.(rest.Repository).Read("all-cols-share")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
share := got.(*model.Share)
|
||||
Expect(share.Description).To(Equal("All Updated"))
|
||||
Expect(share.MaxBitRate).To(Equal(192))
|
||||
Expect(share.ResourceType).To(Equal("album"))
|
||||
})
|
||||
|
||||
It("does not let an owner reassign their share to another user", func() {
|
||||
insertShare("reassign-share", ownerUser.ID)
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
|
||||
repo := NewShareRepository(ctx, GetDBXBuilder())
|
||||
err := repo.(rest.Persistable).Update("reassign-share",
|
||||
&model.Share{UserID: otherUser.ID, Description: "Given away"}, "user_id", "description")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Ownership must not have moved, even though user_id was passed in the body and cols.
|
||||
got, err := repo.(rest.Repository).Read("reassign-share")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.(*model.Share).UserID).To(Equal(ownerUser.ID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Read scoping", func() {
|
||||
BeforeEach(func() {
|
||||
// Persist owner/other users so the JOIN in selectShare resolves.
|
||||
ur := NewUserRepository(ctx, GetDBXBuilder())
|
||||
Expect(ur.Put(&ownerUser)).To(Succeed())
|
||||
Expect(ur.Put(&otherUser)).To(Succeed())
|
||||
|
||||
insertShare("share-owner-1", ownerUser.ID)
|
||||
insertShare("share-owner-2", ownerUser.ID)
|
||||
insertShare("share-other-1", otherUser.ID)
|
||||
})
|
||||
|
||||
Context("non-admin user", func() {
|
||||
var nonAdminRepo model.ShareRepository
|
||||
var nonAdminRest rest.Repository
|
||||
|
||||
BeforeEach(func() {
|
||||
nonAdminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
|
||||
nonAdminRepo = NewShareRepository(nonAdminCtx, GetDBXBuilder())
|
||||
nonAdminRest = nonAdminRepo.(rest.Repository)
|
||||
})
|
||||
|
||||
It("GetAll returns only own shares", func() {
|
||||
shares, err := nonAdminRepo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ids := make([]string, len(shares))
|
||||
for i, s := range shares {
|
||||
ids[i] = s.ID
|
||||
}
|
||||
Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2"))
|
||||
})
|
||||
|
||||
It("ReadAll returns only own shares", func() {
|
||||
res, err := nonAdminRest.ReadAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
shares := res.(model.Shares)
|
||||
ids := make([]string, len(shares))
|
||||
for i, s := range shares {
|
||||
ids[i] = s.ID
|
||||
}
|
||||
Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2"))
|
||||
})
|
||||
|
||||
It("Get returns own share", func() {
|
||||
s, err := nonAdminRepo.Get("share-owner-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.ID).To(Equal("share-owner-1"))
|
||||
})
|
||||
|
||||
It("Get returns ErrNotFound for another user's share", func() {
|
||||
_, err := nonAdminRepo.Get("share-other-1")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("Read returns ErrNotFound for another user's share", func() {
|
||||
_, err := nonAdminRest.Read("share-other-1")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("Exists returns true for own share", func() {
|
||||
exists, err := nonAdminRepo.Exists("share-owner-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exists).To(BeTrue())
|
||||
})
|
||||
|
||||
It("Exists returns false for another user's share", func() {
|
||||
exists, err := nonAdminRepo.Exists("share-other-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exists).To(BeFalse())
|
||||
})
|
||||
|
||||
It("CountAll counts only own shares", func() {
|
||||
count, err := nonAdminRepo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeNumerically("==", 2))
|
||||
})
|
||||
|
||||
It("Count (rest) counts only own shares", func() {
|
||||
count, err := nonAdminRest.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeNumerically("==", 2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("admin user", func() {
|
||||
It("GetAll returns all shares", func() {
|
||||
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
||||
adminRepo := NewShareRepository(adminCtx, GetDBXBuilder())
|
||||
shares, err := adminRepo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ids := make([]string, len(shares))
|
||||
for i, s := range shares {
|
||||
ids[i] = s.ID
|
||||
}
|
||||
Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2", "share-other-1"))
|
||||
})
|
||||
|
||||
It("CountAll counts all shares", func() {
|
||||
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
||||
adminRepo := NewShareRepository(adminCtx, GetDBXBuilder())
|
||||
count, err := adminRepo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeNumerically("==", 3))
|
||||
})
|
||||
})
|
||||
|
||||
Context("headless context (public share route)", func() {
|
||||
It("GetAll returns all shares", func() {
|
||||
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
shares, err := headlessRepo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(shares).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("Get returns another user's share", func() {
|
||||
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
s, err := headlessRepo.Get("share-other-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.ID).To(Equal("share-other-1"))
|
||||
})
|
||||
|
||||
It("Exists returns true for any share", func() {
|
||||
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
|
||||
exists, err := headlessRepo.Exists("share-other-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exists).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -57,6 +58,33 @@ func loggedUser(ctx context.Context) *model.User {
|
||||
}
|
||||
}
|
||||
|
||||
// ownerFilter returns the predicate restricting access to rows owned by the logged-in user, for
|
||||
// tables with a user_id column. It returns nil for admins and for headless/system contexts (invalid
|
||||
// user), meaning "no ownership restriction". Callers should skip the WHERE clause when it is nil.
|
||||
//
|
||||
// The predicate uses an unqualified user_id, so it only works on queries where that column is
|
||||
// unambiguous (no join introducing a second user_id).
|
||||
func (r sqlRepository) ownerFilter() Sqlizer {
|
||||
if usr := loggedUser(r.ctx); !usr.IsAdmin && usr.ID != invalidUserId {
|
||||
return Eq{"user_id": usr.ID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addRestriction combines an optional caller predicate with the ownership filter, producing the
|
||||
// WHERE clause for owner-scoped reads. For admins and headless contexts ownerFilter() is nil and
|
||||
// only the caller's predicate (if any) remains.
|
||||
func (r sqlRepository) addRestriction(sql ...Sqlizer) Sqlizer {
|
||||
s := And{}
|
||||
if len(sql) > 0 {
|
||||
s = append(s, sql[0])
|
||||
}
|
||||
if owner := r.ownerFilter(); owner != nil {
|
||||
s = append(s, owner)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
|
||||
if r.tableName == "" {
|
||||
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
|
||||
@ -186,15 +214,6 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti
|
||||
return sq
|
||||
}
|
||||
|
||||
func (r *sqlRepository) withTableName(filter filterFunc) filterFunc {
|
||||
return func(field string, value any) Sqlizer {
|
||||
if r.tableName != "" {
|
||||
field = r.tableName + "." + field
|
||||
}
|
||||
return filter(field, value)
|
||||
}
|
||||
}
|
||||
|
||||
// libraryIdFilter is a filter function to be added to resources that have a library_id column.
|
||||
func libraryIdFilter(_ string, value any) Sqlizer {
|
||||
return Eq{"library_id": value}
|
||||
@ -382,6 +401,65 @@ func (r sqlRepository) exists(cond Sqlizer) (bool, error) {
|
||||
return res.Exist > 0, err
|
||||
}
|
||||
|
||||
// updateOwned performs an atomic, ownership-restricted update of the row identified by id, for
|
||||
// repositories whose table has a user_id column. Non-admins can only update rows they own: the
|
||||
// ownership predicate is part of the UPDATE's WHERE clause, so a row owned by another user simply
|
||||
// does not match and no write happens. Ownership itself is immutable here: user_id is never written,
|
||||
// so no caller (admin included) can reassign a row to a different owner via an update. Unlike put,
|
||||
// it never falls through to an INSERT, so a non-matching id never creates a row.
|
||||
//
|
||||
// When the update matches no row it classifies the failure: if the row exists but is owned by
|
||||
// another user it returns rest.ErrPermissionDenied, otherwise rest.ErrNotFound. The write itself is
|
||||
// still atomic; the extra lookup happens only on the failure path (count == 0), where no write
|
||||
// occurred, so there is no TOCTOU on the update.
|
||||
func (r sqlRepository) updateOwned(id string, m any, colsToUpdate ...string) error {
|
||||
values, err := toSQLArgs(m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error preparing values to write to DB: %w", err)
|
||||
}
|
||||
updateValues := filterUpdateValues(values, id, colsToUpdate...)
|
||||
delete(updateValues, "user_id") // ownership is immutable on update
|
||||
update := Update(r.tableName).Where(r.addRestriction(Eq{"id": id})).SetMap(updateValues)
|
||||
count, err := r.executeSQL(update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return r.classifyOwnedWriteMiss(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteOwned performs an atomic, ownership-restricted delete of the row identified by id, for
|
||||
// repositories whose table has a user_id column. Non-admins can only delete rows they own: the
|
||||
// ownership predicate is part of the DELETE's WHERE clause, so a row owned by another user simply
|
||||
// does not match and is left untouched. The failure path mirrors updateOwned (see
|
||||
// classifyOwnedWriteMiss), so there is no TOCTOU on the delete.
|
||||
func (r sqlRepository) deleteOwned(id string) error {
|
||||
count, err := r.executeSQL(Delete(r.tableName).Where(r.addRestriction(Eq{"id": id})))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return r.classifyOwnedWriteMiss(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyOwnedWriteMiss explains why an ownership-filtered write (updateOwned/deleteOwned) matched
|
||||
// no row: rest.ErrPermissionDenied if the row exists but is owned by another user, otherwise
|
||||
// rest.ErrNotFound. It runs only on the failure path (count == 0), where no write occurred.
|
||||
func (r sqlRepository) classifyOwnedWriteMiss(id string) error {
|
||||
exists, err := r.exists(Eq{"id": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
|
||||
func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) {
|
||||
countQuery = countQuery.
|
||||
RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count").
|
||||
@ -408,6 +486,30 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate
|
||||
return r.put(res.ID, m, colsToUpdate...)
|
||||
}
|
||||
|
||||
// filterUpdateValues selects, from a marshaled column map, the values to write in an UPDATE on the
|
||||
// row identified by id: only the requested colsToUpdate (or all columns when none are specified),
|
||||
// dropping columns that must never be overwritten on update (created_at, birth_time).
|
||||
func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any {
|
||||
updateValues := map[string]any{}
|
||||
|
||||
// This is a map of the columns that need to be updated, if specified
|
||||
c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) {
|
||||
return toSnakeCase(s), struct{}{}
|
||||
})
|
||||
for k, v := range values {
|
||||
if _, found := c2upd[k]; len(c2upd) == 0 || found {
|
||||
updateValues[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
updateValues["id"] = id
|
||||
delete(updateValues, "created_at")
|
||||
// To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now
|
||||
// TODO move to mediafile_repository when each repo has its own upsert method
|
||||
delete(updateValues, "birth_time")
|
||||
return updateValues
|
||||
}
|
||||
|
||||
func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) {
|
||||
values, err := toSQLArgs(m)
|
||||
if err != nil {
|
||||
@ -415,24 +517,7 @@ func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId stri
|
||||
}
|
||||
// If there's an ID, try to update first
|
||||
if id != "" {
|
||||
updateValues := map[string]any{}
|
||||
|
||||
// This is a map of the columns that need to be updated, if specified
|
||||
c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) {
|
||||
return toSnakeCase(s), struct{}{}
|
||||
})
|
||||
for k, v := range values {
|
||||
if _, found := c2upd[k]; len(c2upd) == 0 || found {
|
||||
updateValues[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
updateValues["id"] = id
|
||||
delete(updateValues, "created_at")
|
||||
// To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now
|
||||
// TODO move to mediafile_repository when each repo has its own upsert method
|
||||
delete(updateValues, "birth_time")
|
||||
update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues)
|
||||
update := Update(r.tableName).Where(Eq{"id": id}).SetMap(filterUpdateValues(values, id, colsToUpdate...))
|
||||
count, err := r.executeSQL(update)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@ -46,7 +46,7 @@ func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.Query
|
||||
continue
|
||||
}
|
||||
// Default to a "starts with" filter
|
||||
filters = append(filters, startsWithFilter(f, v))
|
||||
filters = append(filters, Like{f: fmt.Sprintf("%s%%", v)})
|
||||
}
|
||||
return filters
|
||||
}
|
||||
@ -91,8 +91,10 @@ func eqFilter(field string, value any) Sqlizer {
|
||||
return Eq{field: value}
|
||||
}
|
||||
|
||||
func startsWithFilter(field string, value any) Sqlizer {
|
||||
return Like{field: fmt.Sprintf("%s%%", value)}
|
||||
func startsWithFilter(field string) func(string, any) Sqlizer {
|
||||
return func(_ string, value any) Sqlizer {
|
||||
return Like{field: fmt.Sprintf("%s%%", value)}
|
||||
}
|
||||
}
|
||||
|
||||
func containsFilter(field string) func(string, any) Sqlizer {
|
||||
|
||||
@ -66,7 +66,7 @@ func normalizeForFTS(values ...string) string {
|
||||
result = append(result, variant)
|
||||
}
|
||||
for _, v := range values {
|
||||
for _, word := range strings.Fields(v) {
|
||||
for word := range strings.FieldsSeq(v) {
|
||||
transliterated := sanitize.Accents(word)
|
||||
// Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne.
|
||||
add(word, fts5PunctStrip.ReplaceAllString(transliterated, ""))
|
||||
@ -279,9 +279,9 @@ type ftsSearch struct {
|
||||
}
|
||||
|
||||
// ToSql returns a single-query fallback for the REST filter path (no two-phase split).
|
||||
func (s *ftsSearch) ToSql() (string, []interface{}, error) {
|
||||
func (s *ftsSearch) ToSql() (string, []any, error) {
|
||||
sql := s.tableName + ".rowid IN (SELECT rowid FROM " + s.ftsTable + " WHERE " + s.ftsTable + " MATCH ?)"
|
||||
return sql, []interface{}{s.matchExpr}, nil
|
||||
return sql, []any{s.matchExpr}, nil
|
||||
}
|
||||
|
||||
// execute runs a two-phase FTS5 search:
|
||||
@ -373,8 +373,8 @@ func ftsQueryDegraded(original, ftsQuery string) bool {
|
||||
// Check if all effective FTS tokens are very short (≤2 chars).
|
||||
// Short tokens with prefix matching are too broad when special chars were stripped.
|
||||
// For quoted phrases, extract the content and check the tokens inside.
|
||||
tokens := strings.Fields(ftsQuery)
|
||||
for _, t := range tokens {
|
||||
tokens := strings.FieldsSeq(ftsQuery)
|
||||
for t := range tokens {
|
||||
t = strings.TrimSuffix(t, "*")
|
||||
// Skip internal phrase placeholders
|
||||
if strings.HasPrefix(t, "\x00") {
|
||||
@ -390,7 +390,7 @@ func ftsQueryDegraded(original, ftsQuery string) bool {
|
||||
// Extract content between quotes
|
||||
inner := strings.Trim(t, `"`)
|
||||
innerAlpha := fts5PunctStrip.ReplaceAllString(inner, " ")
|
||||
for _, it := range strings.Fields(innerAlpha) {
|
||||
for it := range strings.FieldsSeq(innerAlpha) {
|
||||
if len(it) > 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ type likeSearch struct {
|
||||
filter Sqlizer
|
||||
}
|
||||
|
||||
func (s *likeSearch) ToSql() (string, []interface{}, error) {
|
||||
func (s *likeSearch) ToSql() (string, []any, error) {
|
||||
return s.filter.ToSql()
|
||||
}
|
||||
|
||||
|
||||
@ -53,14 +53,29 @@ func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, erro
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Read(id string) (any, error) {
|
||||
return r.Get(id)
|
||||
res, err := r.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !loggedUser(r.ctx).IsAdmin {
|
||||
res.Command = ""
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
|
||||
res := model.Transcodings{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !loggedUser(r.ctx).IsAdmin {
|
||||
for i := range res {
|
||||
res[i].Command = ""
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) EntityName() string {
|
||||
|
||||
@ -64,9 +64,69 @@ var _ = Describe("TranscodingRepository", func() {
|
||||
_, err = adminRepo.Get("to-delete")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("reads the Command field via the REST Read method", func() {
|
||||
tr := &model.Transcoding{ID: "adminread", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
res, err := adminRepo.(*transcodingRepository).Read("adminread")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.(*model.Transcoding).Command).To(Equal("ffmpeg -secret"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Regular User", func() {
|
||||
It("reads a transcoding but with the Command field redacted", func() {
|
||||
tr := &model.Transcoding{ID: "readreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
res, err := repo.(*transcodingRepository).Read("readreg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
t := res.(*model.Transcoding)
|
||||
Expect(t.Name).To(Equal("temp"))
|
||||
Expect(t.TargetFormat).To(Equal("test_format"))
|
||||
Expect(t.Command).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("lists transcodings but with the Command field redacted", func() {
|
||||
tr := &model.Transcoding{ID: "listreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
res, err := repo.(*transcodingRepository).ReadAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
list := res.(model.Transcodings)
|
||||
Expect(list).ToNot(BeEmpty())
|
||||
for _, t := range list {
|
||||
Expect(t.Command).To(BeEmpty())
|
||||
}
|
||||
})
|
||||
|
||||
It("counts transcodings", func() {
|
||||
count, err := repo.(*transcodingRepository).Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeNumerically(">=", 0))
|
||||
})
|
||||
|
||||
It("can still resolve a transcoding for streaming via Get (Command not redacted)", func() {
|
||||
tr := &model.Transcoding{ID: "streamreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
res, err := repo.Get("streamreg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.ID).To(Equal("streamreg"))
|
||||
Expect(res.Command).To(Equal("ffmpeg -secret"))
|
||||
})
|
||||
|
||||
It("can still resolve a transcoding for streaming via FindByFormat (Command not redacted)", func() {
|
||||
tr := &model.Transcoding{ID: "fmtreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
res, err := repo.FindByFormat("test_format")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.ID).To(Equal("fmtreg"))
|
||||
Expect(res.Command).To(Equal("ffmpeg -secret"))
|
||||
})
|
||||
|
||||
It("fails to create", func() {
|
||||
err := repo.Put(&model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"})
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
|
||||
@ -59,7 +59,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
|
||||
r.registerModel(&model.User{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"password": invalidFilter(ctx),
|
||||
"name": r.withTableName(startsWithFilter),
|
||||
"name": startsWithFilter(r.tableName + ".name"),
|
||||
})
|
||||
once.Do(func() {
|
||||
_ = r.initPasswordEncryptionKey()
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -207,6 +208,52 @@ var _ = Describe("UserRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReadAll name filter", func() {
|
||||
var adminRepo model.ResourceRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin-id", UserName: "admin", IsAdmin: true})
|
||||
adminRepo = NewUserRepository(adminCtx, GetDBXBuilder()).(model.ResourceRepository)
|
||||
|
||||
for _, u := range []model.User{
|
||||
{ID: "filter-alice", UserName: "alice_filter", Name: "Alice Filter", NewPassword: "x"},
|
||||
{ID: "filter-bob", UserName: "bob_filter", Name: "Bob Filter", NewPassword: "x"},
|
||||
} {
|
||||
Expect(adminRepo.(model.UserRepository).Put(&u)).To(Succeed())
|
||||
}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
ur := adminRepo.(model.UserRepository)
|
||||
_ = ur.Delete("filter-alice")
|
||||
_ = ur.Delete("filter-bob")
|
||||
})
|
||||
|
||||
It("matches users whose name starts with the given prefix", func() {
|
||||
res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Alice"}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
users := res.(model.Users)
|
||||
|
||||
var names []string
|
||||
for _, u := range users {
|
||||
names = append(names, u.Name)
|
||||
}
|
||||
Expect(names).To(ContainElement("Alice Filter"))
|
||||
Expect(names).ToNot(ContainElement("Bob Filter"))
|
||||
})
|
||||
|
||||
It("does not match names by mid-string substring (startsWith, not contains)", func() {
|
||||
res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Filter"}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
users := res.(model.Users)
|
||||
|
||||
for _, u := range users {
|
||||
Expect(u.ID).ToNot(Or(Equal("filter-alice"), Equal("filter-bob")),
|
||||
"a mid-string substring should not match a startsWith filter")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("validateUsernameUnique", func() {
|
||||
var repo *tests.MockedUserRepo
|
||||
var existingUser *model.User
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@ -540,9 +541,7 @@ func (s *taskQueueServiceImpl) cleanupLoop() {
|
||||
func (s *taskQueueServiceImpl) runCleanup() {
|
||||
s.mu.Lock()
|
||||
queues := make(map[string]*queueState, len(s.queues))
|
||||
for k, v := range s.queues {
|
||||
queues[k] = v
|
||||
}
|
||||
maps.Copy(queues, s.queues)
|
||||
s.mu.Unlock()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
@ -367,8 +367,8 @@ var _ = Describe("TaskQueueService", func() {
|
||||
|
||||
// Enqueue several more tasks — they stay pending since the worker is busy
|
||||
var pendingIDs []string
|
||||
for i := 0; i < 3; i++ {
|
||||
taskID, err := service.Enqueue(ctx, "clear-test", []byte(fmt.Sprintf("task-%d", i)))
|
||||
for i := range 3 {
|
||||
taskID, err := service.Enqueue(ctx, "clear-test", fmt.Appendf(nil, "task-%d", i))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
pendingIDs = append(pendingIDs, taskID)
|
||||
}
|
||||
@ -674,8 +674,8 @@ var _ = Describe("TaskQueueService", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Enqueue 5 tasks
|
||||
for i := 0; i < 5; i++ {
|
||||
_, err := service.Enqueue(ctx, "delay-concurrent", []byte(fmt.Sprintf("task-%d", i)))
|
||||
for i := range 5 {
|
||||
_, err := service.Enqueue(ctx, "delay-concurrent", fmt.Appendf(nil, "task-%d", i))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
@ -1112,7 +1112,7 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() {
|
||||
// the second will be dequeued but block on the rate limiter (status=running),
|
||||
// the rest will stay pending.
|
||||
var taskIDs []string
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
output, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "enqueue",
|
||||
QueueName: "test-cancel",
|
||||
@ -1186,11 +1186,11 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Enqueue several tasks
|
||||
for i := 0; i < 4; i++ {
|
||||
for i := range 4 {
|
||||
_, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "enqueue",
|
||||
QueueName: "test-clear",
|
||||
Payload: []byte(fmt.Sprintf("task-%d", i)),
|
||||
Payload: fmt.Appendf(nil, "task-%d", i),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
|
||||
@ -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"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -38,7 +38,9 @@
|
||||
"missing": "Fehlend",
|
||||
"libraryName": "Bibliothek",
|
||||
"composer": "Komponist",
|
||||
"disc": "Disc %{discNumber}"
|
||||
"disc": "Disc %{discNumber}",
|
||||
"albumGain": "Album Gain",
|
||||
"trackGain": "Titel Gain"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Später abspielen",
|
||||
|
||||
@ -35,6 +35,8 @@
|
||||
"rawTags": "Etiquetas sin procesar",
|
||||
"bitDepth": "Profundidad de bits",
|
||||
"sampleRate": "Frecuencia de muestreo",
|
||||
"albumGain": "Ganancia del álbum",
|
||||
"trackGain": "Ganancia de pista",
|
||||
"missing": "Faltante",
|
||||
"libraryName": "Biblioteca",
|
||||
"composer": "Compositor",
|
||||
@ -693,7 +695,7 @@
|
||||
"quickScan": "Escaneo rápido",
|
||||
"fullScan": "Escaneo completo",
|
||||
"serverUptime": "Uptime del servidor",
|
||||
"serverDown": "OFFLINE",
|
||||
"serverDown": "DESCONECTADO",
|
||||
"scanType": "Tipo",
|
||||
"status": "Error de escaneo",
|
||||
"elapsedTime": "Tiempo transcurrido",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"languageName": "Euskara",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Abestia |||| Abesti",
|
||||
"name": "Abestia |||| Abestiak",
|
||||
"fields": {
|
||||
"albumArtist": "Albumaren artista",
|
||||
"duration": "Iraupena",
|
||||
@ -22,6 +22,8 @@
|
||||
"bitRate": "Bit-tasa",
|
||||
"bitDepth": "Bit-sakonera",
|
||||
"sampleRate": "Lagin-tasa",
|
||||
"albumGain": "Album-irabazia",
|
||||
"trackGain": "Pista-irabazia",
|
||||
"channels": "Kanalak",
|
||||
"disc": "%{discNumber}. diskoa",
|
||||
"discSubtitle": "Diskoaren azpititulua",
|
||||
@ -53,7 +55,7 @@
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Albuma |||| Album",
|
||||
"name": "Albuma |||| Albumak",
|
||||
"fields": {
|
||||
"albumArtist": "Albumaren artista",
|
||||
"artist": "Artista",
|
||||
@ -104,7 +106,7 @@
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artista |||| Artista",
|
||||
"name": "Artista |||| Artistak",
|
||||
"fields": {
|
||||
"name": "Izena",
|
||||
"albumCount": "Album kopurua",
|
||||
@ -117,7 +119,7 @@
|
||||
"missing": "Ez da aurkitu"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albumeko egilea |||| Albumeko artistak",
|
||||
"albumartist": "Albumeko artista |||| Albumeko artistak",
|
||||
"artist": "Artista |||| Artistak",
|
||||
"composer": "Konpositorea |||| Konpositoreak",
|
||||
"conductor": "Orkestra zuzendaria |||| Orkestra zuzendariak",
|
||||
@ -335,7 +337,7 @@
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Plugina |||| Plugin",
|
||||
"name": "Plugina |||| Pluginak",
|
||||
"fields": {
|
||||
"id": "IDa",
|
||||
"name": "Izena",
|
||||
@ -492,7 +494,7 @@
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Jaregin edo hautatu igo nahi dituzun fitxategiak.",
|
||||
"upload_single": "AJaregin edo hautatu igo nahi duzun fitxategia."
|
||||
"upload_single": "Jaregin edo hautatu igo nahi duzun fitxategia."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Jaregin edo hautatu igo nahi dituzun irudiak.",
|
||||
@ -537,9 +539,9 @@
|
||||
"skip_nav": "Joan edukira"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Elementu bat eguneratu da |||| %{smart_count} elementu eguneratu dira",
|
||||
"updated": "Elementua eguneratu da |||| %{smart_count} elementu eguneratu dira",
|
||||
"created": "Elementua sortu da",
|
||||
"deleted": "Elementu bat ezabatu da |||| %{smart_count} elementu ezabatu dira.",
|
||||
"deleted": "Elementua ezabatu da |||| %{smart_count} elementu ezabatu dira.",
|
||||
"bad_item": "Elementu okerra",
|
||||
"item_doesnt_exist": "Elementua ez dago",
|
||||
"http_error": "Errorea zerbitzariarekin komunikatzerakoan",
|
||||
@ -588,7 +590,7 @@
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
|
||||
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
|
||||
"openIn": {
|
||||
"lastfm": "Ikusi Last.fm-n",
|
||||
"lastfm": "Ikusi Last.fm-en",
|
||||
"musicbrainz": "Ikusi MusicBrainz-en"
|
||||
},
|
||||
"lastfmLink": "Irakurri gehiago…",
|
||||
|
||||
@ -38,7 +38,9 @@
|
||||
"missing": "Puuttuva",
|
||||
"libraryName": "Kirjasto",
|
||||
"composer": "Säveltäjä",
|
||||
"disc": "Levy %{discNumber}"
|
||||
"disc": "Levy %{discNumber}",
|
||||
"albumGain": "Albumin äänenvoimakkuus",
|
||||
"trackGain": "Kappaleen äänenvoimakkuus"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lisää jonoon",
|
||||
|
||||
@ -38,7 +38,9 @@
|
||||
"missing": "Falta",
|
||||
"libraryName": "Biblioteca",
|
||||
"composer": "Composición",
|
||||
"disc": "Disco %{discNumber}"
|
||||
"disc": "Disco %{discNumber}",
|
||||
"albumGain": "Gañancia de Album",
|
||||
"trackGain": "Gañancia de Canción"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ao final da cola",
|
||||
|
||||
@ -38,7 +38,9 @@
|
||||
"missing": "Ontbrekend",
|
||||
"libraryName": "Bibliotheek",
|
||||
"composer": "Componist",
|
||||
"disc": "Schijf %{discNumber}"
|
||||
"disc": "Schijf %{discNumber}",
|
||||
"albumGain": "Album gain",
|
||||
"trackGain": "Nummer gain"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Voeg toe aan wachtrij",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"languageName": "Slovenčina",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Skladba |||| Skladieb",
|
||||
"name": "Skladba |||| Skladby",
|
||||
"fields": {
|
||||
"albumArtist": "Interpret albumu",
|
||||
"duration": "Dĺžka",
|
||||
@ -10,20 +10,14 @@
|
||||
"playCount": "Počet prehratí",
|
||||
"title": "Názov",
|
||||
"artist": "Interpret",
|
||||
"composer": "Skladateľ",
|
||||
"album": "Album",
|
||||
"path": "Cesta k súboru",
|
||||
"libraryName": "Knižnica",
|
||||
"genre": "Žáner",
|
||||
"compilation": "Kompilácia",
|
||||
"year": "Rok",
|
||||
"size": "Veľkosť súboru",
|
||||
"updatedAt": "Nahrané",
|
||||
"bitRate": "Prenosová rýchlosť",
|
||||
"bitDepth": "Bitová hĺbka",
|
||||
"sampleRate": "Vzorkovacia frekvencia",
|
||||
"channels": "Kanály",
|
||||
"disc": "Disk %{discNumber}",
|
||||
"discSubtitle": "Podtitul disku",
|
||||
"starred": "Obľúbené",
|
||||
"comment": "Komentár",
|
||||
@ -31,6 +25,7 @@
|
||||
"quality": "Kvalita",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Naposledy prehraná skladba",
|
||||
"channels": "Kanály",
|
||||
"createdAt": "Pridané",
|
||||
"grouping": "Zoskupovanie",
|
||||
"mood": "Nálada",
|
||||
@ -38,17 +33,24 @@
|
||||
"tags": "Ďalšie značky",
|
||||
"mappedTags": "Mapované značky",
|
||||
"rawTags": "Nespracované značky",
|
||||
"missing": "Chýbajúce"
|
||||
"bitDepth": "Bitová hĺbka",
|
||||
"sampleRate": "Vzorkovacia frekvencia",
|
||||
"missing": "Chýbajúce",
|
||||
"libraryName": "Knižnica",
|
||||
"composer": "Skladateľ",
|
||||
"disc": "Disk %{discNumber}",
|
||||
"albumGain": "Zosilnenie albumu",
|
||||
"trackGain": "Zosilnenie stopy"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Prehrať neskôr",
|
||||
"playNow": "Prehrať teraz",
|
||||
"addToPlaylist": "Pridať do zoznamu skladieb",
|
||||
"showInPlaylist": "Zobraziť v zozname skladieb",
|
||||
"shuffleAll": "Zamiešať všetko",
|
||||
"download": "Stiahnuť",
|
||||
"playNext": "Prehrať ako ďalšie",
|
||||
"info": "Získať informácie",
|
||||
"showInPlaylist": "Zobraziť v zozname skladieb",
|
||||
"instantMix": "Okamžitý mix"
|
||||
}
|
||||
},
|
||||
@ -60,38 +62,38 @@
|
||||
"duration": "Dĺžka",
|
||||
"songCount": "Skladby",
|
||||
"playCount": "Počet prehratí",
|
||||
"size": "Veľkosť",
|
||||
"name": "Názov",
|
||||
"libraryName": "Knižnica",
|
||||
"genre": "Žáner",
|
||||
"compilation": "Kompilácia",
|
||||
"year": "Rok",
|
||||
"date": "Dátum záznamu",
|
||||
"originalDate": "Pôvodné",
|
||||
"releaseDate": "Vydané",
|
||||
"releases": "Vydanie |||| Vydania",
|
||||
"released": "Vydané",
|
||||
"updatedAt": "Aktualizované",
|
||||
"comment": "Komentár",
|
||||
"rating": "Hodnotenie",
|
||||
"createdAt": "Pridané",
|
||||
"size": "Veľkosť",
|
||||
"originalDate": "Pôvodné",
|
||||
"releaseDate": "Vydané",
|
||||
"releases": "Vydanie |||| Vydania",
|
||||
"released": "Vydané",
|
||||
"recordLabel": "Štítok",
|
||||
"catalogNum": "Katalógové číslo",
|
||||
"releaseType": "Typ vydania",
|
||||
"grouping": "Zoskupovanie",
|
||||
"media": "Médiá",
|
||||
"mood": "Nálada",
|
||||
"missing": "Chýbajúce"
|
||||
"date": "Dátum záznamu",
|
||||
"missing": "Chýbajúce",
|
||||
"libraryName": "Knižnica"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Prehrať",
|
||||
"playNext": "Prehrať ako ďalšie",
|
||||
"addToQueue": "Prehrať neskôr",
|
||||
"share": "Zdieľať",
|
||||
"shuffle": "Zamiešať",
|
||||
"addToPlaylist": "Pridať do zoznamu skladieb",
|
||||
"download": "Stiahnuť",
|
||||
"info": "Získať informácie"
|
||||
"info": "Získať informácie",
|
||||
"share": "Zdieľať"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Všetko",
|
||||
@ -109,10 +111,10 @@
|
||||
"name": "Názov",
|
||||
"albumCount": "Počet albumov",
|
||||
"songCount": "Počet skladieb",
|
||||
"size": "Veľkosť",
|
||||
"playCount": "Prehrania",
|
||||
"rating": "Hodnotenie",
|
||||
"genre": "Žáner",
|
||||
"size": "Veľkosť",
|
||||
"role": "Rola",
|
||||
"missing": "Chýbajúci"
|
||||
},
|
||||
@ -133,9 +135,9 @@
|
||||
"maincredit": "Interpret albumu alebo interpret |||| Interpreti albumov alebo interpreti"
|
||||
},
|
||||
"actions": {
|
||||
"topSongs": "Najpopulárnejšie skladby",
|
||||
"shuffle": "Zamiešať",
|
||||
"radio": "Rádio"
|
||||
"radio": "Rádio",
|
||||
"topSongs": "Najpopulárnejšie skladby"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@ -144,7 +146,6 @@
|
||||
"userName": "Používateľské meno",
|
||||
"isAdmin": "Správca",
|
||||
"lastLoginAt": "Naposledy prihlásený",
|
||||
"lastAccessAt": "Posledný Prístup",
|
||||
"updatedAt": "Upravený",
|
||||
"name": "Meno",
|
||||
"password": "Heslo",
|
||||
@ -153,6 +154,7 @@
|
||||
"currentPassword": "Súčastné heslo",
|
||||
"newPassword": "Nové heslo",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Posledný Prístup",
|
||||
"libraries": "Knižnice"
|
||||
},
|
||||
"helperTexts": {
|
||||
@ -164,14 +166,14 @@
|
||||
"updated": "Používateľ upravený",
|
||||
"deleted": "Používateľ odstránený"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Vložte svoj používateľský ListenBrainz token.",
|
||||
"clickHereForToken": "Kliknite sem pre získanie svojho tokenu",
|
||||
"selectAllLibraries": "Vybrať všetky knižnice",
|
||||
"adminAutoLibraries": "Administrátori majú automaticky prístup ku všetkým knižniciam"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@ -214,9 +216,9 @@
|
||||
"selectPlaylist": "Vybrať zoznam skladieb:",
|
||||
"addNewPlaylist": "Vytvoriť \"%{name}\"",
|
||||
"export": "Export",
|
||||
"saveQueue": "Uložiť rad do zoznamu skladieb",
|
||||
"makePublic": "Zverejniť",
|
||||
"makePrivate": "Nastaviť ako súkromné",
|
||||
"saveQueue": "Uložiť rad do zoznamu skladieb",
|
||||
"searchOrCreate": "Vyhľadajte zoznamy skladieb alebo napíšte pre vytvorenie nového...",
|
||||
"pressEnterToCreate": "Stlačte Enter pre vytvorenie nového zoznamu skladieb",
|
||||
"removeFromSelection": "Odstrániť z výberu"
|
||||
@ -247,7 +249,6 @@
|
||||
"username": "Zdieľané",
|
||||
"url": "URL",
|
||||
"description": "Popis",
|
||||
"downloadable": "Povoliť sťahovanie?",
|
||||
"contents": "Obsah",
|
||||
"expiresAt": "Vyprší",
|
||||
"lastVisitedAt": "Naposledy navštívené",
|
||||
@ -255,19 +256,17 @@
|
||||
"format": "Formát",
|
||||
"maxBitRate": "Max. Bit Rate",
|
||||
"updatedAt": "Nahrané",
|
||||
"createdAt": "Vytvorené"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
"createdAt": "Vytvorené",
|
||||
"downloadable": "Povoliť sťahovanie?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Chýbajúci súbor |||| Chýbajúce súbory",
|
||||
"empty": "Žiadne chýbajúce súbory",
|
||||
"fields": {
|
||||
"path": "Cesta",
|
||||
"size": "Veľkosť",
|
||||
"libraryName": "Knižnica",
|
||||
"updatedAt": "Zmizol dňa"
|
||||
"updatedAt": "Zmizol dňa",
|
||||
"libraryName": "Knižnica"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Odstrániť",
|
||||
@ -275,7 +274,8 @@
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Chýbajúce súbory odstránené"
|
||||
}
|
||||
},
|
||||
"empty": "Žiadne chýbajúce súbory"
|
||||
},
|
||||
"library": {
|
||||
"name": "Knižnica |||| Knižnice",
|
||||
@ -305,20 +305,20 @@
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Skenovať knižnicu",
|
||||
"quickScan": "Rýchly sken",
|
||||
"fullScan": "Úplný sken",
|
||||
"manageUsers": "Spravovať prístup používateľov",
|
||||
"viewDetails": "Zobraziť detaily"
|
||||
"viewDetails": "Zobraziť detaily",
|
||||
"quickScan": "Rýchly sken",
|
||||
"fullScan": "Úplný sken"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Knižnica úspešne vytvorená",
|
||||
"updated": "Knižnica úspešne aktualizovaná",
|
||||
"deleted": "Knižnica úspešne odstránená",
|
||||
"scanStarted": "Skenovanie knižnice spustené",
|
||||
"scanCompleted": "Skenovanie knižnice dokončené",
|
||||
"quickScanStarted": "Rýchly sken spustený",
|
||||
"fullScanStarted": "Úplný sken spustený",
|
||||
"scanError": "Chyba pri spustení skenu. Skontrolujte logy",
|
||||
"scanCompleted": "Skenovanie knižnice dokončené"
|
||||
"scanError": "Chyba pri spustení skenu. Skontrolujte logy"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Názov knižnice je povinný",
|
||||
@ -391,8 +391,6 @@
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Nakonfigurujte plugin pomocou párov kľúč-hodnota. Nechajte prázdne, ak plugin nevyžaduje žiadnu konfiguráciu.",
|
||||
"configValidationError": "Overenie konfigurácie zlyhalo:",
|
||||
"schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.",
|
||||
"clickPermissions": "Kliknite na oprávnenie pre detaily",
|
||||
"noConfig": "Žiadna konfigurácia nastavená",
|
||||
"allUsersHelp": "Keď je povolené, plugin bude mať prístup ku všetkým používateľom, vrátane tých vytvorených v budúcnosti.",
|
||||
@ -402,8 +400,10 @@
|
||||
"allLibrariesHelp": "Keď je povolené, plugin bude mať prístup ku všetkým knižniciam, vrátane tých vytvorených v budúcnosti.",
|
||||
"noLibraries": "Žiadne knižnice nevybrané",
|
||||
"librariesRequired": "Tento plugin vyžaduje prístup k informáciám o knižniciach. Vyberte, ku ktorým knižniciam má plugin prístup, alebo povolte 'Povoliť všetky knižnice'.",
|
||||
"allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie.",
|
||||
"requiredHosts": "Požadovaní hostitelia"
|
||||
"requiredHosts": "Požadovaní hostitelia",
|
||||
"configValidationError": "Overenie konfigurácie zlyhalo:",
|
||||
"schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.",
|
||||
"allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "kľúč",
|
||||
@ -446,7 +446,6 @@
|
||||
"add": "Pridať",
|
||||
"back": "Ísť späť",
|
||||
"bulk_actions": "1 vybraná |||| %{smart_count} vybraných",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "Zrušiť",
|
||||
"clear_input_value": "Vymazať hodnotu",
|
||||
"clone": "Klonovať",
|
||||
@ -470,6 +469,7 @@
|
||||
"close_menu": "Zavrieť ponuku",
|
||||
"unselect": "Zrušiť výber",
|
||||
"skip": "Preskočiť",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Zdieľať",
|
||||
"download": "Stiahnuť"
|
||||
},
|
||||
@ -557,58 +557,52 @@
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"uploadCover": "Nahrať obrázok obalu",
|
||||
"removeCover": "Odstrániť obrázok obalu",
|
||||
"coverUploaded": "Obrázok obalu albumu aktualizovaný",
|
||||
"coverRemoved": "Obrázok obalu albumu odstránený",
|
||||
"coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu",
|
||||
"coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu",
|
||||
"note": "POZNÁMKA",
|
||||
"transcodingDisabled": "Zmena nastavení transkódovania je vo webovom prostredí vypnutá z bezpečnostných dôvodov. Ak chcete zmeniť (upraviť alebo pridať) možnosti transkódovania, reštartujte server s možnosťou %{config}.",
|
||||
"transcodingEnabled": "Navidrome práve beží s možnosťou %{config}, ktorá umožňuje spúšťanie systémových príkazov z nastavení transkódovania pomocou webového rozhrania. Odporúčame ju vypnúť z bezpečnostných dôvodov a používať ju iba pri úprave nastavení transkódovania.",
|
||||
"songsAddedToPlaylist": "1 skladba pridaná do zoznamu skladieb |||| %{smart_count} skladieb pridaných do zoznamu skladieb",
|
||||
"noSimilarSongsFound": "Nenašli sa žiadne podobné skladby",
|
||||
"startingInstantMix": "Načítava sa Instant Mix...",
|
||||
"noTopSongsFound": "Nenašli sa žiadne top skladby",
|
||||
"noPlaylistsAvailable": "Žiadne nie sú dostupné",
|
||||
"delete_user_title": "Odstrániť používateľa '%{name}'",
|
||||
"delete_user_content": "Ste si istí, že chcete odstrániť tohto používateľa a všetky jeho dáta (vrátane zoznamov skladieb a nastavení)?",
|
||||
"remove_missing_title": "Odstráňte chýbajúce súbory",
|
||||
"remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.",
|
||||
"remove_all_missing_title": "Odstráňte všetky chýbajúce súbory",
|
||||
"remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.",
|
||||
"notifications_blocked": "Zablokovali ste si oznámenia pre túto stránku v nastaveniach vášho prehliadača",
|
||||
"notifications_not_available": "Tento prehliadač nepodporuje oznámenia na ploche alebo nepristupujete k Navidrome cez https",
|
||||
"lastfmLinkSuccess": "Last.fm úspešne pripojené a scrobbling zapnutý",
|
||||
"lastfmLinkFailure": "Last.fm sa nepodarilo pripojiť",
|
||||
"lastfmUnlinkSuccess": "Last.fm odpojené a scrobbling vypnutý",
|
||||
"lastfmUnlinkFailure": "Last.fm sa nepodarilo odpojiť",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť",
|
||||
"openIn": {
|
||||
"lastfm": "Otvoriť na Last.fm",
|
||||
"musicbrainz": "Otvoriť na MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Čítať ďalej...",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť",
|
||||
"downloadOriginalFormat": "Stiahnuť v pôvodnom formáte",
|
||||
"shareOriginalFormat": "Zdieľať v pôvodnom formáte",
|
||||
"shareDialogTitle": "Zdieľať %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Zdieľať 1 %{resource} |||| Zdieľať %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter",
|
||||
"shareSuccess": "URL skopírovaná do schránky: %{url}",
|
||||
"shareFailure": "Chyba pri kopírovaní URL %{url} do schránky",
|
||||
"downloadDialogTitle": "Stiahnuť %{resource} '%{name}' (%{size})",
|
||||
"downloadOriginalFormat": "Stiahnuť v pôvodnom formáte"
|
||||
"shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Odstráňte chýbajúce súbory",
|
||||
"remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.",
|
||||
"remove_all_missing_title": "Odstráňte všetky chýbajúce súbory",
|
||||
"remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.",
|
||||
"noSimilarSongsFound": "Nenašli sa žiadne podobné skladby",
|
||||
"noTopSongsFound": "Nenašli sa žiadne top skladby",
|
||||
"startingInstantMix": "Načítava sa Instant Mix...",
|
||||
"uploadCover": "Nahrať obrázok obalu",
|
||||
"removeCover": "Odstrániť obrázok obalu",
|
||||
"coverUploaded": "Obrázok obalu albumu aktualizovaný",
|
||||
"coverRemoved": "Obrázok obalu albumu odstránený",
|
||||
"coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu",
|
||||
"coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Knižnica",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Všetky knižnice (%{count})",
|
||||
"multipleLibraries": "%{selected} z %{total} knižníc",
|
||||
"selectLibraries": "Vyberte knižnice",
|
||||
"none": "Žiadne"
|
||||
},
|
||||
"settings": "Nastavenia",
|
||||
"version": "Verzia",
|
||||
"theme": "Téma",
|
||||
@ -619,7 +613,6 @@
|
||||
"language": "Jazyk",
|
||||
"defaultView": "Predvolená stránka",
|
||||
"desktop_notifications": "Oznámenia na ploche",
|
||||
"lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný",
|
||||
"lastfmScrobbling": "Scrobblovať na Last.fm",
|
||||
"listenBrainzScrobbling": "Scrobblovať na ListenBrainz",
|
||||
"replaygain": "Mód ReplayGain",
|
||||
@ -628,13 +621,20 @@
|
||||
"none": "Vypnuté",
|
||||
"album": "Použiť Album Gain",
|
||||
"track": "Použiť Track Gain"
|
||||
}
|
||||
},
|
||||
"lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný"
|
||||
}
|
||||
},
|
||||
"albumList": "Albumy",
|
||||
"about": "O Navidrome",
|
||||
"playlists": "Zoznamy skladieb",
|
||||
"sharedPlaylists": "Zdieľané zoznamy skladieb",
|
||||
"about": "O Navidrome"
|
||||
"librarySelector": {
|
||||
"allLibraries": "Všetky knižnice (%{count})",
|
||||
"multipleLibraries": "%{selected} z %{total} knižníc",
|
||||
"selectLibraries": "Vyberte knižnice",
|
||||
"none": "Žiadne"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Rad",
|
||||
@ -682,11 +682,11 @@
|
||||
"currentValue": "Aktuálna hodnota",
|
||||
"configurationFile": "Konfiguračný súbor",
|
||||
"exportToml": "Exportovať konfiguráciu (TOML)",
|
||||
"downloadToml": "Stiahnuť konfiguráciu (TOML)",
|
||||
"exportSuccess": "Konfigurácia exportovaná do schránky vo formáte TOML",
|
||||
"exportFailed": "Nepodarilo sa skopírovať konfiguráciu",
|
||||
"devFlagsHeader": "Vývojové príznaky (môžu byť zmenené/odstránené)",
|
||||
"devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách"
|
||||
"devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách",
|
||||
"downloadToml": "Stiahnuť konfiguráciu (TOML)"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@ -694,17 +694,12 @@
|
||||
"totalScanned": "Naskenované priečinky",
|
||||
"quickScan": "Rýchly sken",
|
||||
"fullScan": "Úplný sken",
|
||||
"selectiveScan": "Selektívne",
|
||||
"serverUptime": "Doba od spustenia",
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Posledný Sken",
|
||||
"status": "Chyba skenovania",
|
||||
"elapsedTime": "Uplynutý čas"
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "Práve hrá",
|
||||
"empty": "Nič sa neprehráva",
|
||||
"minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami"
|
||||
"elapsedTime": "Uplynutý čas",
|
||||
"selectiveScan": "Selektívne"
|
||||
},
|
||||
"help": {
|
||||
"title": "Klávesové skratky Navidrome",
|
||||
@ -714,10 +709,15 @@
|
||||
"toggle_play": "Prehrať / Pozastaviť",
|
||||
"prev_song": "Predchádzajúca skladba",
|
||||
"next_song": "Nasledujúca skladba",
|
||||
"current_song": "Prejsť na aktuálnu skladbu",
|
||||
"vol_up": "Zvýšiť hlasitosť",
|
||||
"vol_down": "Znížiť hlasitosť",
|
||||
"toggle_love": "Pridať túto skladbu do obľúbených"
|
||||
"toggle_love": "Pridať túto skladbu do obľúbených",
|
||||
"current_song": "Prejsť na aktuálnu skladbu"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "Práve hrá",
|
||||
"empty": "Nič sa neprehráva",
|
||||
"minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami"
|
||||
}
|
||||
}
|
||||
@ -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": "Додај ову нумеру у омиљене"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,7 +38,9 @@
|
||||
"missing": "หายไป",
|
||||
"libraryName": "ห้องสมุด",
|
||||
"composer": "ผู้แต่ง",
|
||||
"disc": ""
|
||||
"disc": "พื้นที่ %{discNumber}",
|
||||
"albumGain": "เนื้อหาในอัลบั้ม",
|
||||
"trackGain": "เนื้อหาในเพลง"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "เพิ่มในคิว",
|
||||
@ -355,7 +357,7 @@
|
||||
"selectedUsers": "ผู้ใช้ถูกเลือก",
|
||||
"allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด",
|
||||
"selectedLibraries": "ห้องสมุดเพลงถูกเลือก",
|
||||
"allowWriteAccess": ""
|
||||
"allowWriteAccess": "อนุญาตให้เขียน"
|
||||
},
|
||||
"sections": {
|
||||
"status": "สถานะ",
|
||||
@ -401,7 +403,7 @@
|
||||
"requiredHosts": "ต้องการ Host",
|
||||
"configValidationError": "การตั้งค่าเกิดความผิดพลาด",
|
||||
"schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน",
|
||||
"allowWriteAccessHelp": ""
|
||||
"allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "คีย์",
|
||||
@ -591,7 +593,13 @@
|
||||
"remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
|
||||
"noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน",
|
||||
"noTopSongsFound": "ไม่พบเพลงยอดนิยม",
|
||||
"startingInstantMix": "กำลังโหลดอินสแตนท์ มิก..."
|
||||
"startingInstantMix": "กำลังโหลดอินสแตนท์ มิก...",
|
||||
"uploadCover": "อัพโหลดภาพหน้าปก",
|
||||
"removeCover": "ลบถาพหน้าปก",
|
||||
"coverUploaded": "ภาพหน้าปกถูกอัพเดทแล้ว",
|
||||
"coverRemoved": "ภาพหน้าปกถูกลบแล้ว",
|
||||
"coverUploadError": "อัพโหลดภาพหน้าปกผิดพลาด",
|
||||
"coverRemoveError": "ลบภาพหน้าปกผิดพลาด"
|
||||
},
|
||||
"menu": {
|
||||
"library": "ห้องสมุดเพลง",
|
||||
@ -712,4 +720,4 @@
|
||||
"empty": "ไม่มีเพลงเล่น",
|
||||
"minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,7 +38,9 @@
|
||||
"missing": "遺失",
|
||||
"libraryName": "媒體庫",
|
||||
"composer": "作曲者",
|
||||
"disc": "光碟 %{discNumber}"
|
||||
"disc": "光碟 %{discNumber}",
|
||||
"albumGain": "專輯增益",
|
||||
"trackGain": "曲目增益"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "加入至播放佇列",
|
||||
@ -718,4 +720,4 @@
|
||||
"empty": "無播放內容",
|
||||
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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"},
|
||||
}}
|
||||
})
|
||||
|
||||
|
||||
@ -185,7 +185,7 @@ var _ = Describe("ParseCrontab", func() {
|
||||
// findSetBit returns the lowest bit position set in v, ignoring the starBit (bit 63).
|
||||
func findSetBit(v uint64) int {
|
||||
v &^= 1 << 63 // clear starBit
|
||||
for i := 0; i < 63; i++ {
|
||||
for i := range 63 {
|
||||
if v&(1<<uint(i)) != 0 {
|
||||
return i
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -125,3 +125,82 @@ var _ = Describe("Sharing Endpoints", Ordered, func() {
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Sharing Cross-User Isolation", Ordered, func() {
|
||||
var userA, userB model.User
|
||||
var shareID string
|
||||
var albumID string
|
||||
|
||||
BeforeAll(func() {
|
||||
conf.Server.EnableSharing = true
|
||||
setupTestDB()
|
||||
|
||||
userA = createUser("share-user-a", "share-user-a", "Share User A", false)
|
||||
userB = createUser("share-user-b", "share-user-b", "Share User B", false)
|
||||
|
||||
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"album.name": "Abbey Road"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albums).ToNot(BeEmpty())
|
||||
albumID = albums[0].ID
|
||||
|
||||
resp := doReqWithUser(userA, "createShare", "id", albumID, "description", "User A's share")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Shares.Share).To(HaveLen(1))
|
||||
shareID = resp.Shares.Share[0].ID
|
||||
Expect(resp.Shares.Share[0].Username).To(Equal(userA.UserName))
|
||||
})
|
||||
|
||||
It("userB's getShares does not leak userA's share", func() {
|
||||
resp := doReqWithUser(userB, "getShares")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Shares).ToNot(BeNil())
|
||||
Expect(resp.Shares.Share).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("userA still sees own share", func() {
|
||||
resp := doReqWithUser(userA, "getShares")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Shares.Share).To(HaveLen(1))
|
||||
Expect(resp.Shares.Share[0].ID).To(Equal(shareID))
|
||||
Expect(resp.Shares.Share[0].Description).To(Equal("User A's share"))
|
||||
})
|
||||
|
||||
It("admin sees userA's share", func() {
|
||||
resp := doReqWithUser(adminUser, "getShares")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
ids := make([]string, len(resp.Shares.Share))
|
||||
for i, s := range resp.Shares.Share {
|
||||
ids[i] = s.ID
|
||||
}
|
||||
Expect(ids).To(ContainElement(shareID))
|
||||
})
|
||||
|
||||
It("userB cannot updateShare on userA's share", func() {
|
||||
resp := doReqWithUser(userB, "updateShare", "id", shareID, "description", "hijacked")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
|
||||
// Confirm description unchanged for userA.
|
||||
check := doReqWithUser(userA, "getShares")
|
||||
Expect(check.Shares.Share).To(HaveLen(1))
|
||||
Expect(check.Shares.Share[0].Description).To(Equal("User A's share"))
|
||||
})
|
||||
|
||||
It("userB cannot deleteShare on userA's share", func() {
|
||||
resp := doReqWithUser(userB, "deleteShare", "id", shareID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
|
||||
// Confirm share still present for userA.
|
||||
check := doReqWithUser(userA, "getShares")
|
||||
Expect(check.Shares.Share).To(HaveLen(1))
|
||||
Expect(check.Shares.Share[0].ID).To(Equal(shareID))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user