Merge branch 'master' into update-spanish-translations

This commit is contained in:
Deluan Quintão 2026-06-05 18:32:47 -04:00 committed by GitHub
commit 1915ca4b5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
259 changed files with 8667 additions and 2212 deletions

View File

@ -53,13 +53,13 @@ runs:
- name: Login to Docker Hub
if: inputs.hub_username != '' && inputs.hub_password != ''
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ inputs.hub_username }}
password: ${{ inputs.hub_password }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@ -67,12 +67,13 @@ runs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Extract metadata for Docker image
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
github-token: ${{ inputs.github_token }}
labels: |
maintainer=deluan@navidrome.org
images: |

View File

@ -8,7 +8,7 @@ jobs:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v3
- uses: actions/github-script@v7
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
@ -19,8 +19,7 @@ jobs:
const pull_user_id = ${{github.event.sender.id}};
const issue_number = await (async () => {
const pulls = await github.pulls.list({owner, repo});
for await (const {data} of github.paginate.iterator(pulls)) {
for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) {
for (const pull of data) {
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
return pull.number;
@ -34,7 +33,7 @@ jobs:
return core.error(`No matching pull request found`);
}
const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
@ -43,12 +42,12 @@ jobs:
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
const {data: comments} = await github.issues.listComments({repo, owner, issue_number});
const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number});
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
if (existing_comment) {
core.info(`Updating comment ${existing_comment.id}`);
await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
} else {
core.info(`Creating a comment`);
await github.issues.createComment({repo, owner, issue_number, body});
await github.rest.issues.createComment({repo, owner, issue_number, body});
}

View File

@ -145,7 +145,7 @@ jobs:
- name: Cache ffmpeg
id: ffmpeg-cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: C:\ffmpeg
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64

View File

@ -28,7 +28,7 @@ jobs:
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
- uses: actions/stale@v9
- uses: actions/stale@v10
with:
operations-per-run: 999
days-before-issue-stale: 180

3
.gitignore vendored
View File

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

View File

@ -164,6 +164,7 @@ RUN touch /.nddockerenv
EXPOSE ${ND_PORT}
WORKDIR /app
ENV PATH="/app:${PATH}"
ENTRYPOINT ["/app/navidrome"]

View File

@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
GOLANGCI_LINT_VERSION ?= v2.11.1
GOLANGCI_LINT_VERSION ?= v2.12.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")

View File

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

View File

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

View File

@ -416,6 +416,10 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
return err == nil && sk != ""
}
func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
return nil
}
func init() {
conf.AddHook(func() {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {

View File

@ -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

View 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"))
})
})
})

View 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
}

View File

@ -212,6 +212,10 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin
return songs, nil
}
func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
return nil
}
func init() {
conf.AddHook(func() {
if conf.Server.ListenBrainz.Enabled {

View File

@ -75,7 +75,7 @@ var (
func runBackup(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = backupDir
conf.Server.Backup.Path = conf.NewDir(backupDir)
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
@ -104,7 +104,7 @@ func runBackup(ctx context.Context) {
func runPrune(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = backupDir
conf.Server.Backup.Path = conf.NewDir(backupDir)
}
if backupCount != -1 {

View File

@ -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 {

View File

@ -76,13 +76,13 @@ var svcInstance = sync.OnceValue(func() service.Service {
options["Restart"] = "on-failure"
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
options["UserService"] = false
options["LogDirectory"] = conf.Server.DataFolder
options["LogDirectory"] = conf.Server.DataFolder.String()
options["SystemdScript"] = systemdScript
if conf.Server.LogFile != "" {
options["LogOutput"] = false
} else {
options["LogOutput"] = true
options["LogDirectory"] = conf.Server.DataFolder
options["LogDirectory"] = conf.Server.DataFolder.String()
}
svcConfig := &service.Config{
UserName: installUser,
@ -131,11 +131,11 @@ func buildInstallCmd() *cobra.Command {
println("Installing service with:")
println(" working directory: " + executablePath())
println(" music folder: " + conf.Server.MusicFolder)
println(" data folder: " + conf.Server.DataFolder)
println(" data folder: " + conf.Server.DataFolder.String())
if conf.Server.LogFile != "" {
println(" log file: " + conf.Server.LogFile)
} else {
println(" logs folder: " + conf.Server.DataFolder)
println(" logs folder: " + conf.Server.DataFolder.String())
}
if cfgFile != "" {
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)

View File

@ -2,9 +2,7 @@ package configtest
import "github.com/navidrome/navidrome/conf"
// TODO Remove this redirection and call SnapshotConfig directly from tests
func SetupConfig() func() {
oldValues := *conf.Server
return func() {
conf.Server = &oldValues
}
return conf.SnapshotConfig()
}

View File

@ -2,6 +2,7 @@ package conf
import (
"cmp"
"encoding/json"
"fmt"
"net/url"
"os"
@ -14,6 +15,7 @@ import (
"github.com/bmatcuk/doublestar/v4"
"github.com/dustin/go-humanize"
"github.com/go-viper/encoding/ini"
"github.com/go-viper/mapstructure/v2"
"github.com/kr/pretty"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
@ -29,8 +31,8 @@ type configOptions struct {
UnixSocketPerm string
EnforceNonRootUser bool
MusicFolder string
DataFolder string
CacheFolder string
DataFolder Dir
CacheFolder Dir
DbPath string
LogLevel string
LogFile string
@ -45,7 +47,6 @@ type configOptions struct {
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
EnableTranscodingCancellation bool
EnableDownloads bool
EnableExternalServices bool
EnableM3UExternalAlbumArt bool
@ -95,6 +96,7 @@ type configOptions struct {
EnableReplayGain bool
EnableCoverAnimation bool
EnableNowPlaying bool
UIPlaybackReportInterval time.Duration
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
@ -110,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"`
@ -133,6 +136,7 @@ type configOptions struct {
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtworkThrottleBuffered bool
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
@ -161,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
@ -227,7 +237,7 @@ type jukeboxOptions struct {
type backupOptions struct {
Count int
Path string
Path Dir
Schedule string
}
@ -245,7 +255,7 @@ type inspectOptions struct {
type pluginsOptions struct {
Enabled bool
Folder string
Folder Dir
CacheSize string
AutoReload bool
LogLevel string
@ -285,6 +295,22 @@ var (
hooks []func()
)
// SnapshotConfig returns a function that restores Server to its current state.
// Uses JSON round-tripping so Dir fields get fresh sync.Once values.
func SnapshotConfig() func() {
snapshot, err := json.Marshal(Server)
if err != nil {
panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err))
}
return func() {
var restored configOptions
if err := json.Unmarshal(snapshot, &restored); err != nil {
panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err))
}
Server = &restored
}
}
func LoadFromFile(confFile string) {
viper.SetConfigFile(confFile)
err := viper.ReadInConfig()
@ -304,8 +330,15 @@ func Load(noConfigDump bool) {
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
err := viper.Unmarshal(&Server)
err := viper.Unmarshal(&Server, viper.DecodeHook(
mapstructure.ComposeDecodeHookFunc(
mapstructure.TextUnmarshallerHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
),
))
if err != nil {
logFatal("Error parsing config:", err)
}
@ -315,48 +348,28 @@ func Load(noConfigDump bool) {
logFatal(err)
}
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
if err != nil {
logFatal("Error creating data path:", err)
}
if Server.CacheFolder == "" {
Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
}
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
if err != nil {
logFatal("Error creating cache path:", err)
}
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
if err != nil {
logFatal("Error creating artwork path:", err)
if Server.CacheFolder.String() == "" {
Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache"))
}
if Server.Plugins.Enabled {
if Server.Plugins.Folder == "" {
Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
}
err = os.MkdirAll(Server.Plugins.Folder, 0700)
if err != nil {
logFatal("Error creating plugins path:", err)
if Server.Plugins.Folder.String() == "" {
Server.Plugins.Folder = NewDirWithPerm(filepath.Join(Server.DataFolder.String(), "plugins"), 0700)
} else {
Server.Plugins.Folder = NewDirWithPerm(Server.Plugins.Folder.String(), 0700)
}
}
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
if Server.DbPath == "" {
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
}
if Server.Backup.Path != "" {
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
if err != nil {
logFatal("Error creating backup path:", err)
}
Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath)
}
out := os.Stderr
if Server.LogFile != "" {
if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
}
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
@ -443,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")
@ -634,7 +648,7 @@ func validateScanSchedule() error {
}
func validateBackupSchedule() error {
if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
Server.Backup.Schedule = ""
return nil
}
@ -731,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)
@ -776,6 +789,7 @@ func setViperDefaults() {
viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablenowplaying", true)
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
viper.SetDefault("enableartworkupload", true)
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
viper.SetDefault("enablesharing", false)
@ -815,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)
@ -859,6 +876,7 @@ func setViperDefaults() {
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("devartworkthrottlebuffered", true)
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
viper.SetDefault("devexternalscanner", true)

View File

@ -186,27 +186,12 @@ var _ = Describe("Configuration", func() {
}).To(PanicWith(ContainSubstring("Error reading config file")))
})
It("is called when DataFolder is not writable", func() {
viper.SetDefault("datafolder", invalidPath)
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error creating data path")))
})
It("is called when CacheFolder is not writable", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("cachefolder", invalidPath)
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error creating cache path")))
})
It("is called when LogFile path is not writable", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt"))
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error opening log file")))
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
})
It("is called when BaseURL is invalid", func() {

77
conf/dir.go Normal file
View File

@ -0,0 +1,77 @@
package conf
import (
"cmp"
"fmt"
"os"
)
// 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
}
// NewDir creates a new Dir with the given path and default permissions (os.ModePerm).
func NewDir(path string) Dir {
return Dir{path: path, perm: os.ModePerm}
}
// 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 {
return d.path
}
// 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 {
path, err := d.Path()
if err != nil {
logFatal("creating directory:", err)
}
return path
}
// 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 {
return fmt.Sprintf("%q", d.path)
}
// MarshalText returns the raw path bytes. No side effects.
func (d Dir) MarshalText() ([]byte, error) {
return []byte(d.path), nil
}
// UnmarshalText sets the path from bytes. No side effects.
func (d *Dir) UnmarshalText(text []byte) error {
d.path = string(text)
if d.perm == 0 {
d.perm = os.ModePerm
}
return nil
}

164
conf/dir_test.go Normal file
View File

@ -0,0 +1,164 @@
package conf_test
import (
"os"
"sync"
"github.com/kr/pretty"
"github.com/navidrome/navidrome/conf"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Dir", func() {
Describe("NewDir", func() {
It("creates a Dir with the given path without side effects", func() {
d := conf.NewDir("/some/path")
Expect(d.String()).To(Equal("/some/path"))
})
})
Describe("String", func() {
It("returns the raw path without creating the directory", func() {
d := conf.NewDir("/nonexistent/path/that/should/not/be/created")
Expect(d.String()).To(Equal("/nonexistent/path/that/should/not/be/created"))
})
})
Describe("Path", func() {
It("creates the directory and returns the path on first call", func() {
dir := GinkgoT().TempDir()
target := dir + "/subdir/nested"
d := conf.NewDir(target)
path, err := d.Path()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(target))
Expect(target).To(BeADirectory())
})
It("is idempotent on subsequent calls", func() {
dir := GinkgoT().TempDir()
target := dir + "/idempotent"
d := conf.NewDir(target)
path1, err1 := d.Path()
path2, err2 := d.Path()
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() {
f := GinkgoT().TempDir()
blocker := f + "/blocker"
By("creating a file that blocks directory creation")
Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed())
invalid := blocker + "/subdir"
d := conf.NewDir(invalid)
_, pathErr := d.Path()
Expect(pathErr).To(HaveOccurred())
})
It("returns empty path and no error for empty path", func() {
d := conf.NewDir("")
path, err := d.Path()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(BeEmpty())
})
})
Describe("MustPath", func() {
It("returns the path when directory is created successfully", func() {
dir := GinkgoT().TempDir()
target := dir + "/mustpath"
d := conf.NewDir(target)
path := d.MustPath()
Expect(path).To(Equal(target))
Expect(target).To(BeADirectory())
})
It("calls logFatal on error", func() {
var fatalMsg []any
restore := conf.SetLogFatal(func(args ...any) {
fatalMsg = args
panic("logFatal called")
})
DeferCleanup(restore)
f := GinkgoT().TempDir() + "/blocker"
Expect(os.WriteFile(f, []byte("x"), 0600)).To(Succeed())
invalid := f + "/subdir"
d := conf.NewDir(invalid)
Expect(func() { d.MustPath() }).To(Panic())
Expect(fatalMsg).ToNot(BeEmpty())
})
})
Describe("MarshalText", func() {
It("returns the raw path bytes without side effects", func() {
d := conf.NewDir("/marshal/path")
b, err := d.MarshalText()
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).To(Equal("/marshal/path"))
})
})
Describe("UnmarshalText", func() {
It("sets the path from bytes without side effects", func() {
d := conf.NewDir("")
err := d.UnmarshalText([]byte("/unmarshal/path"))
Expect(err).ToNot(HaveOccurred())
Expect(d.String()).To(Equal("/unmarshal/path"))
})
It("allows round-trip marshal/unmarshal", func() {
d1 := conf.NewDir("/round/trip")
b, err := d1.MarshalText()
Expect(err).ToNot(HaveOccurred())
var d2 conf.Dir
err = d2.UnmarshalText(b)
Expect(err).ToNot(HaveOccurred())
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()
})
})
})

View File

@ -67,11 +67,12 @@ const (
ScanIgnoreFile = ".ndignore"
ArtworkFolder = "artwork"
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
DefaultUIVolume = 100
DefaultUISearchDebounceMs = 200
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
DefaultUIVolume = 100
DefaultUISearchDebounceMs = 200
DefaultUIPlaybackReportInterval = time.Minute
DefaultHttpClientTimeOut = 10 * time.Second
@ -152,25 +153,25 @@ var (
Name: "mp3 audio",
TargetFormat: "mp3",
DefaultBitRate: 192,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
},
{
Name: "opus audio",
TargetFormat: "opus",
DefaultBitRate: 128,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
},
{
Name: "aac audio",
TargetFormat: "aac",
DefaultBitRate: 256,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
},
{
Name: "flac audio",
TargetFormat: "flac",
DefaultBitRate: 0,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
},
}
)

View File

@ -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)

View File

@ -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{

View File

@ -52,7 +52,7 @@ func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID
// Configure cache
conf.Server.ImageCacheSize = cacheSize
conf.Server.CacheFolder = tmpDir
conf.Server.CacheFolder = conf.NewDir(tmpDir)
conf.Server.CoverArtQuality = 75
conf.Server.CoverArtPriority = "cover.*"
@ -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)

View File

@ -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))

View File

@ -37,20 +37,20 @@ var _ = Describe("Album artwork resolution", func() {
})
})
// Bug 2 variant: cover.* basenames tie across album-root and per-disc folders;
// compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder
// files first. Flip from PIt to It once it prefers shorter/parent paths.
// https://github.com/navidrome/navidrome/issues/5376
// cover.* basenames tie across album-root and per-disc folders;
// compareImageFiles must prefer shallower paths.
When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg ← currently wins (bug)
// │ └── cover.jpg ← should not win
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg
// └── cover.jpg ← should win (album-root fallback)
PIt("uses the album-root cover (currently picks a disc subfolder image — bug)", func() {
It("prefers the album-root cover over per-disc covers", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
@ -68,21 +68,20 @@ var _ = Describe("Album artwork resolution", func() {
})
})
// Bug 2: folder.jpg basenames tie across album-root and per-disc folders;
// the lexicographic full-path tiebreaker in compareImageFiles ranks
// "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg".
// Flip from PIt to It once compareImageFiles prefers shorter/parent paths.
// https://github.com/navidrome/navidrome/issues/5376
// folder.jpg basenames tie across album-root and per-disc folders;
// compareImageFiles must prefer shallower paths.
When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← currently wins (bug)
// │ └── folder.jpg ← should not win
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── folder.jpg ← should win (album-root fallback)
PIt("uses the album-root folder.jpg (currently picks a disc subfolder image — bug)", func() {
It("prefers the album-root folder.jpg over per-disc folder.jpg", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
@ -98,17 +97,15 @@ var _ = Describe("Album artwork resolution", func() {
})
})
// Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder
// lookup whenever an album lives entirely under a single subfolder, so an
// album-root cover is never considered. Flip from PIt to It once the guard
// accepts single-folder albums whose parent isn't already in the folder set.
// https://github.com/navidrome/navidrome/issues/5376
// Single-subfolder albums must still consider the parent folder's images.
When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() {
// Artist/
// └── Album/
// ├── disc1/
// │ └── 01 - Track.mp3
// └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug)
PIt("uses the parent-folder cover (currently ignored — bug)", func() {
// └── cover.jpg ← should win (parent-folder fallback)
It("uses the parent-folder cover for single-disc-subfolder albums", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
@ -121,6 +118,32 @@ var _ = Describe("Album artwork resolution", func() {
})
})
// https://github.com/navidrome/navidrome/issues/5456
When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() {
// Album/ (top-level folder, Path=".")
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── cover.jpg ← should win (album-root)
It("prefers the album-root cover.jpg", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
"Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
"Album/cover.jpg": imageFile("album-root"),
"Album/CD1/folder.jpg": imageFile("disc1"),
"Album/CD2/folder.jpg": imageFile("disc2"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() {
// Artist/
// └── Album/

View File

@ -1,6 +1,7 @@
package artworke2e_test
import (
"fmt"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
@ -255,6 +256,100 @@ var _ = Describe("Disc artwork resolution", func() {
})
})
// Reproduces https://github.com/navidrome/navidrome/issues/5456
// Deeply nested layout matching the reporter's actual structure.
When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() {
// Genre/Artist/Album/ ← album root with cover.jpg
// ├── cover.jpg ← album-level cover
// ├── Disc 01 (Subtitle)/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← disc 1 art
// ├── Disc 02 (Subtitle)/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── ... (12 discs)
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
conf.Server.CoverArtPriority = defaultCoverPriority
discNames := []string{
"Disc 01 (Birth of the Dead - The Studio Sides)",
"Disc 02 (Birth of the Dead - The Live Sides)",
"Disc 03 (The Grateful Dead)",
"Disc 04 (Anthem of the Sun)",
"Disc 05 (Aoxomoxoa)",
"Disc 06 (Live; Dead)",
"Disc 07 (Workingman's Dead)",
"Disc 08 (American Beauty)",
"Disc 09 (Grateful Dead)",
"Disc 10 (Europe '72)",
"Disc 11 (Europe '72)",
"Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))",
}
layout := fstest.MapFS{
"Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"),
}
for i, name := range discNames {
discNum := i + 1
prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name)
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)})
layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum))
}
setLayout(layout)
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
for i := range discNames {
discNum := i + 1
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))),
"disc %d should use its own folder.jpg", discNum)
}
})
})
// https://github.com/navidrome/navidrome/issues/5456
// Top-level album variant — album folder at library root (Path=".").
When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() {
// Album/ (top-level, Path=".")
// ├── cover.jpg ← album-level cover
// ├── Disc 01/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← disc 1 art
// ├── Disc 02/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── Disc 03/
// ├── 01 - Track.mp3
// └── folder.jpg
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
conf.Server.CoverArtPriority = defaultCoverPriority
layout := fstest.MapFS{
"Album/cover.jpg": imageFile("album-root-cover"),
}
for i := 1; i <= 3; i++ {
prefix := fmt.Sprintf("Album/Disc %02d/", i)
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)})
layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i))
}
setLayout(layout)
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
for i := 1; i <= 3; i++ {
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))),
"disc %d should use its own folder.jpg", i)
}
})
})
When("discsubtitle is set but no image filename matches the subtitle", func() {
// Artist/
// └── Album/

View File

@ -63,7 +63,7 @@ func setupHarness() {
// Reuse the suite-level DB path so the singleton connection keeps working
// across specs (see suiteDBTempDir comment).
conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL"
conf.Server.DataFolder = tempDir
conf.Server.DataFolder = conf.NewDir(tempDir)
conf.Server.MusicFolder = fakeLibPath
conf.Server.DevExternalScanner = false
conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call

View File

@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/natural"
)
@ -53,10 +54,9 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
lib: lib,
}
a.cacheKey.artID = artID
if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) {
a.cacheKey.lastUpdate = *a.updatedAt
} else {
a.cacheKey.lastUpdate = al.UpdatedAt
a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
if imagesUpdateAt != nil {
a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt)
}
return a, nil
}
@ -118,19 +118,22 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
folderIDSet[id] = true
}
// For multi-disc albums (2+ folders), check if all folders share a common parent
// that is not already included. This finds cover art in the album root folder
// (e.g., "Artist/Album/cover.jpg" when tracks are in "Artist/Album/CD1/" and "Artist/Album/CD2/").
// We skip single-folder albums to avoid pulling images from the artist folder.
// Check if all folders share a common parent that is not already included.
// This finds cover art in the album root folder (e.g., "Artist/Album/cover.jpg"
// when tracks are in disc subfolders like "Artist/Album/CD1/" and "Artist/Album/CD2/").
// For single-folder albums, the parent is only included when the folder has no
// images of its own (indicating a disc subfolder needing parent artwork).
if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" {
parentFolder, err := ds.Folder(ctx).Get(commonParentID)
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
} else if err != nil {
return nil, nil, nil, err
}
if parentFolder != nil {
folders = append(folders, *parentFolder)
if len(folders) >= 2 || !anyFolderHasImages(folders) {
parentFolder, err := ds.Folder(ctx).Get(commonParentID)
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
} else if err != nil {
return nil, nil, nil, err
}
if parentFolder != nil && parentFolder.ParentID != "" {
folders = append(folders, *parentFolder)
}
}
}
@ -156,10 +159,19 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
return paths, imgFiles, &updatedAt, nil
}
func anyFolderHasImages(folders []model.Folder) bool {
for _, f := range folders {
if len(f.ImageFiles) > 0 {
return true
}
}
return false
}
// commonParentFolder returns the shared parent folder ID when all folders have the
// same parent and that parent is not already in folderIDSet. Returns "" otherwise.
func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) string {
if len(folders) < 2 {
if len(folders) == 0 {
return ""
}
parentID := folders[0].ParentID
@ -174,11 +186,8 @@ func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) str
return parentID
}
// compareImageFiles compares two image file paths for sorting.
// It extracts the base filename (without extension) and compares case-insensitively.
// This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1".
// Note: This function is called O(n log n) times during sorting, but in practice albums
// typically have only 1-20 image files, making the repeated string operations negligible.
// compareImageFiles sorts image paths by: base filename (natural order),
// then path depth (shallower first), then full path (stable tiebreaker).
func compareImageFiles(a, b string) int {
// Case-insensitive comparison
a = strings.ToLower(a)
@ -188,9 +197,10 @@ func compareImageFiles(a, b string) int {
baseA := strings.TrimSuffix(path.Base(a), path.Ext(a))
baseB := strings.TrimSuffix(path.Base(b), path.Ext(b))
// Compare base names first, then full paths if equal
// Compare base names first, then prefer shallower paths, then full path as tiebreaker
return cmp.Or(
natural.Compare(baseA, baseB),
cmp.Compare(strings.Count(a, "/"), strings.Count(b, "/")),
natural.Compare(a, b),
)
}

View File

@ -141,6 +141,7 @@ var _ = Describe("Album Artwork Reader", func() {
ID: "parentFolder",
Path: "Artist",
Name: "Album",
ParentID: "artistFolder",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"cover.jpg", "back.jpg"},
}
@ -213,9 +214,83 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(repo.getCallCount).To(Equal(0))
})
It("does not query parent for single-folder albums", func() {
// A single-folder album's parent is typically the artist folder,
// which should not be searched for cover art
It("does not include library root parent for multi-folder albums", func() {
// Two album parts directly under the library root — parent is the root itself
repo.result = []model.Folder{
{
ID: "folder1",
Path: ".",
Name: "AlbumPart1",
ParentID: "rootFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
{
ID: "folder2",
Path: ".",
Name: "AlbumPart2",
ParentID: "rootFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{},
},
}
repo.parentResult = &model.Folder{
ID: "rootFolder",
Path: "",
Name: ".",
ParentID: "",
ImageFiles: []string{"unrelated.jpg"},
}
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal("AlbumPart1/cover.jpg"))
Expect(repo.getCallCount).To(Equal(1))
})
It("includes top-level album folder for multi-disc albums", func() {
// Album folder directly under library root, with disc subfolders
repo.result = []model.Folder{
{
ID: "folder1",
Path: "Album",
Name: "Disc1",
ParentID: "albumFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{"folder.jpg"},
},
{
ID: "folder2",
Path: "Album",
Name: "Disc2",
ParentID: "albumFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{"folder.jpg"},
},
}
repo.parentResult = &model.Folder{
ID: "albumFolder",
Path: ".",
Name: "Album",
ParentID: "rootFolder",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"cover.jpg"},
}
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
Expect(imgFiles).To(HaveLen(3))
Expect(imgFiles[0]).To(Equal("Album/cover.jpg"))
Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg"))
Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.jpg"))
Expect(repo.getCallCount).To(Equal(1))
})
It("does not query parent for single-folder albums that already have images", func() {
repo.result = []model.Folder{
{
ID: "folder1",
@ -232,10 +307,38 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
// Get should not have been called (single folder, no parent lookup)
Expect(repo.getCallCount).To(Equal(0))
})
It("includes parent images for single-disc-subfolder albums", func() {
repo.result = []model.Folder{
{
ID: "folder1",
Path: "Artist/Album",
Name: "disc1",
ParentID: "albumFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{},
},
}
repo.parentResult = &model.Folder{
ID: "albumFolder",
Path: "Artist",
Name: "Album",
ParentID: "artistFolder",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"cover.jpg"},
}
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
Expect(repo.getCallCount).To(Equal(1))
})
It("propagates non-ErrNotFound errors from parent folder lookup", func() {
repo.result = []model.Folder{
{

View File

@ -452,7 +452,7 @@ var _ = Describe("artistArtworkReader", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tempDir = GinkgoT().TempDir()
conf.Server.DataFolder = tempDir
conf.Server.DataFolder = conf.NewDir(tempDir)
// Create the artwork/artist directory
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed())

View File

@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
)
type discArtworkReader struct {
@ -105,10 +106,9 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
updatedAt: imagesUpdatedAt,
}
r.cacheKey.artID = artID
if r.updatedAt != nil && r.updatedAt.After(al.UpdatedAt) {
r.cacheKey.lastUpdate = *r.updatedAt
} else {
r.cacheKey.lastUpdate = al.UpdatedAt
r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
if imagesUpdatedAt != nil {
r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt)
}
return r, nil
}

View File

@ -21,7 +21,7 @@ var _ = Describe("radioArtworkReader", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tempDir = GinkgoT().TempDir()
conf.Server.DataFolder = tempDir
conf.Server.DataFolder = conf.NewDir(tempDir)
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed())

View File

@ -100,7 +100,7 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
} else {
log.Error(ctx, "No admin user found!", err)
}
u = &model.User{}
u = &model.User{IsAdmin: true, UserName: "admin"}
}
ctx = request.WithUsername(ctx, u.UserName)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
@ -325,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
@ -394,12 +394,13 @@ func isDefaultCommand(format, command string) bool {
// including all transcoding parameters (bitrate, sample rate, channels).
func buildDynamicArgs(opts TranscodeOptions) []string {
cmdPath, _ := ffmpegCmd()
args := []string{cmdPath, "-i", opts.FilePath}
args := []string{cmdPath}
if opts.Offset > 0 {
args = append(args, "-ss", strconv.Itoa(opts.Offset))
}
args = append(args, "-i", opts.FilePath)
args = append(args, "-map", "0:a:0")
if codec, ok := formatCodecMap[opts.Format]; ok {
@ -491,11 +492,20 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
var args []string
for _, s := range fixCmd(cmd) {
if strings.Contains(s, "%s") {
if offset > 0 && !strings.Contains(cmd, "%t") {
// 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, arg := range slices.Backward(args) {
if arg == "-i" {
insertAt = i
break
}
}
args = slices.Insert(args, insertAt, "-ss", strconv.Itoa(offset))
}
s = strings.ReplaceAll(s, "%s", path)
args = append(args, s)
if offset > 0 && !strings.Contains(cmd, "%t") {
args = append(args, "-ss", strconv.Itoa(offset))
}
} else {
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))

View File

@ -7,7 +7,7 @@ import (
"path/filepath"
"runtime"
"strings"
sync "sync"
"sync"
"testing"
"time"
@ -47,15 +47,15 @@ var _ = Describe("ffmpeg", func() {
})
Context("when command has time offset param", func() {
It("creates a valid command line with offset", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"}))
args := createFFmpegCommand("ffmpeg -ss %t -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
})
Context("when command does not have time offset param", func() {
It("adds time offset after the input file name", func() {
It("adds time offset before the input file name", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
})
})
@ -82,16 +82,16 @@ var _ = Describe("ffmpeg", func() {
Describe("isDefaultCommand", func() {
It("returns true for known default mp3 command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
})
It("returns true for known default opus command", func() {
Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
})
It("returns true for known default aac command", func() {
Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
})
It("returns true for known default flac command", func() {
Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
})
It("returns false for a custom command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
@ -165,8 +165,9 @@ var _ = Describe("ffmpeg", func() {
Offset: 30,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.mp3",
"ffmpeg",
"-ss", "30",
"-i", "/music/file.mp3",
"-map", "0:a:0",
"-c:a", "libmp3lame",
"-b:a", "192k",

View File

@ -21,7 +21,7 @@ var _ = Describe("ImageUploadService", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
conf.Server.DataFolder = tmpDir
conf.Server.DataFolder = conf.NewDir(tmpDir)
svc = core.NewImageUploadService()
})

View File

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

View File

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

View File

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

View File

@ -165,7 +165,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.OS.Containerized = consts.InContainer
// Install info
packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".package")
packageFileData, err := os.ReadFile(packageFilename)
if err == nil {
data.OS.Package = string(packageFileData)
@ -179,12 +179,12 @@ var staticData = sync.OnceValue(func() insights.Data {
// FS info
data.FS.Music = getFSInfo(conf.Server.MusicFolder)
data.FS.Data = getFSInfo(conf.Server.DataFolder)
if conf.Server.CacheFolder != "" {
data.FS.Cache = getFSInfo(conf.Server.CacheFolder)
data.FS.Data = getFSInfo(conf.Server.DataFolder.String())
if conf.Server.CacheFolder.String() != "" {
data.FS.Cache = getFSInfo(conf.Server.CacheFolder.String())
}
if conf.Server.Backup.Path != "" {
data.FS.Backup = getFSInfo(conf.Server.Backup.Path)
if conf.Server.Backup.Path.String() != "" {
data.FS.Backup = getFSInfo(conf.Server.Backup.Path.String())
}
// Config info

View File

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

View File

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

View File

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

View File

@ -144,29 +144,25 @@ var _ = Describe("Playlists", func() {
It("allows owner to update their playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
It("allows admin to update any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
newName := "Updated Name"
err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "pls-other", new("Updated Name"), nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
It("denies non-owner, non-admin from updating", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "nonexistent", new("Updated Name"), nil, nil, nil, nil)
Expect(err).To(Equal(model.ErrNotFound))
})
@ -184,8 +180,7 @@ var _ = Describe("Playlists", func() {
It("allows metadata updates on a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Smart"
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
})
@ -307,7 +302,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
conf.Server.DataFolder = tmpDir
conf.Server.DataFolder = conf.NewDir(tmpDir)
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
@ -371,7 +366,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
conf.Server.DataFolder = tmpDir
conf.Server.DataFolder = conf.NewDir(tmpDir)
// Create a real image file on disk
imgDir := filepath.Join(tmpDir, "artwork", "playlist")

View File

@ -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 {

View File

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

View File

@ -80,6 +80,14 @@ func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrob
return nil
}
func (b *bufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
s, ok := b.loader()
if !ok {
return errors.New("scrobbler not available")
}
return s.PlaybackReport(ctx, info)
}
func (b *bufferedScrobbler) sendWakeSignal() {
// Don't block if the previous signal was not read yet
select {

View File

@ -23,6 +23,7 @@ type Scrobbler interface {
IsAuthorized(ctx context.Context, userId string) bool
NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error
Scrobble(ctx context.Context, userId string, s Scrobble) error
PlaybackReport(ctx context.Context, info PlaybackSession) error
}
type Constructor func(ds model.DataStore) Scrobbler

View File

@ -0,0 +1,78 @@
package scrobbler
import (
"context"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) {
p.npMu.Lock()
defer p.npMu.Unlock()
ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
p.npQueue[playerId] = nowPlayingEntry{
ctx: ctx,
userId: userId,
track: track,
position: position,
}
p.sendNowPlayingSignal()
}
func (p *playTracker) sendNowPlayingSignal() {
// Don't block if the previous signal was not read yet
select {
case p.npSignal <- struct{}{}:
default:
}
}
func (p *playTracker) nowPlayingWorker() {
defer close(p.workerDone)
for {
select {
case <-p.shutdown:
return
case <-time.After(time.Second):
case <-p.npSignal:
}
p.npMu.Lock()
if len(p.npQueue) == 0 {
p.npMu.Unlock()
continue
}
// Keep a copy of the entries to process and clear the queue
entries := p.npQueue
p.npQueue = make(map[string]nowPlayingEntry)
p.npMu.Unlock()
// Process entries without holding lock
for _, entry := range entries {
p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
}
}
}
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
if t.Artist == consts.UnknownArtist {
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
return
}
allScrobblers := p.getActiveScrobblers()
for name, s := range allScrobblers {
if !s.IsAuthorized(ctx, userId) {
continue
}
log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position)
err := s.NowPlaying(ctx, userId, t, position)
if err != nil {
log.Error(ctx, "Error sending PlaybackSession", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
continue
}
}
}

View File

@ -3,7 +3,7 @@ package scrobbler
import (
"context"
"maps"
"sort"
"slices"
"sync"
"time"
@ -17,13 +17,32 @@ import (
"github.com/navidrome/navidrome/utils/singleton"
)
type NowPlayingInfo struct {
MediaFile model.MediaFile
Start time.Time
Position int
Username string
PlayerId string
PlayerName string
const (
StateStarting = "starting"
StatePlaying = "playing"
StatePaused = "paused"
StateStopped = "stopped"
StateExpired = "expired"
)
var ValidStates = map[string]bool{
StateStarting: true,
StatePlaying: true,
StatePaused: true,
StateStopped: true,
}
type PlaybackSession struct {
MediaFile model.MediaFile
Start time.Time
UserId string
Username string
PlayerId string
PlayerName string
State string
PositionMs int64
PlaybackRate float64
LastReport time.Time
}
type Submission struct {
@ -31,6 +50,16 @@ type Submission struct {
Timestamp time.Time
}
type ReportPlaybackParams struct {
MediaId string
PositionMs int64
State string
PlaybackRate float64
IgnoreScrobble bool
ClientId string
ClientName string
}
type nowPlayingEntry struct {
ctx context.Context
userId string
@ -38,10 +67,15 @@ type nowPlayingEntry struct {
position int
}
type playbackReportEntry struct {
ctx context.Context
info PlaybackSession
}
type PlayTracker interface {
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
GetNowPlaying(ctx context.Context) ([]PlaybackSession, error)
Submit(ctx context.Context, submissions []Submission) error
ReportPlayback(ctx context.Context, params ReportPlaybackParams) error
}
// PluginLoader is a minimal interface for plugin manager usage in PlayTracker
@ -54,7 +88,7 @@ type PluginLoader interface {
type playTracker struct {
ds model.DataStore
broker events.Broker
playMap cache.SimpleCache[string, NowPlayingInfo]
playMap cache.SimpleCache[string, PlaybackSession]
builtinScrobblers map[string]Scrobbler
pluginScrobblers map[string]Scrobbler
pluginLoader PluginLoader
@ -64,6 +98,10 @@ type playTracker struct {
npSignal chan struct{}
shutdown chan struct{}
workerDone chan struct{}
prQueue []playbackReportEntry
prMu sync.Mutex
prSignal chan struct{}
prWorkerDone chan struct{}
}
func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
@ -72,10 +110,14 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
})
}
// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by
// the GetPlayTracker function above
// NewPlayTracker creates a new PlayTracker instance. For normal usage, the PlayTracker has to be a singleton,
// returned by the GetPlayTracker function above. This constructor is exported for testing.
func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
return newPlayTracker(ds, broker, pluginManager)
}
func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker {
m := cache.NewSimpleCache[string, NowPlayingInfo]()
m := cache.NewSimpleCache[string, PlaybackSession]()
p := &playTracker{
ds: ds,
playMap: m,
@ -87,12 +129,24 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
npSignal: make(chan struct{}, 1),
shutdown: make(chan struct{}),
workerDone: make(chan struct{}),
prSignal: make(chan struct{}, 1),
prWorkerDone: make(chan struct{}),
}
if conf.Server.EnableNowPlaying {
m.OnExpiration(func(_ string, _ NowPlayingInfo) {
enableNowPlaying := conf.Server.EnableNowPlaying
m.OnExpiration(func(_ string, info PlaybackSession) {
log.Debug("PlaybackSession expired", "clientId", info.PlayerId, "mediaId", info.MediaFile.ID, "state",
info.State, "username", info.Username, "userId", info.UserId)
if enableNowPlaying {
broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()})
})
}
}
ctx := request.WithUser(context.Background(), model.User{ID: info.UserId, UserName: info.Username})
if info.State != StateStopped {
log.Trace("Enqueueing PlaybackReport for expired session", "session", info)
info.State = StateExpired
info.LastReport = time.Now()
p.enqueuePlaybackReport(ctx, info)
}
})
var enabled []string
for name, constructor := range constructors {
@ -107,13 +161,15 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
}
log.Debug("List of builtin scrobblers enabled", "names", enabled)
go p.nowPlayingWorker()
go p.playbackReportWorker()
return p
}
// stopNowPlayingWorker stops the background worker. This is primarily for testing.
func (p *playTracker) stopNowPlayingWorker() {
// stopBackgroundWorkers stops the background workers. This is primarily for testing.
func (p *playTracker) stopBackgroundWorkers() {
close(p.shutdown)
<-p.workerDone // Wait for worker to finish
<-p.workerDone // Wait for nowPlaying worker to finish
<-p.prWorkerDone // Wait for playbackReport worker to finish
}
// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers.
@ -193,112 +249,158 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler {
return combined
}
func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId)
if err != nil {
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
return err
func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration {
if rate <= 0 {
rate = 1.0
}
remainingMs := float64(int64(durationSec*1000)-positionMs) / rate
remainingSec := max(int(remainingMs/1000), 0)
return time.Duration(remainingSec+5) * time.Second
}
func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackParams) error {
player, _ := request.PlayerFrom(ctx)
user, _ := request.UserFrom(ctx)
info := NowPlayingInfo{
MediaFile: *mf,
Start: time.Now(),
Position: position,
Username: user.UserName,
PlayerId: playerId,
PlayerName: playerName,
clientId := params.ClientId
client := params.ClientName
now := time.Now()
switch params.State {
case StateStarting:
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
if err != nil {
return err
}
info := PlaybackSession{
MediaFile: *mf,
Start: now,
UserId: user.ID,
Username: user.UserName,
PlayerId: clientId,
PlayerName: client,
State: params.State,
PositionMs: params.PositionMs,
PlaybackRate: params.PlaybackRate,
LastReport: now,
}
err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate))
if err != nil {
log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
}
p.enqueuePlaybackReport(ctx, info)
case StatePlaying, StatePaused:
info, getErr := p.playMap.Get(clientId)
if getErr != nil || info.MediaFile.ID != params.MediaId {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
if err != nil {
return err
}
info = PlaybackSession{
MediaFile: *mf,
Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond),
UserId: user.ID,
Username: user.UserName,
PlayerId: clientId,
PlayerName: client,
}
}
info.State = params.State
info.PositionMs = params.PositionMs
info.PlaybackRate = params.PlaybackRate
info.LastReport = now
ttl := 30 * time.Minute
if params.State == StatePlaying {
ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate)
}
log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl)
err := p.playMap.AddWithTTL(clientId, info, ttl)
if err != nil {
log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
}
p.enqueuePlaybackReport(ctx, info)
case StateStopped:
var loadedMF *model.MediaFile
if !params.IgnoreScrobble && player.ScrobbleEnabled {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
if err != nil {
return err
}
loadedMF = mf
trackDurationMs := int64(mf.Duration * 1000)
threshold := min(trackDurationMs*50/100, 240_000)
if params.PositionMs >= threshold {
err = p.incPlay(ctx, mf, now)
if err != nil {
log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err)
}
p.dispatchScrobble(ctx, mf, now)
}
}
stoppedInfo := PlaybackSession{
UserId: user.ID,
Username: user.UserName,
PlayerId: clientId,
PlayerName: client,
State: params.State,
PositionMs: params.PositionMs,
PlaybackRate: params.PlaybackRate,
LastReport: now,
}
if info, getErr := p.playMap.Get(clientId); getErr == nil {
stoppedInfo.MediaFile = info.MediaFile
stoppedInfo.Start = info.Start
} else {
mf := loadedMF
if mf == nil {
var mfErr error
mf, mfErr = p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
if mfErr != nil {
return mfErr
}
}
stoppedInfo.MediaFile = *mf
}
p.enqueuePlaybackReport(ctx, stoppedInfo)
p.playMap.Remove(clientId)
}
// Calculate TTL based on remaining track duration. If position exceeds track duration,
// remaining is set to 0 to avoid negative TTL.
remaining := max(int(mf.Duration)-position, 0)
// Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration.
ttl := time.Duration(remaining+5) * time.Second
_ = p.playMap.AddWithTTL(playerId, info, ttl)
if conf.Server.EnableNowPlaying {
p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
}
player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled {
p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position)
// 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))
}
}
return nil
}
func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) {
p.npMu.Lock()
defer p.npMu.Unlock()
ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
p.npQueue[playerId] = nowPlayingEntry{
ctx: ctx,
userId: userId,
track: track,
position: position,
}
p.sendNowPlayingSignal()
}
func (p *playTracker) sendNowPlayingSignal() {
// Don't block if the previous signal was not read yet
select {
case p.npSignal <- struct{}{}:
default:
}
}
func (p *playTracker) nowPlayingWorker() {
defer close(p.workerDone)
for {
select {
case <-p.shutdown:
return
case <-time.After(time.Second):
case <-p.npSignal:
}
p.npMu.Lock()
if len(p.npQueue) == 0 {
p.npMu.Unlock()
continue
}
// Keep a copy of the entries to process and clear the queue
entries := p.npQueue
p.npQueue = make(map[string]nowPlayingEntry)
p.npMu.Unlock()
// Process entries without holding lock
for _, entry := range entries {
p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
}
}
}
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
if t.Artist == consts.UnknownArtist {
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
return
}
allScrobblers := p.getActiveScrobblers()
for name, s := range allScrobblers {
if !s.IsAuthorized(ctx, userId) {
continue
}
log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position)
err := s.NowPlaying(ctx, userId, t, position)
if err != nil {
log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
continue
}
}
}
func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) {
func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) {
res := p.playMap.Values()
sort.Slice(res, func(i, j int) bool {
return res[i].Start.After(res[j].Start)
slices.SortFunc(res, func(a, b PlaybackSession) int {
return b.Start.Compare(a.Start)
})
for i := range res {
if res[i].State == StatePlaying {
elapsed := time.Since(res[i].LastReport).Milliseconds()
estimated := res[i].PositionMs + int64(float64(elapsed)*res[i].PlaybackRate)
trackDurationMs := int64(res[i].MediaFile.Duration * 1000)
res[i].PositionMs = min(estimated, trackDurationMs)
}
}
return res, nil
}

View File

@ -20,9 +20,6 @@ import (
. "github.com/onsi/gomega"
)
// mockPluginLoader is a test implementation of PluginLoader for plugin scrobbler tests
// Moved to top-level scope to avoid linter issues
type mockPluginLoader struct {
mu sync.RWMutex
names []string
@ -51,7 +48,7 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
var _ = Describe("PlayTracker", func() {
var ctx context.Context
var ds model.DataStore
var tracker PlayTracker
var tracker *playTracker
var eventBroker *fakeEventBroker
var track model.MediaFile
var album model.Album
@ -74,7 +71,7 @@ var _ = Describe("PlayTracker", func() {
})
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests
tracker.builtinScrobblers["fake"] = fake // Bypass buffering for tests
track = model.MediaFile{
ID: "123",
@ -99,88 +96,12 @@ var _ = Describe("PlayTracker", func() {
AfterEach(func() {
// Stop the worker goroutine to prevent data races between tests
tracker.(*playTracker).stopNowPlayingWorker()
tracker.stopBackgroundWorkers()
})
It("does not register disabled scrobblers", func() {
Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
})
Describe("NowPlaying", func() {
It("sends track to agent", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Expect(fake.GetUserID()).To(Equal("u-1"))
Expect(fake.GetTrack().ID).To(Equal("123"))
Expect(fake.GetTrack().Participants).To(Equal(track.Participants))
})
It("does not send track to agent if user has not authorized", func() {
fake.Authorized = false
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if player is not enabled to send scrobbles", func() {
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if artist is unknown", func() {
track.Artist = consts.UnknownArtist
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("stores position when greater than zero", func() {
pos := 42
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos)
Expect(err).ToNot(HaveOccurred())
Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos))
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].Position).To(Equal(pos))
})
It("sends event with count", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
eventList := eventBroker.getEvents()
Expect(eventList).ToNot(BeEmpty())
evt, ok := eventList[0].(*events.NowPlayingCount)
Expect(ok).To(BeTrue())
Expect(evt.Count).To(Equal(1))
})
It("does not send event when disabled", func() {
conf.Server.EnableNowPlaying = false
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(eventBroker.getEvents()).To(BeEmpty())
})
It("passes user to scrobbler via context (fix for issue #4787)", func() {
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
// Verify the username was passed through async dispatch via context
Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser"))
})
Expect(tracker.builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled"))
})
Describe("GetNowPlaying", func() {
@ -188,10 +109,16 @@ var _ = Describe("PlayTracker", func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true})
_ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", ClientName: "player-one",
})
ctx2 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
ctx2 = request.WithPlayer(ctx2, model.Player{ScrobbleEnabled: true})
_ = tracker.ReportPlayback(ctx2, ReportPlaybackParams{
MediaId: "456", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
})
playing, err := tracker.GetNowPlaying(ctx)
@ -211,8 +138,8 @@ var _ = Describe("PlayTracker", func() {
Describe("Expiration events", func() {
It("sends event when entry expires", func() {
info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
_ = tracker.(*playTracker).playMap.AddWithTTL("player-1", info, 10*time.Millisecond)
info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"}
_ = tracker.playMap.AddWithTTL("player-1", info, 10*time.Millisecond)
Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0))
eventList := eventBroker.getEvents()
evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount)
@ -223,10 +150,48 @@ var _ = Describe("PlayTracker", func() {
It("does not send event when disabled", func() {
conf.Server.EnableNowPlaying = false
tracker = newPlayTracker(ds, eventBroker, nil)
info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
_ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"}
_ = tracker.playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0))
})
It("sends expired playback report when session expires", func() {
info := PlaybackSession{
MediaFile: track,
Start: time.Now(),
UserId: "u-1",
Username: "user",
PlayerId: "player-3",
PlayerName: "test-player",
State: StatePlaying,
PositionMs: 5000,
}
_ = tracker.playMap.AddWithTTL("player-3", info, 10*time.Millisecond)
Eventually(func() *PlaybackSession {
return fake.LastPlaybackReport.Load()
}).ShouldNot(BeNil())
report := fake.LastPlaybackReport.Load()
Expect(report.State).To(Equal(StateExpired))
Expect(report.MediaFile.ID).To(Equal("123"))
Expect(report.PlayerId).To(Equal("player-3"))
})
It("does not send expired report when session was already stopped", func() {
info := PlaybackSession{
MediaFile: track,
Start: time.Now(),
UserId: "u-1",
Username: "user",
PlayerId: "player-4",
PlayerName: "test-player",
State: StateStopped,
PositionMs: 180000,
}
_ = tracker.playMap.AddWithTTL("player-4", info, 10*time.Millisecond)
Consistently(func() *PlaybackSession {
return fake.LastPlaybackReport.Load()
}).Should(BeNil())
})
})
Describe("Submit", func() {
@ -336,6 +301,534 @@ var _ = Describe("PlayTracker", func() {
})
})
Describe("ReportPlayback", func() {
const defaultClientId = "client-1"
BeforeEach(func() {
ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: true})
})
It("creates entry on starting and removes on stopped", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("starting"))
Expect(playing[0].MediaFile.ID).To(Equal("123"))
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
IgnoreScrobble: true,
})
Expect(err).ToNot(HaveOccurred())
playing, err = tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(BeEmpty())
})
It("full lifecycle: starting -> playing -> paused -> playing -> stopped", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("playing"))
Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(10000)))
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err = tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing[0].State).To(Equal("paused"))
Expect(playing[0].PositionMs).To(Equal(int64(30000)))
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err = tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(BeEmpty())
})
It("starting replaces existing entry for same player", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("starting"))
Expect(playing[0].PositionMs).To(Equal(int64(0)))
})
It("multiple players have independent sessions", func() {
ctx1 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"})
ctx1 = request.WithPlayer(ctx1, model.Player{ID: "p1", ScrobbleEnabled: true})
ctx2 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"})
ctx2 = request.WithPlayer(ctx2, model.Player{ID: "p2", ScrobbleEnabled: true})
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1",
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx2, ReportPlaybackParams{
MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-2",
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(2))
})
Describe("SSE broadcast on state change", func() {
BeforeEach(func() {
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.builtinScrobblers["fake"] = fake
})
It("broadcasts NowPlayingCount on every state change", func() {
// starting -> count should be 1
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
evts := eventBroker.getEvents()
Expect(evts).To(HaveLen(1))
Expect(evts[0].(*events.NowPlayingCount).Count).To(Equal(1))
// playing -> count should be 1
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
evts = eventBroker.getEvents()
Expect(evts).To(HaveLen(2))
Expect(evts[1].(*events.NowPlayingCount).Count).To(Equal(1))
// paused -> count should be 1
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
evts = eventBroker.getEvents()
Expect(evts).To(HaveLen(3))
Expect(evts[2].(*events.NowPlayingCount).Count).To(Equal(1))
// stopped -> count should be 0
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
IgnoreScrobble: true,
})
Expect(err).ToNot(HaveOccurred())
evts = eventBroker.getEvents()
Expect(evts).To(HaveLen(4))
Expect(evts[3].(*events.NowPlayingCount).Count).To(Equal(0))
})
It("does NOT broadcast when EnableNowPlaying is false", func() {
conf.Server.EnableNowPlaying = false
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.builtinScrobblers["fake"] = fake
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(eventBroker.getEvents()).To(BeEmpty())
})
})
Describe("auto-scrobble", func() {
It("scrobbles on stopped when positionMs >= 50% of track", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(1)))
Expect(album.PlayCount).To(Equal(int64(1)))
Expect(artist1.PlayCount).To(Equal(int64(1)))
})
It("scrobbles on stopped when positionMs >= 4 min for long tracks", func() {
longTrack := model.MediaFile{
ID: "long", Title: "Long Song", Album: "Album", AlbumID: "al-1",
Duration: 600,
Participants: map[model.Role]model.ParticipantList{
model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")},
},
}
_ = ds.MediaFile(ctx).Put(&longTrack)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "long", PositionMs: 240000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(longTrack.PlayCount).To(Equal(int64(1)))
})
It("does NOT scrobble when positionMs below threshold", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
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,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
IgnoreScrobble: true,
})
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() {
ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false})
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
It("scrobbles twice for two separate sessions of same song", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(2)))
})
It("dispatches to external scrobblers on auto-scrobble", func() {
fake.ScrobbleCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
})
})
Describe("position estimation", func() {
It("estimates position for playing state based on elapsed time", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10000)))
})
It("does NOT estimate for paused", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(Equal(int64(10000)))
})
It("does NOT estimate for starting", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(Equal(int64(0)))
})
It("respects playbackRate", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 2.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(100 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
// At 2x speed, 100ms real time = ~200ms playback time
Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10100)))
})
It("caps estimated position at track duration", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 179990, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(Equal(int64(180000))) // track.Duration * 1000
})
})
Describe("resilience (no prior starting)", func() {
It("playing without prior starting creates entry with Start approx now - positionMs", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("playing"))
expectedStart := time.Now().Add(-30 * time.Second)
Expect(playing[0].Start).To(BeTemporally("~", expectedStart, 2*time.Second))
})
It("paused without prior starting creates entry", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("paused"))
})
It("stopped without prior starting auto-scrobbles if threshold met", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(1)))
})
It("stopped without prior starting does NOT scrobble if below threshold", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
})
Describe("external scrobbler dispatch", func() {
It("dispatches NowPlaying on starting", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("dispatches NowPlaying on playing", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("does NOT dispatch on paused", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
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())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("does NOT dispatch when ScrobbleEnabled=false", func() {
fake.nowPlayingCalled.Store(false)
ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false})
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
})
Describe("PlaybackReport dispatch", func() {
It("dispatches PlaybackReport for starting state", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
ClientId: "client-1", ClientName: "Test Player",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool {
return fake.PlaybackReportCalled.Load()
}).Should(BeTrue())
info := fake.LastPlaybackReport.Load()
Expect(info).ToNot(BeNil())
Expect(info.MediaFile.ID).To(Equal("123"))
Expect(info.State).To(Equal(StateStarting))
Expect(info.PositionMs).To(Equal(int64(0)))
Expect(info.PlaybackRate).To(Equal(1.0))
Expect(info.PlayerId).To(Equal("client-1"))
Expect(info.PlayerName).To(Equal("Test Player"))
})
It("dispatches PlaybackReport for playing state", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
ClientId: "client-1", ClientName: "Test Player",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
fake.PlaybackReportCalled.Store(false)
fake.LastPlaybackReport.Store(nil)
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: StatePlaying, PlaybackRate: 1.5,
ClientId: "client-1", ClientName: "Test Player",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
info := fake.LastPlaybackReport.Load()
Expect(info.State).To(Equal(StatePlaying))
Expect(info.PositionMs).To(Equal(int64(30000)))
Expect(info.PlaybackRate).To(Equal(1.5))
})
It("dispatches PlaybackReport for paused state", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
ClientId: "client-1", ClientName: "Test Player",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
fake.PlaybackReportCalled.Store(false)
fake.LastPlaybackReport.Store(nil)
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 45000, State: StatePaused, PlaybackRate: 1.0,
ClientId: "client-1", ClientName: "Test Player",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
info := fake.LastPlaybackReport.Load()
Expect(info.State).To(Equal(StatePaused))
Expect(info.PositionMs).To(Equal(int64(45000)))
})
It("dispatches PlaybackReport for stopped state", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
ClientId: "client-1", ClientName: "Test Player",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
fake.PlaybackReportCalled.Store(false)
fake.LastPlaybackReport.Store(nil)
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 100000, State: StateStopped, PlaybackRate: 1.0,
ClientId: "client-1", ClientName: "Test Player",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
info := fake.LastPlaybackReport.Load()
Expect(info.State).To(Equal(StateStopped))
Expect(info.PositionMs).To(Equal(int64(100000)))
})
})
})
Describe("Plugin scrobbler logic", func() {
var pluginLoader *mockPluginLoader
var pluginFake *fakeScrobbler
@ -349,32 +842,37 @@ var _ = Describe("PlayTracker", func() {
tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader)
// Bypass buffering for both built-in and plugin scrobblers
tracker.(*playTracker).builtinScrobblers["fake"] = fake
tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake
tracker.builtinScrobblers["fake"] = fake
tracker.pluginScrobblers["plugin1"] = pluginFake
})
It("registers and uses plugin scrobbler for NowPlaying", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("removes plugin scrobbler if not present anymore", func() {
// First call: plugin present
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
_ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
})
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
pluginFake.nowPlayingCalled.Store(false)
// Remove plugin
pluginLoader.SetNames([]string{})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
// Should not be called since plugin was removed
_ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
})
Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("calls both builtin and plugin scrobblers for NowPlaying", func() {
fake.nowPlayingCalled.Store(false)
pluginFake.nowPlayingCalled.Store(false)
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
@ -462,7 +960,7 @@ var _ = Describe("PlayTracker", func() {
})
AfterEach(func() {
pTracker.stopNowPlayingWorker()
pTracker.stopBackgroundWorkers()
})
It("uses the new plugin instance after reload (simulating config update)", func() {
@ -550,16 +1048,36 @@ var _ = Describe("PlayTracker", func() {
})
})
var _ = DescribeTable("remainingTTL",
func(durationSec float32, positionMs int64, rate float64, expected time.Duration) {
Expect(remainingTTL(durationSec, positionMs, rate)).To(Equal(expected))
},
Entry("full track at 1x", float32(300), int64(0), 1.0, 305*time.Second),
Entry("halfway through at 1x", float32(300), int64(150000), 1.0, 155*time.Second),
Entry("near end at 1x", float32(300), int64(298000), 1.0, 7*time.Second),
Entry("at end of track", float32(300), int64(300000), 1.0, 5*time.Second),
Entry("past end of track", float32(300), int64(310000), 1.0, 5*time.Second),
Entry("2x speed halves remaining time", float32(300), int64(0), 2.0, 155*time.Second),
Entry("2x speed halfway", float32(300), int64(150000), 2.0, 80*time.Second),
Entry("0.5x speed doubles remaining time", float32(300), int64(0), 0.5, 605*time.Second),
Entry("zero rate defaults to 1x", float32(300), int64(0), 0.0, 305*time.Second),
Entry("negative rate defaults to 1x", float32(300), int64(0), -1.0, 305*time.Second),
Entry("short track", float32(3.5), int64(0), 1.0, 8*time.Second),
Entry("zero duration", float32(0), int64(0), 1.0, 5*time.Second),
)
type fakeScrobbler struct {
Authorized bool
nowPlayingCalled atomic.Bool
ScrobbleCalled atomic.Bool
userID atomic.Pointer[string]
username atomic.Pointer[string]
track atomic.Pointer[model.MediaFile]
position atomic.Int32
LastScrobble atomic.Pointer[Scrobble]
Error error
Authorized bool
nowPlayingCalled atomic.Bool
ScrobbleCalled atomic.Bool
PlaybackReportCalled atomic.Bool
userID atomic.Pointer[string]
username atomic.Pointer[string]
track atomic.Pointer[model.MediaFile]
position atomic.Int32
LastScrobble atomic.Pointer[Scrobble]
LastPlaybackReport atomic.Pointer[PlaybackSession]
Error error
}
func (f *fakeScrobbler) GetNowPlayingCalled() bool {
@ -577,17 +1095,6 @@ func (f *fakeScrobbler) GetTrack() *model.MediaFile {
return f.track.Load()
}
func (f *fakeScrobbler) GetPosition() int {
return int(f.position.Load())
}
func (f *fakeScrobbler) GetUsername() string {
if p := f.username.Load(); p != nil {
return *p
}
return ""
}
func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return f.Error == nil && f.Authorized
}
@ -623,6 +1130,16 @@ func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble)
return nil
}
func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
f.PlaybackReportCalled.Store(true)
if f.Error != nil {
return f.Error
}
f.userID.Store(new(info.UserId))
f.LastPlaybackReport.Store(&info)
return nil
}
func _p(id, name string, sortName ...string) model.Participant {
p := model.Participant{Artist: model.Artist{ID: id, Name: name}}
if len(sortName) > 0 {
@ -678,3 +1195,7 @@ func (m *mockBufferedScrobbler) NowPlaying(ctx context.Context, userId string, t
func (m *mockBufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
return m.wrapped.Scrobble(ctx, userId, s)
}
func (m *mockBufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
return m.wrapped.PlaybackReport(ctx, info)
}

View File

@ -0,0 +1,64 @@
package scrobbler
import (
"context"
"github.com/navidrome/navidrome/log"
)
func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) {
p.prMu.Lock()
defer p.prMu.Unlock()
ctx = context.WithoutCancel(ctx)
p.prQueue = append(p.prQueue, playbackReportEntry{
ctx: ctx,
info: info,
})
p.sendPlaybackReportSignal()
}
func (p *playTracker) sendPlaybackReportSignal() {
select {
case p.prSignal <- struct{}{}:
default:
}
}
func (p *playTracker) playbackReportWorker() {
defer close(p.prWorkerDone)
for {
select {
case <-p.shutdown:
return
case <-p.prSignal:
}
p.prMu.Lock()
if len(p.prQueue) == 0 {
p.prMu.Unlock()
continue
}
entries := p.prQueue
p.prQueue = nil
p.prMu.Unlock()
allScrobblers := p.getActiveScrobblers()
for _, entry := range entries {
p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers)
}
}
}
func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) {
for name, s := range allScrobblers {
if !s.IsAuthorized(ctx, info.UserId) {
continue
}
log.Debug(ctx, "Sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, "positionMs", info.PositionMs)
err := s.PlaybackReport(ctx, info)
if err != nil {
log.Error(ctx, "Error sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, err)
continue
}
}
}

View File

@ -41,7 +41,7 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error
if !expiresAt.IsZero() && expiresAt.Before(time.Now()) {
return nil, model.ErrExpired
}
share.LastVisitedAt = P(time.Now())
share.LastVisitedAt = new(time.Now())
share.VisitCount++
err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count")
@ -95,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

View File

@ -59,18 +59,6 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile,
decision.SourceStream = buildSourceStream(mf, probe)
src := &decision.SourceStream
// Check for server-side player transcoding override
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
} else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
modified := *clientInfo
modified.MaxAudioBitrate = player.MaxBitRate
clientInfo = &modified
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
}
}
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container,
"codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels,
"sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name)

View File

@ -1042,8 +1042,8 @@ var _ = Describe("Decider", func() {
})
})
Context("Server-side player transcoding override", func() {
It("forces transcoding when override targets a different format", func() {
Context("Server-side context is ignored by MakeDecision", func() {
It("ignores transcoding override in context", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
@ -1051,148 +1051,21 @@ var _ = Describe("Decider", func() {
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
},
}
// Set server override in context
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
Expect(decision.TargetBitrate).To(Equal(192))
})
It("allows direct play when source matches forced format and bitrate is within cap", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
},
}
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256})
decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
Expect(decision.CanTranscode).To(BeFalse())
})
It("transcodes when source bitrate exceeds the forced cap", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
}
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
Expect(decision.TargetBitrate).To(Equal(192))
})
It("uses player MaxBitRate over transcoding DefaultBitRate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
}
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320})
decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
Expect(decision.TargetBitrate).To(Equal(320))
})
It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
}
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
// With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock)
Expect(decision.TargetBitrate).To(Equal(160))
})
It("does not apply override when no transcoding is in context", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
},
}
// No override in context — client profiles used as-is
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
})
Context("Player MaxBitRate cap", func() {
It("applies player MaxBitRate cap when client has no limit", func() {
It("ignores player MaxBitRate in context", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
ci := &ClientInfo{
Name: "TestClient",
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac", "mp3"}, AudioCodecs: []string{"flac", "mp3"}, Protocols: []string{ProtocolHTTP}},
},
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
},
}
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
// Source bitrate 1000 > player cap 320, so direct play is not possible
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
// Lossless→lossy should use MaxAudioBitrate (320) as target, not format default
Expect(decision.TargetBitrate).To(Equal(320))
})
It("uses client limit when it is more restrictive than player MaxBitRate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
ci := &ClientInfo{
Name: "TestClient",
MaxAudioBitrate: 256,
MaxTranscodingAudioBitrate: 256,
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
},
}
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
// Client limit 256 < player cap 500, so player cap doesn't apply; client limit wins
Expect(decision.TargetBitrate).To(Equal(256))
})
It("does not cap when player MaxBitRate is 0", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
Name: "TestClient",
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
},
}
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0})
decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())

View File

@ -7,12 +7,11 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
// buildLegacyClientInfo translates legacy Subsonic stream/download parameters
// into a ClientInfo for use with MakeDecision.
// It does NOT read request.TranscodingFrom(ctx) — that is handled by
// MakeDecision's applyServerOverride.
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo {
ci := &ClientInfo{Name: "legacy"}
@ -65,6 +64,19 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile
}
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
// Apply server-side player transcoding override before making the decision
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
} else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
modified := *clientInfo
modified.MaxAudioBitrate = player.MaxBitRate
clientInfo = &modified
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
}
}
decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true})
if err != nil {
log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err)

View File

@ -7,6 +7,7 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"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"
@ -187,6 +188,109 @@ var _ = Describe("ResolveRequest", func() {
Expect(req.Offset).To(Equal(30))
})
Context("Server-side player transcoding override", func() {
It("forces transcoding when override targets a different format", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(192))
})
It("allows direct play when source matches forced format and bitrate is within cap", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
It("transcodes when source bitrate exceeds the forced cap", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(192))
})
It("uses player MaxBitRate over transcoding DefaultBitRate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(320))
})
It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("mp3"))
// With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock)
Expect(req.BitRate).To(Equal(160))
})
It("does not apply override when no transcoding is in context", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
})
Context("Player MaxBitRate cap", func() {
It("applies player MaxBitRate cap when client has no limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "mp3", 0, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(320))
})
It("uses client limit when it is more restrictive than player MaxBitRate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "mp3", 256, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(256))
})
It("does not cap when player MaxBitRate is 0", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
})
Context("fallback for unknown format", func() {
It("falls back to DefaultDownsamplingFormat", func() {
DeferCleanup(configtest.SetupConfig())

135
core/stream/limiter.go Normal file
View 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
View 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())
})
})
})

View File

@ -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 ""
}

View File

@ -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"
@ -23,7 +25,8 @@ var _ = Describe("MediaStreamer", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches")
cacheDir, _ := os.MkdirTemp("", "file_caches")
conf.Server.CacheFolder = conf.NewDir(cacheDir)
conf.Server.TranscodingCacheSize = "100MB"
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
@ -34,7 +37,7 @@ var _ = Describe("MediaStreamer", func() {
streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache)
})
AfterEach(func() {
_ = os.RemoveAll(conf.Server.CacheFolder)
_ = os.RemoveAll(conf.Server.CacheFolder.String())
})
Context("NewStream", func() {
@ -60,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())

View File

@ -27,7 +27,7 @@ const backupSuffixLayout = "2006.01.02_15.04.05"
func backupPath(t time.Time) string {
return filepath.Join(
conf.Server.Backup.Path,
conf.Server.Backup.Path.MustPath(),
fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)),
)
}
@ -117,7 +117,11 @@ func Restore(ctx context.Context, path string) error {
}
func Prune(ctx context.Context) (int, error) {
files, err := os.ReadDir(conf.Server.Backup.Path)
backupDir, err := conf.Server.Backup.Path.Path()
if err != nil {
return 0, fmt.Errorf("backup directory not available: %w", err)
}
files, err := os.ReadDir(backupDir)
if err != nil {
return 0, fmt.Errorf("unable to read database backup entries: %w", err)
}

View File

@ -60,7 +60,7 @@ var _ = Describe("database backups", func() {
tempFolder, err := os.MkdirTemp("", "navidrome_backup")
Expect(err).ToNot(HaveOccurred())
conf.Server.Backup.Path = tempFolder
conf.Server.Backup.Path = conf.NewDir(tempFolder)
DeferCleanup(func() {
_ = os.RemoveAll(tempFolder)
@ -118,7 +118,7 @@ var _ = Describe("database backups", func() {
BeforeEach(func() {
tempFolder, err := os.MkdirTemp("", "navidrome_backup")
Expect(err).ToNot(HaveOccurred())
conf.Server.Backup.Path = tempFolder
conf.Server.Backup.Path = conf.NewDir(tempFolder)
DeferCleanup(func() {
_ = os.RemoveAll(tempFolder)

View File

@ -38,6 +38,8 @@ func Db() *sql.DB {
if Path == ":memory:" {
Path = "file::memory:?cache=shared&_foreign_keys=on"
conf.Server.DbPath = Path
} else {
conf.Server.DataFolder.MustPath()
}
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
db, err := sql.Open(Driver, Path)

View File

@ -0,0 +1,55 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upMoveSsBeforeInput, downMoveSsBeforeInput)
}
// ssSeekPairs maps old commands (output seeking) to new commands (input seeking).
// Index 0 = old (after -i), index 1 = new (before -i).
var ssSeekPairs = [][2]string{
{
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
"ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
},
{
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
"ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
},
{
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
"ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
},
{
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
"ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
},
{
"ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
"ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
},
}
func upMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error {
for _, p := range ssSeekPairs {
if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil {
return err
}
}
return nil
}
func downMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error {
for _, p := range ssSeekPairs {
if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil {
return err
}
}
return nil
}

View File

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

52
go.mod
View File

@ -1,9 +1,9 @@
module github.com/navidrome/navidrome
go 1.26.0
go 1.26
// Fork to implement raw tags support
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a
require (
github.com/Masterminds/squirrel v1.5.4
@ -20,11 +20,12 @@ 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
github.com/go-viper/encoding/ini v0.1.1
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/gohugoio/hashstructure v0.6.0
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
github.com/google/uuid v1.6.0
@ -34,13 +35,13 @@ require (
github.com/jellydator/ttlcache/v3 v3.4.0
github.com/kardianos/service v1.2.4
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v3 v3.1.0
github.com/mattn/go-sqlite3 v1.14.42
github.com/lestrrat-go/jwx/v3 v3.1.1
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.2
github.com/onsi/gomega v1.39.1
github.com/pelletier/go-toml/v2 v2.3.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
github.com/pressly/goose/v3 v3.27.1
@ -53,24 +54,24 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/tetratelabs/wazero v1.11.0
github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633
github.com/unrolled/secure v1.17.0
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.39.0
golang.org/x/net v0.53.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.43.0
golang.org/x/term v0.42.0
golang.org/x/text v0.36.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
gopkg.in/yaml.v3 v3.0.1
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/atombender/go-jsonschema v0.20.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@ -80,20 +81,19 @@ 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/fsnotify/fsnotify v1.9.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
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect
github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/cpuid/v2 v2.3.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,12 +133,12 @@ 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.50.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect
golang.org/x/tools v0.44.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
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect
gopkg.in/ini.v1 v1.67.2 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
)

96
go.sum
View File

@ -2,8 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
@ -32,8 +32,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a h1:ZPwh87Xa08FCg5MU5e0Did5WgapEWGxb5d4Je0pLjJw=
github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a h1:L5E3uF4hKLEqoEYT0tXXuFH6c3PEEzQSWLfTqF5Lpqw=
github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a/go.mod h1:+k5CamBu88xgydgNGJjugYVeafoCCswoGjpw5w5CvD4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
@ -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=
@ -63,8 +63,8 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg=
github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0=
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
@ -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=
@ -106,8 +106,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M=
github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -125,8 +125,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f h1:Fnl4pzx8SR7k7JuzyW8lEtSFH6EQ8xgcypgIn8pcGIE=
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QSchEk63/fjeEvWOa2cE02FSv9ox//VE/N4c8g=
github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
@ -167,16 +167,16 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw=
github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw=
github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@ -193,12 +193,12 @@ 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.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps=
github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -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=
@ -279,8 +279,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 h1:6GN/lazdqr69FIzz1U6c4TF/ppE2dInMR4GzU9QKxjg=
github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633/go.mod h1:3ghOSSWYnzX0zd/3Ns4ni2tKxcXDE9/QgkwuH1PW3Rs=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@ -316,17 +316,17 @@ 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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
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=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
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,11 +364,11 @@ 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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.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-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
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=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -377,8 +377,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -389,8 +389,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -400,8 +400,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
@ -409,8 +409,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss=
gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@ -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
}

View File

@ -14,7 +14,7 @@ var _ = Describe("Artist", func() {
Describe("UploadedImagePath", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = "/data"
conf.Server.DataFolder = conf.NewDir("/data")
})
It("returns empty string when no image uploaded", func() {

View File

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

View File

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

View File

@ -2,83 +2,95 @@ package criteria
import "strings"
// FieldInfo contains semantic metadata about a criteria field
// FieldInfo contains semantic metadata about a criteria field.
type FieldInfo struct {
Name string
Alias string // If set, this field is a backward-compat alias for another canonical name
IsTag bool
IsRole bool
Numeric bool
alias string
Boolean bool
tagAlias string // If set, a tag name from mappings.yml that resolves to this field
name string // Canonical name, populated by LookupField from the map key
}
// Name returns the canonical field name (the map key used to register this field).
func (f FieldInfo) Name() string {
return f.name
}
var fieldMap = map[string]FieldInfo{
"title": {Name: "title"},
"album": {Name: "album"},
"hascoverart": {Name: "hascoverart"},
"tracknumber": {Name: "tracknumber"},
"discnumber": {Name: "discnumber"},
"year": {Name: "year"},
"date": {Name: "date", alias: "recordingdate"},
"originalyear": {Name: "originalyear"},
"originaldate": {Name: "originaldate"},
"releaseyear": {Name: "releaseyear"},
"releasedate": {Name: "releasedate"},
"size": {Name: "size"},
"compilation": {Name: "compilation"},
"missing": {Name: "missing"},
"explicitstatus": {Name: "explicitstatus"},
"dateadded": {Name: "dateadded"},
"datemodified": {Name: "datemodified"},
"discsubtitle": {Name: "discsubtitle"},
"comment": {Name: "comment"},
"lyrics": {Name: "lyrics"},
"sorttitle": {Name: "sorttitle"},
"sortalbum": {Name: "sortalbum"},
"sortartist": {Name: "sortartist"},
"sortalbumartist": {Name: "sortalbumartist"},
"albumcomment": {Name: "albumcomment"},
"catalognumber": {Name: "catalognumber"},
"filepath": {Name: "filepath"},
"filetype": {Name: "filetype"},
"codec": {Name: "codec"},
"duration": {Name: "duration"},
"bitrate": {Name: "bitrate"},
"bitdepth": {Name: "bitdepth"},
"samplerate": {Name: "samplerate"},
"bpm": {Name: "bpm"},
"channels": {Name: "channels"},
"loved": {Name: "loved"},
"dateloved": {Name: "dateloved"},
"lastplayed": {Name: "lastplayed"},
"daterated": {Name: "daterated"},
"playcount": {Name: "playcount"},
"rating": {Name: "rating"},
"averagerating": {Name: "averagerating", Numeric: true},
"albumrating": {Name: "albumrating"},
"albumloved": {Name: "albumloved"},
"albumplaycount": {Name: "albumplaycount"},
"albumlastplayed": {Name: "albumlastplayed"},
"albumdateloved": {Name: "albumdateloved"},
"albumdaterated": {Name: "albumdaterated"},
"artistrating": {Name: "artistrating"},
"artistloved": {Name: "artistloved"},
"artistplaycount": {Name: "artistplaycount"},
"artistlastplayed": {Name: "artistlastplayed"},
"artistdateloved": {Name: "artistdateloved"},
"artistdaterated": {Name: "artistdaterated"},
"mbz_album_id": {Name: "mbz_album_id"},
"mbz_album_artist_id": {Name: "mbz_album_artist_id"},
"mbz_artist_id": {Name: "mbz_artist_id"},
"mbz_recording_id": {Name: "mbz_recording_id"},
"mbz_release_track_id": {Name: "mbz_release_track_id"},
"mbz_release_group_id": {Name: "mbz_release_group_id"},
"library_id": {Name: "library_id", Numeric: true},
"title": {},
"album": {},
"hascoverart": {Boolean: true},
"tracknumber": {},
"discnumber": {},
"year": {},
"date": {tagAlias: "recordingdate"},
"originalyear": {},
"originaldate": {},
"releaseyear": {},
"releasedate": {},
"size": {},
"compilation": {Boolean: true},
"missing": {Boolean: true},
"explicitstatus": {},
"dateadded": {},
"datemodified": {},
"discsubtitle": {},
"comment": {},
"lyrics": {},
"sorttitle": {},
"sortalbum": {},
"sortartist": {},
"sortalbumartist": {},
"albumcomment": {},
"catalognumber": {},
"filepath": {},
"filetype": {},
"codec": {},
"duration": {},
"bitrate": {},
"bitdepth": {},
"samplerate": {},
"bpm": {},
"channels": {},
"loved": {Boolean: true},
"dateloved": {},
"lastplayed": {},
"daterated": {},
"playcount": {},
"rating": {},
"averagerating": {Numeric: true},
"albumrating": {},
"albumloved": {Boolean: true},
"albumplaycount": {},
"albumlastplayed": {},
"albumdateloved": {},
"albumdaterated": {},
"artistrating": {},
"artistloved": {Boolean: true},
"artistplaycount": {},
"artistlastplayed": {},
"artistdateloved": {},
"artistdaterated": {},
"mbz_album_id": {},
"mbz_album_artist_id": {},
"mbz_artist_id": {},
"mbz_recording_id": {},
"mbz_release_track_id": {},
"mbz_release_group_id": {},
"rgalbumgain": {Numeric: true},
"rgalbumpeak": {Numeric: true},
"rgtrackgain": {Numeric: true},
"rgtrackpeak": {Numeric: true},
"library_id": {Numeric: true},
// Backward compatibility: albumtype is an alias for the releasetype tag.
"albumtype": {Name: "releasetype", IsTag: true},
"albumtype": {Alias: "releasetype", IsTag: true},
"random": {Name: "random"},
"value": {Name: "value"},
// Pseudo-field for random sorting
"random": {},
}
// AllFieldNames returns the names of all registered criteria fields.
@ -92,7 +104,15 @@ func AllFieldNames() []string {
// LookupField returns semantic metadata for a criteria field name.
func LookupField(name string) (FieldInfo, bool) {
f, ok := fieldMap[strings.ToLower(name)]
key := strings.ToLower(name)
f, ok := fieldMap[key]
if ok {
if f.Alias != "" {
f.name = f.Alias
} else {
f.name = key
}
}
return f, ok
}
@ -104,7 +124,7 @@ func AddRoles(roles []string) {
if _, ok := fieldMap[name]; ok {
continue
}
fieldMap[name] = FieldInfo{Name: name, IsRole: true}
fieldMap[name] = FieldInfo{IsRole: true}
}
}
@ -116,14 +136,16 @@ func AddTagNames(tagNames []string) {
if _, ok := fieldMap[name]; ok {
continue
}
for _, fm := range fieldMap {
if fm.alias == name {
for key, fm := range fieldMap {
if fm.tagAlias == name {
fm.Alias = key
fm.tagAlias = ""
fieldMap[name] = fm
break
}
}
if _, ok := fieldMap[name]; !ok {
fieldMap[name] = FieldInfo{Name: name, IsTag: true}
fieldMap[name] = FieldInfo{IsTag: true}
}
}
}
@ -136,7 +158,7 @@ func AddNumericTags(tagNames []string) {
fm.Numeric = true
fieldMap[name] = fm
} else {
fieldMap[name] = FieldInfo{Name: name, IsTag: true, Numeric: true}
fieldMap[name] = FieldInfo{IsTag: true, Numeric: true}
}
}
}

View File

@ -11,31 +11,24 @@ var _ = Describe("fields", func() {
field, ok := LookupField("Title")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field).To(gomega.Equal(FieldInfo{Name: "title"}))
gomega.Expect(field.Name()).To(gomega.Equal("title"))
})
It("resolves aliases to their semantic field name", func() {
It("resolves aliases to their canonical field name", func() {
field, ok := LookupField("albumtype")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("releasetype"))
gomega.Expect(field.Name()).To(gomega.Equal("releasetype"))
gomega.Expect(field.IsTag).To(gomega.BeTrue())
})
It("finds special fields", func() {
field, ok := LookupField("value")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("value"))
})
It("finds registered tag names", func() {
AddTagNames([]string{"task3_mood"})
field, ok := LookupField("task3_mood")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("task3_mood"))
gomega.Expect(field.Name()).To(gomega.Equal("task3_mood"))
gomega.Expect(field.IsTag).To(gomega.BeTrue())
})
@ -56,8 +49,9 @@ var _ = Describe("fields", func() {
field, ok := LookupField("task3_producer")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("task3_producer"))
gomega.Expect(field.Name()).To(gomega.Equal("task3_producer"))
gomega.Expect(field.IsRole).To(gomega.BeTrue())
})
})
})

View File

@ -3,6 +3,7 @@ package criteria
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
@ -38,6 +39,7 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
if err != nil {
return nil
}
normalizeBoolFields(m)
switch opName {
case "is":
return Is(m)
@ -69,10 +71,48 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
return InPlaylist(m)
case "notinplaylist":
return NotInPlaylist(m)
case "ismissing":
normalizeAllBoolFields(m)
return IsMissing(m)
case "ispresent":
normalizeAllBoolFields(m)
return IsPresent(m)
}
return nil
}
func normalizeAllBoolFields(m map[string]any) {
for k, v := range m {
m[k] = normalizeBoolValue(v)
}
}
func normalizeBoolFields(m map[string]any) {
for field, value := range m {
info, ok := LookupField(field)
if ok && info.Boolean {
m[field] = normalizeBoolValue(value)
}
}
}
func normalizeBoolValue(v any) any {
switch val := v.(type) {
case string:
if b, err := strconv.ParseBool(val); err == nil {
return b
}
case float64:
if val == 1 {
return true
}
if val == 0 {
return false
}
}
return v
}
func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression {
var items unmarshalConjunctionType
err := json.Unmarshal(rawValue, &items)

View File

@ -1,7 +1,5 @@
package criteria
import "time"
// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively
type conjunction interface {
ChildPlaylistIds() []string
@ -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) {
@ -162,6 +156,22 @@ func (nipl NotInPlaylist) MarshalJSON() ([]byte, error) {
func (nipl NotInPlaylist) fields() map[string]any { return nipl }
type IsMissing map[string]any
func (im IsMissing) MarshalJSON() ([]byte, error) {
return marshalExpression("isMissing", im)
}
func (im IsMissing) fields() map[string]any { return im }
type IsPresent map[string]any
func (ip IsPresent) MarshalJSON() ([]byte, error) {
return marshalExpression("isPresent", ip)
}
func (ip IsPresent) fields() map[string]any { return ip }
func extractPlaylistIds(inputRule any) (ids []string) {
var id string
var ok bool

View File

@ -31,6 +31,7 @@ var _ = Describe("Operators", func() {
},
Entry("is [string]", Is{"title": "Low Rider"}, `{"is":{"title":"Low Rider"}}`),
Entry("is [bool]", Is{"loved": false}, `{"is":{"loved":false}}`),
Entry("is [string does not coerce non-boolean field]", Is{"title": "true"}, `{"is":{"title":"true"}}`),
Entry("isNot", IsNot{"title": "Low Rider"}, `{"isNot":{"title":"Low Rider"}}`),
Entry("gt", Gt{"playCount": 10.0}, `{"gt":{"playCount":10}}`),
Entry("lt", Lt{"playCount": 10.0}, `{"lt":{"playCount":10}}`),
@ -46,5 +47,83 @@ var _ = Describe("Operators", func() {
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30.0}, `{"notInTheLast":{"lastPlayed":30}}`),
Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, `{"inPlaylist":{"id":"deadbeef-dead-beef"}}`),
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, `{"notInPlaylist":{"id":"deadbeef-dead-beef"}}`),
Entry("isMissing [true]", IsMissing{"genre": true}, `{"isMissing":{"genre":true}}`),
Entry("isMissing [false]", IsMissing{"genre": false}, `{"isMissing":{"genre":false}}`),
Entry("isPresent [true]", IsPresent{"genre": true}, `{"isPresent":{"genre":true}}`),
Entry("isPresent [false]", IsPresent{"genre": false}, `{"isPresent":{"genre":false}}`),
)
Describe("Boolean string coercion at unmarshal time (issue #4826)", func() {
It("coerces string 'true' to bool for boolean fields", func() {
var obj UnmarshalConjunctionType
err := json.Unmarshal([]byte(`[{"is":{"loved":"true"}}]`), &obj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true}))
})
It("coerces string 'false' to bool for boolean fields", func() {
var obj UnmarshalConjunctionType
err := json.Unmarshal([]byte(`[{"is":{"loved":"false"}}]`), &obj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false}))
})
It("does not coerce string values for non-boolean fields", func() {
var obj UnmarshalConjunctionType
err := json.Unmarshal([]byte(`[{"is":{"title":"true"}}]`), &obj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(obj[0]).To(gomega.Equal(Is{"title": "true"}))
})
It("coerces numeric 1 to bool true for boolean fields", func() {
var obj UnmarshalConjunctionType
err := json.Unmarshal([]byte(`[{"is":{"loved":1}}]`), &obj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true}))
})
It("coerces numeric 0 to bool false for boolean fields", func() {
var obj UnmarshalConjunctionType
err := json.Unmarshal([]byte(`[{"is":{"loved":0}}]`), &obj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false}))
})
It("coerces in nested any/all groups", func() {
var c Criteria
err := json.Unmarshal([]byte(`{"all":[{"contains":{"title":"love"}},{"any":[{"is":{"loved":"true"}}]}]}`), &c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
all := c.Expression.(All)
nested := all[1].(Any)
gomega.Expect(nested[0]).To(gomega.Equal(Is{"loved": true}))
})
It("coerces isMissing string 'true' to bool", func() {
var obj UnmarshalConjunctionType
err := json.Unmarshal([]byte(`[{"isMissing":{"genre":"true"}}]`), &obj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": true}))
})
It("coerces isMissing numeric 0 to bool false", func() {
var obj UnmarshalConjunctionType
err := json.Unmarshal([]byte(`[{"isMissing":{"genre":0}}]`), &obj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": false}))
})
It("coerces isPresent string 'false' to bool", func() {
var obj UnmarshalConjunctionType
err := json.Unmarshal([]byte(`[{"isPresent":{"genre":"false"}}]`), &obj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": false}))
})
It("coerces isPresent numeric 1 to bool true", func() {
var obj UnmarshalConjunctionType
err := json.Unmarshal([]byte(`[{"isPresent":{"genre":1}}]`), &obj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": true}))
})
})
})

View File

@ -43,7 +43,7 @@ func (c Criteria) OrderByFields() []SortField {
if order == "desc" {
desc = !desc
}
fields = append(fields, SortField{Field: info.Name, Desc: desc})
fields = append(fields, SortField{Field: info.Name(), Desc: desc})
}
if len(fields) == 0 {
log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue)

View File

@ -24,7 +24,7 @@ func Walk(expr Expression, visit Visitor) error {
return err
}
}
case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist:
case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist, IsMissing, IsPresent:
return nil
default:
return fmt.Errorf("unknown criteria expression type %T", expr)

View File

@ -13,5 +13,5 @@ func UploadedImagePath(entityType, filename string) string {
if filename == "" {
return ""
}
return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, entityType, filename)
return filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, entityType, filename)
}

View File

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

View File

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

View File

@ -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() {

View File

@ -8,7 +8,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -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),
)

View File

@ -26,7 +26,7 @@ var _ = Describe("Radio", func() {
Describe("UploadedImagePath", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = "/data"
conf.Server.DataFolder = conf.NewDir("/data")
})
It("returns empty string when no image uploaded", func() {

View File

@ -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

View 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"}))
})
})
})

View File

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

View File

@ -840,7 +840,7 @@ var _ = Describe("ArtistRepository", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
conf.Server.DataFolder = tmpDir
conf.Server.DataFolder = conf.NewDir(tmpDir)
ctx := request.WithUser(GinkgoT().Context(), adminUser)
repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository)

View File

@ -3,7 +3,9 @@ package persistence
import (
"errors"
"fmt"
"maps"
"reflect"
"slices"
"strconv"
"strings"
"time"
@ -111,9 +113,12 @@ var smartPlaylistFields = map[string]smartPlaylistField{
"mbz_recording_id": {expr: "media_file.mbz_recording_id"},
"mbz_release_track_id": {expr: "media_file.mbz_release_track_id"},
"mbz_release_group_id": {expr: "media_file.mbz_release_group_id"},
"rgalbumgain": {expr: "media_file.rg_album_gain"},
"rgalbumpeak": {expr: "media_file.rg_album_peak"},
"rgtrackgain": {expr: "media_file.rg_track_gain"},
"rgtrackpeak": {expr: "media_file.rg_track_peak"},
"library_id": {expr: "media_file.library_id"},
"random": {order: "random()"},
"value": {expr: "value"},
}
func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) {
@ -144,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)
@ -185,6 +190,10 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz
return c.inList(e, false)
case criteria.NotInPlaylist:
return c.inList(e, true)
case criteria.IsMissing:
return missingExpr(e, true)
case criteria.IsPresent:
return missingExpr(e, false)
default:
return nil, fmt.Errorf("unknown criteria expression type %T", expr)
}
@ -201,6 +210,26 @@ func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) {
return squirrel.NotEq(fields), nil
}
func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) {
field, value, info, ok := singleField(values)
if !ok {
if len(values) != 1 {
return nil, fmt.Errorf("invalid field in criteria: isMissing/isPresent requires exactly one field")
}
return nil, fmt.Errorf("invalid field in criteria: %s", field)
}
if !info.IsTag && !info.IsRole {
return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field)
}
b, ok := value.(bool)
if !ok {
return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value)
}
negate := checkAbsence == b
return jsonExpr(info, nil, negate), nil
}
func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) {
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil
@ -314,9 +343,9 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir
func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
if info.IsRole {
return roleCond{role: info.Name, cond: cond, not: negate}
return roleCond{role: info.Name(), cond: cond, not: negate}
}
return tagCond{tag: info.Name, numeric: info.Numeric, cond: cond, not: negate}
return tagCond{tag: info.Name(), numeric: info.Numeric, cond: cond, not: negate}
}
type tagCond struct {
@ -327,11 +356,18 @@ type tagCond struct {
}
func (e tagCond) ToSql() (string, []any, error) {
cond, args, err := e.cond.ToSql()
if e.numeric {
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
var cond string
var args []any
var err error
if e.cond != nil {
cond, args, err = e.cond.ToSql()
if e.numeric {
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
}
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond)
} else {
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value')", e.tag)
}
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond)
if e.not {
cond = "not " + cond
}
@ -345,12 +381,175 @@ type roleCond struct {
}
func (e roleCond) ToSql() (string, []any, error) {
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)
var cond string
var args []any
if e.cond != nil {
innerSQL, innerArgs, err := roleCondSQL(e.cond)
if err != nil {
return "", nil, err
}
cond = roleExistsSQL(innerSQL)
args = append([]any{e.role}, innerArgs...)
} else {
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) {
@ -374,7 +573,7 @@ func sqlFields(values map[string]any) (map[string]any, error) {
if info.IsTag || info.IsRole {
return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field)
}
sqlField, ok := fieldExpr(info.Name)
sqlField, ok := fieldExpr(info.Name())
if !ok || sqlField == "" {
return nil, fmt.Errorf("invalid field in criteria: %s", field)
}
@ -393,7 +592,7 @@ func fieldJoinType(name string) smartPlaylistJoinType {
if !ok {
return smartPlaylistJoinNone
}
field, ok := smartPlaylistFields[info.Name]
field, ok := smartPlaylistFields[info.Name()]
if !ok {
return smartPlaylistJoinNone
}
@ -441,17 +640,17 @@ func sortExpr(sortField string) (string, bool) {
if !ok {
return "", false
}
if field, ok := smartPlaylistFields[info.Name]; ok && field.order != "" {
if field, ok := smartPlaylistFields[info.Name()]; ok && field.order != "" {
return field.order, true
}
var mapped string
switch {
case info.IsTag:
mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name + "[0].value'), '')"
mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name() + "[0].value'), '')"
case info.IsRole:
mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name + "[0].name'), '')"
mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name() + "[0].name'), '')"
default:
field, ok := smartPlaylistFields[info.Name]
field, ok := smartPlaylistFields[info.Name()]
if !ok || field.expr == "" {
return "", false
}

View File

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

View File

@ -1,6 +1,8 @@
package persistence
import (
"fmt"
"strings"
"time"
"github.com/navidrome/navidrome/model"
@ -56,9 +58,33 @@ 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),
Entry("rgTrackPeak lt", criteria.Lt{"rgTrackPeak": 1.0}, "media_file.rg_track_peak < ?", 1.0),
// isMissing — tags
Entry("isMissing tag [true]", criteria.IsMissing{"genre": true},
"not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
Entry("isMissing tag [false]", criteria.IsMissing{"genre": false},
"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 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 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')"),
Entry("isPresent tag [false]", criteria.IsPresent{"genre": false},
"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 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 media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"),
)
Describe("playlist permissions", func() {
@ -115,6 +141,21 @@ var _ = Describe("Smart playlist criteria SQL", func() {
Expect(err).To(MatchError("invalid field in criteria: unknown"))
})
It("returns an error when isMissing is used with a regular field", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where()
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields")))
})
It("returns an error when isPresent is used with a regular field", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where()
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields")))
})
It("returns an error when isMissing has a non-boolean value", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).Where()
Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression")))
})
Describe("sort", func() {
It("sorts by regular fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc"))
@ -160,11 +201,151 @@ var _ = Describe("Smart playlist criteria SQL", func() {
if info.IsTag || info.IsRole {
continue
}
_, hasSQLField := smartPlaylistFields[info.Name]
Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name)
_, hasSQLField := smartPlaylistFields[info.Name()]
Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name())
}
})
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"}

Some files were not shown because too many files have changed in this diff Show More