mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge branch 'master' into update-spanish-translations
This commit is contained in:
commit
1915ca4b5f
9
.github/actions/prepare-docker/action.yml
vendored
9
.github/actions/prepare-docker/action.yml
vendored
@ -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: |
|
||||
|
||||
13
.github/workflows/download-link-on-pr.yml
vendored
13
.github/workflows/download-link-on-pr.yml
vendored
@ -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});
|
||||
}
|
||||
|
||||
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -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
3
.gitignore
vendored
@ -37,5 +37,6 @@ AGENTS.md
|
||||
*.wasm
|
||||
*.ndp
|
||||
openspec/
|
||||
.agents
|
||||
go.work*
|
||||
.worktrees/
|
||||
.worktrees/
|
||||
|
||||
@ -164,6 +164,7 @@ RUN touch /.nddockerenv
|
||||
|
||||
EXPOSE ${ND_PORT}
|
||||
WORKDIR /app
|
||||
ENV PATH="/app:${PATH}"
|
||||
|
||||
ENTRYPOINT ["/app/navidrome"]
|
||||
|
||||
|
||||
2
Makefile
2
Makefile
@ -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/*")
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
bytes "bytes"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
@ -8,7 +8,6 @@ import (
|
||||
"github.com/djherbis/times"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -91,8 +90,7 @@ var _ = Describe("Extractor", func() {
|
||||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||
|
||||
metadata := metadata.New(path, info)
|
||||
mf := metadata.ToMediaFile(1, "folderID")
|
||||
return &mf
|
||||
return new(metadata.ToMediaFile(1, "folderID"))
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
@ -109,7 +107,7 @@ var _ = Describe("Extractor", func() {
|
||||
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
|
||||
},
|
||||
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
|
||||
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
|
||||
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", new(0.0), new(1.0), new(0.0), new(1.0)),
|
||||
)
|
||||
})
|
||||
|
||||
@ -120,8 +118,8 @@ var _ = Describe("Extractor", func() {
|
||||
DisplayTitle: "",
|
||||
Lang: code,
|
||||
Line: []model.Line{
|
||||
{Start: gg.P(int64(0)), Value: "This is"},
|
||||
{Start: gg.P(int64(2500)), Value: secondLine},
|
||||
{Start: new(int64(0)), Value: "This is"},
|
||||
{Start: new(int64(2500)), Value: secondLine},
|
||||
},
|
||||
Offset: nil,
|
||||
Synced: true,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -77,6 +77,13 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
resp["status"] = key != ""
|
||||
linkToken, err := createLinkToken(u.ID)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Could not create LastFM link token", "userId", u.ID, err)
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
resp["linkToken"] = linkToken
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@ -97,11 +104,17 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
|
||||
return
|
||||
}
|
||||
uid, err := p.String("uid")
|
||||
linkToken, err := p.String("uid")
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
|
||||
return
|
||||
}
|
||||
uid, err := verifyLinkToken(linkToken)
|
||||
if err != nil {
|
||||
log.Warn(r.Context(), "Rejected LastFM callback with invalid link token", "requestId", middleware.GetReqID(r.Context()), err)
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "invalid link token")
|
||||
return
|
||||
}
|
||||
|
||||
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
|
||||
// automatically contain any user info
|
||||
|
||||
218
adapters/lastfm/auth_router_test.go
Normal file
218
adapters/lastfm/auth_router_test.go
Normal file
@ -0,0 +1,218 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("auth_router", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
userProps *tests.MockedUserPropsRepo
|
||||
httpClient *tests.FakeHttpClient
|
||||
router *Router
|
||||
)
|
||||
|
||||
const (
|
||||
victimID = "victim-user-id"
|
||||
attackerID = "attacker-user-id"
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
userProps = &tests.MockedUserPropsRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
MockedUserProps: userProps,
|
||||
}
|
||||
auth.Init(ds)
|
||||
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
router = &Router{
|
||||
ds: ds,
|
||||
apiKey: "API_KEY",
|
||||
secret: "SECRET",
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
router.client = newClient(router.apiKey, router.secret, httpClient)
|
||||
router.Handler = router.routes()
|
||||
})
|
||||
|
||||
storedSessionKey := func(userID string) string {
|
||||
key, _ := userProps.Get(userID, sessionKeyProperty)
|
||||
return key
|
||||
}
|
||||
|
||||
stubGetSessionOK := func(sessionKey string) {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
}
|
||||
|
||||
Describe("getLinkStatus", func() {
|
||||
It("includes a signed linkToken for the authenticated user", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/link", nil)
|
||||
ctx := request.WithUser(req.Context(), model.User{ID: victimID})
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.getLinkStatus(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
var body map[string]any
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed())
|
||||
Expect(body["apiKey"]).To(Equal("API_KEY"))
|
||||
Expect(body["status"]).To(Equal(false))
|
||||
token, ok := body["linkToken"].(string)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(token).ToNot(BeEmpty())
|
||||
|
||||
verified, err := verifyLinkToken(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(verified).To(Equal(victimID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("callback", func() {
|
||||
It("stores the session key under the user encoded in the signed token", func() {
|
||||
stubGetSessionOK("LEGIT_SESSION")
|
||||
linkToken, err := createLinkToken(victimID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION"))
|
||||
})
|
||||
|
||||
It("rejects a raw (unsigned) uid value", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest).To(BeNil())
|
||||
})
|
||||
|
||||
It("rejects an expired link token", func() {
|
||||
expiredToken, err := auth.EncodeToken(map[string]any{
|
||||
"uid": victimID,
|
||||
"scope": linkTokenScope,
|
||||
"exp": time.Now().Add(-1 * time.Minute).UTC().Unix(),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest).To(BeNil())
|
||||
})
|
||||
|
||||
It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() {
|
||||
sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(storedSessionKey(attackerID)).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest).To(BeNil())
|
||||
})
|
||||
|
||||
It("writes only under the user encoded in the token, regardless of query manipulation", func() {
|
||||
// An attacker holds a legitimate link token for their own account.
|
||||
// They attempt to call the callback hoping to overwrite the victim's
|
||||
// session key — but the handler must derive the user ID from the
|
||||
// signed token, not from any other input.
|
||||
stubGetSessionOK("ATTACKER_SESSION")
|
||||
attackerToken, err := createLinkToken(attackerID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION"))
|
||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns 400 when uid is missing", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns 400 when token is missing", func() {
|
||||
linkToken, err := createLinkToken(victimID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("link token helpers", func() {
|
||||
It("round-trips a freshly issued token", func() {
|
||||
token, err := createLinkToken(victimID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
uid, err := verifyLinkToken(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(uid).To(Equal(victimID))
|
||||
})
|
||||
|
||||
It("rejects garbage", func() {
|
||||
_, err := verifyLinkToken("not-a-jwt")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects a token whose scope claim is wrong", func() {
|
||||
wrongScopeToken, err := auth.EncodeToken(map[string]any{
|
||||
"uid": victimID,
|
||||
"scope": "some-other-scope",
|
||||
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = verifyLinkToken(wrongScopeToken)
|
||||
Expect(err).To(MatchError("invalid link token scope"))
|
||||
})
|
||||
|
||||
It("rejects a scoped token that has no expiration", func() {
|
||||
nonExpiringToken, err := auth.EncodeToken(map[string]any{
|
||||
"uid": victimID,
|
||||
"scope": linkTokenScope,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = verifyLinkToken(nonExpiringToken)
|
||||
Expect(err).To(MatchError("link token missing expiration"))
|
||||
})
|
||||
})
|
||||
})
|
||||
50
adapters/lastfm/link_token.go
Normal file
50
adapters/lastfm/link_token.go
Normal file
@ -0,0 +1,50 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
linkTokenScope = "lastfm-link"
|
||||
linkTokenTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
// createLinkToken issues a signed token binding the Last.fm callback to the
|
||||
// user who initiated the OAuth flow. It travels back through Last.fm via the
|
||||
// `cb` URL in place of the previously-trusted raw `uid` query parameter.
|
||||
func createLinkToken(userID string) (string, error) {
|
||||
claims := map[string]any{
|
||||
"uid": userID,
|
||||
"scope": linkTokenScope,
|
||||
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
|
||||
}
|
||||
return auth.EncodeToken(claims)
|
||||
}
|
||||
|
||||
// verifyLinkToken validates a signed link token and returns the encoded user ID.
|
||||
// It enforces both the signature/expiry (via the underlying JWT verifier) and a
|
||||
// dedicated scope claim, preventing tokens minted for other purposes (e.g. a
|
||||
// regular session JWT) from being accepted here.
|
||||
func verifyLinkToken(tokenStr string) (string, error) {
|
||||
token, err := auth.DecodeAndVerifyToken(tokenStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// jwtauth treats a token without `exp` as non-expiring; require it
|
||||
// explicitly so an accidental regression cannot mint permanent tokens.
|
||||
if exp, ok := token.Expiration(); !ok || exp.IsZero() {
|
||||
return "", errors.New("link token missing expiration")
|
||||
}
|
||||
var scope string
|
||||
if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope {
|
||||
return "", errors.New("invalid link token scope")
|
||||
}
|
||||
var uid string
|
||||
if err := token.Get("uid", &uid); err != nil || uid == "" {
|
||||
return "", errors.New("invalid link token user ID")
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
77
conf/dir.go
Normal 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
164
conf/dir_test.go
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@ -60,7 +61,15 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
||||
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
||||
for _, mf := range album {
|
||||
file := a.albumFilename(mf, format, isMultiDisc)
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Stop iterating: continuing would just rack up more
|
||||
// rejections from the limiter. Close finalises whatever
|
||||
// tracks were already written; the rejected one is not
|
||||
// present in the archive (addFileToZip aborts before
|
||||
// writing its entry header).
|
||||
_ = z.Close()
|
||||
return addErr
|
||||
}
|
||||
}
|
||||
}
|
||||
err = z.Close()
|
||||
@ -120,7 +129,12 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||
for idx, mf := range mfs {
|
||||
file := a.playlistFilename(mf, format, idx)
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Abort the whole archive: continuing would silently emit
|
||||
// empty zip entries since the headers are already written.
|
||||
_ = z.Close()
|
||||
return addErr
|
||||
}
|
||||
mf.Path = file
|
||||
zippedMfs[idx] = mf
|
||||
}
|
||||
@ -162,6 +176,27 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
|
||||
|
||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
||||
path := mf.AbsolutePath()
|
||||
|
||||
// Open the source before writing the zip entry header so a rejection
|
||||
// (limiter, missing file, etc.) does not leave an empty entry in the
|
||||
// archive.
|
||||
var r io.ReadCloser
|
||||
var err error
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
w, err := z.CreateHeader(&zip.FileHeader{
|
||||
Name: filename,
|
||||
Modified: mf.UpdatedAt,
|
||||
@ -172,23 +207,6 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||
return err
|
||||
}
|
||||
|
||||
var r io.ReadCloser
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(w, r)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error zipping file", "file", path, err)
|
||||
|
||||
@ -89,6 +89,32 @@ var _ = Describe("Archiver", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("when the transcode limiter rejects a file", func() {
|
||||
It("aborts the archive instead of continuing with empty entries", func() {
|
||||
mfs := model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
||||
}
|
||||
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{
|
||||
Filters: squirrel.Eq{"album_id": "1"},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
||||
Return(nil, stream.ErrTooManyTranscodes).Once()
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
||||
Expect(err).To(MatchError(stream.ErrTooManyTranscodes))
|
||||
// NewStream should only have been called once: the loop must bail
|
||||
// out on the rejection instead of trying every remaining track.
|
||||
ms.AssertNumberOfCalls(GinkgoT(), "NewStream", 1)
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipShare", func() {
|
||||
It("zips a share correctly", func() {
|
||||
mfs := model.MediaFiles{
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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/
|
||||
|
||||
@ -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/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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{
|
||||
{
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -21,8 +21,7 @@ func TestAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
const (
|
||||
testJWTSecret = "not so secret"
|
||||
oneDay = 24 * time.Hour
|
||||
oneDay = 24 * time.Hour
|
||||
)
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
|
||||
4
core/external/provider.go
vendored
4
core/external/provider.go
vendored
@ -153,7 +153,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
|
||||
return album, err
|
||||
}
|
||||
|
||||
album.ExternalInfoUpdatedAt = P(time.Now())
|
||||
album.ExternalInfoUpdatedAt = new(time.Now())
|
||||
album.ExternalUrl = info.URL
|
||||
|
||||
if info.Description != "" {
|
||||
@ -269,7 +269,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
||||
return artist, ctx.Err()
|
||||
}
|
||||
|
||||
artist.ExternalInfoUpdatedAt = P(time.Now())
|
||||
artist.ExternalInfoUpdatedAt = new(time.Now())
|
||||
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
||||
|
||||
6
core/external/provider_artistimage_test.go
vendored
6
core/external/provider_artistimage_test.go
vendored
@ -272,12 +272,11 @@ var _ = Describe("Provider - ArtistImage", func() {
|
||||
|
||||
It("returns cached URL and does not call agent when info is not expired", func() {
|
||||
// Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt
|
||||
recentTime := time.Now().Add(-1 * time.Minute)
|
||||
cachedArtist := &model.Artist{
|
||||
ID: "artist-cached",
|
||||
Name: "Cached Artist",
|
||||
LargeImageUrl: "http://example.com/cached-large.jpg",
|
||||
ExternalInfoUpdatedAt: &recentTime,
|
||||
ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)),
|
||||
}
|
||||
mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe()
|
||||
expectedURL, _ := url.Parse("http://example.com/cached-large.jpg")
|
||||
@ -304,12 +303,11 @@ var _ = Describe("Provider - ArtistImage", func() {
|
||||
It("returns stale URL and enqueues refresh when info is expired", func() {
|
||||
// Arrange
|
||||
conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond
|
||||
expiredTime := time.Now().Add(-1 * time.Hour)
|
||||
staleArtist := &model.Artist{
|
||||
ID: "artist-expired",
|
||||
Name: "Expired Artist",
|
||||
LargeImageUrl: "http://example.com/expired-large.jpg",
|
||||
ExternalInfoUpdatedAt: &expiredTime,
|
||||
ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)),
|
||||
}
|
||||
mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe()
|
||||
expectedURL, _ := url.Parse("http://example.com/expired-large.jpg")
|
||||
|
||||
@ -12,7 +12,6 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@ -90,7 +89,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||
ExternalUrl: "http://cached.com/album",
|
||||
Description: "Cached Desc",
|
||||
LargeImageUrl: "http://cached.com/large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
|
||||
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
@ -113,7 +112,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||
ExternalUrl: "http://expired.com/album",
|
||||
Description: "Expired Desc",
|
||||
LargeImageUrl: "http://expired.com/large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||
ExternalInfoUpdatedAt: new(expiredTime),
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
|
||||
@ -13,7 +13,6 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@ -137,7 +136,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
ExternalUrl: "http://cached.url",
|
||||
Biography: "Cached Bio",
|
||||
LargeImageUrl: "http://cached_large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-similar-present", Name: "Similar Present"},
|
||||
{ID: "ar-similar-absent", Name: "Similar Absent"},
|
||||
@ -174,7 +173,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-expired",
|
||||
Name: "Expired Artist",
|
||||
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||
ExternalInfoUpdatedAt: new(expiredTime),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-exp-similar", Name: "Expired Similar"},
|
||||
},
|
||||
@ -205,7 +204,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-similar-test",
|
||||
Name: "Similar Test Artist",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-sim-present", Name: "Similar Present"},
|
||||
{ID: "", Name: "Similar Absent Raw"},
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -7,7 +7,6 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
)
|
||||
|
||||
type InspectOutput struct {
|
||||
@ -44,7 +43,7 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e
|
||||
result := &InspectOutput{
|
||||
File: filePath,
|
||||
RawTags: tags[file].Tags,
|
||||
MappedTags: P(md.ToMediaFile(libraryId, folderId)),
|
||||
MappedTags: new(md.ToMediaFile(libraryId, folderId)),
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
@ -12,7 +12,6 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -32,15 +31,15 @@ var _ = Describe("sources", func() {
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: gg.P(int64(18800)),
|
||||
Start: new(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: gg.P(int64(22801)),
|
||||
Start: new(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: gg.P(int64(-100)),
|
||||
Offset: new(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -74,15 +73,15 @@ var _ = Describe("sources", func() {
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: gg.P(int64(18800)),
|
||||
Start: new(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: gg.P(int64(22801)),
|
||||
Start: new(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: gg.P(int64(-100)),
|
||||
Offset: new(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}))
|
||||
@ -122,7 +121,7 @@ var _ = Describe("sources", func() {
|
||||
// The critical assertion: even with BOM, synced should be true
|
||||
Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced")
|
||||
Expect(lyrics[0].Line).To(HaveLen(1))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0))))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0))))
|
||||
Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲"))
|
||||
})
|
||||
|
||||
@ -137,9 +136,9 @@ var _ = Describe("sources", func() {
|
||||
// UTF-16 should be properly converted to UTF-8
|
||||
Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced")
|
||||
Expect(lyrics[0].Line).To(HaveLen(2))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800))))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800))))
|
||||
Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love"))
|
||||
Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801))))
|
||||
Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801))))
|
||||
Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I"))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -62,8 +62,7 @@ func (j *Executor) start(ctx context.Context) error {
|
||||
|
||||
func (j *Executor) wait() {
|
||||
if err := j.cmd.Wait(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
|
||||
} else {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
|
||||
|
||||
@ -206,7 +206,7 @@ func (t *MpvTrack) IsPlaying() bool {
|
||||
func waitForSocket(path string, timeout time.Duration, pause time.Duration) error {
|
||||
start := time.Now()
|
||||
end := start.Add(timeout)
|
||||
var retries int = 0
|
||||
var retries = 0
|
||||
|
||||
for {
|
||||
fileInfo, err := os.Stat(path)
|
||||
|
||||
@ -59,8 +59,7 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R
|
||||
}
|
||||
err = json.Unmarshal(input, nsp)
|
||||
if err != nil {
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
if syntaxErr, ok := errors.AsType[*json.SyntaxError](err); ok {
|
||||
line, col := getPositionFromOffset(input, syntaxErr.Offset)
|
||||
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
|
||||
}
|
||||
|
||||
@ -144,29 +144,25 @@ var _ = Describe("Playlists", func() {
|
||||
|
||||
It("allows owner to update their playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
|
||||
err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows admin to update any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil)
|
||||
err := ps.Update(ctx, "pls-other", new("Updated Name"), nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin from updating", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
|
||||
err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil)
|
||||
err := ps.Update(ctx, "nonexistent", new("Updated Name"), nil, nil, nil, nil)
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
@ -184,8 +180,7 @@ var _ = Describe("Playlists", func() {
|
||||
|
||||
It("allows metadata updates on a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Smart"
|
||||
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
|
||||
err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
@ -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")
|
||||
|
||||
@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
// --- REST adapter (follows Share/Library pattern) ---
|
||||
@ -34,8 +36,8 @@ func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
|
||||
return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Update(id string, entity any, _ ...string) error {
|
||||
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist))
|
||||
func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
|
||||
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Delete(id string) error {
|
||||
@ -79,7 +81,15 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri
|
||||
|
||||
// updatePlaylistEntity updates playlist metadata with permission checks.
|
||||
// Used by the REST API wrapper.
|
||||
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error {
|
||||
//
|
||||
// cols names the fields the client actually sent in the JSON body (extracted by
|
||||
// rest.Put). When non-empty, fields outside cols are not considered changed and
|
||||
// are left untouched — this prevents partial requests like bulk "Make Public"
|
||||
// (body: {"public": true}) from wiping fields that just happen to be zero in
|
||||
// the deserialized entity (see issue #5541). An empty cols means "treat the
|
||||
// entity as a complete record" — preserved for callers that don't use the REST
|
||||
// wrapper.
|
||||
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
|
||||
current, err := s.checkWritable(ctx, id)
|
||||
if err != nil {
|
||||
switch {
|
||||
@ -91,41 +101,92 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
sent := sentFields(cols)
|
||||
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
|
||||
ownerChanged := sent("ownerId") && entity.OwnerID != "" && entity.OwnerID != current.OwnerID
|
||||
if !usr.IsAdmin && ownerChanged {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
contentChanged := entity.Name != current.Name ||
|
||||
entity.Comment != current.Comment ||
|
||||
(entity.OwnerID != "" && entity.OwnerID != current.OwnerID) ||
|
||||
!rulesEqual(current.Rules, entity.Rules)
|
||||
nameChanged := sent("name") && entity.Name != current.Name
|
||||
commentChanged := sent("comment") && entity.Comment != current.Comment
|
||||
rulesChanged := sent("rules") && !rulesEqual(current.Rules, entity.Rules)
|
||||
|
||||
if contentChanged {
|
||||
if entity.OwnerID != "" {
|
||||
current.OwnerID = entity.OwnerID
|
||||
}
|
||||
if nameChanged || commentChanged || ownerChanged || rulesChanged {
|
||||
return s.applyContentUpdate(ctx, current, entity, sent,
|
||||
nameChanged, commentChanged, ownerChanged, rulesChanged)
|
||||
}
|
||||
return s.applyFlagsOnly(ctx, current, entity, sent)
|
||||
}
|
||||
|
||||
// applyContentUpdate handles updates that change at least one of name/comment/
|
||||
// owner/rules. It goes through updateMetadata, which always bumps updatedAt
|
||||
// (invalidating cached cover-art URLs). namePtr/commentPtr are nil when the
|
||||
// field is absent from the request OR present-but-unchanged (so updateMetadata
|
||||
// skips them); publicPtr is nil only when public is absent from the request
|
||||
// (an idempotent public value is still forwarded).
|
||||
func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *model.Playlist,
|
||||
sent func(string) bool, nameChanged, commentChanged, ownerChanged, rulesChanged bool,
|
||||
) error {
|
||||
if ownerChanged {
|
||||
current.OwnerID = entity.OwnerID
|
||||
}
|
||||
if rulesChanged {
|
||||
current.Rules = entity.Rules
|
||||
if current.Path != "" && current.Sync != entity.Sync {
|
||||
current.Sync = entity.Sync
|
||||
}
|
||||
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
|
||||
}
|
||||
|
||||
// Only sync/public changed — skip updatedAt so cover art URLs stay stable
|
||||
var cols []string
|
||||
if current.Path != "" && current.Sync != entity.Sync {
|
||||
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
|
||||
current.Sync = entity.Sync
|
||||
cols = append(cols, "sync")
|
||||
}
|
||||
if current.Public != entity.Public {
|
||||
var namePtr, commentPtr *string
|
||||
var publicPtr *bool
|
||||
if nameChanged {
|
||||
namePtr = &entity.Name
|
||||
}
|
||||
if commentChanged {
|
||||
commentPtr = &entity.Comment
|
||||
}
|
||||
if sent("public") {
|
||||
publicPtr = &entity.Public
|
||||
}
|
||||
return s.updateMetadata(ctx, s.ds, current, namePtr, commentPtr, publicPtr)
|
||||
}
|
||||
|
||||
// applyFlagsOnly handles updates that only toggle sync/public — skips
|
||||
// updatedAt so cover art URLs stay stable.
|
||||
func (s *playlists) applyFlagsOnly(ctx context.Context, current, entity *model.Playlist,
|
||||
sent func(string) bool,
|
||||
) error {
|
||||
var updateCols []string
|
||||
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
|
||||
current.Sync = entity.Sync
|
||||
updateCols = append(updateCols, "sync")
|
||||
}
|
||||
if sent("public") && current.Public != entity.Public {
|
||||
current.Public = entity.Public
|
||||
cols = append(cols, "public")
|
||||
updateCols = append(updateCols, "public")
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
if len(updateCols) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(current, cols...)
|
||||
return s.ds.Playlist(ctx).Put(current, updateCols...)
|
||||
}
|
||||
|
||||
// sentFields returns a predicate that reports whether a JSON field was present
|
||||
// in the request body. Matching is case-insensitive to mirror Go's json
|
||||
// decoder, which populates struct fields from case-variant keys like
|
||||
// {"Name":"x"} or {"OWNERID":"y"}. An empty cols list means "treat the entity
|
||||
// as a full record" — every field is considered sent.
|
||||
func sentFields(cols []string) func(string) bool {
|
||||
if len(cols) == 0 {
|
||||
return func(string) bool { return true }
|
||||
}
|
||||
set := slice.ToMap(cols, func(c string) (string, struct{}) { return strings.ToLower(c), struct{}{} })
|
||||
return func(field string) bool {
|
||||
_, ok := set[strings.ToLower(field)]
|
||||
return ok
|
||||
}
|
||||
}
|
||||
|
||||
func rulesEqual(a, b *criteria.Criteria) bool {
|
||||
|
||||
@ -63,7 +63,6 @@ var _ = Describe("REST Adapter", func() {
|
||||
It("clears server-managed fields to prevent injection via REST API", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
now := time.Now()
|
||||
pls := &model.Playlist{
|
||||
Name: "Legit Playlist",
|
||||
Comment: "A comment",
|
||||
@ -73,7 +72,7 @@ var _ = Describe("REST Adapter", func() {
|
||||
Sync: true,
|
||||
UploadedImage: "injected-image-path",
|
||||
ExternalImageURL: "http://evil.example.com/ssrf",
|
||||
EvaluatedAt: &now,
|
||||
EvaluatedAt: new(time.Now()),
|
||||
}
|
||||
_, err := repo.Save(pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -126,6 +125,25 @@ var _ = Describe("REST Adapter", func() {
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
DescribeTable("denies regular user from changing ownership under any case-variant JSON key",
|
||||
func(colName string) {
|
||||
// rest.Put's field-name extraction is case-sensitive, but Go's
|
||||
// json decoder is case-insensitive on struct fields, so any
|
||||
// {"OwnerId":"x"} / {"OWNERID":"x"} / {"ownerid":"x"} populates
|
||||
// entity.OwnerID. sentFields normalizes both sides so the
|
||||
// permission gate fires regardless of casing.
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{OwnerID: "other-user"}
|
||||
err := repo.Update("pls-1", pls, colName)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
},
|
||||
Entry("canonical camelCase", "ownerId"),
|
||||
Entry("PascalCase", "OwnerId"),
|
||||
Entry("all upper", "OWNERID"),
|
||||
Entry("all lower", "ownerid"),
|
||||
)
|
||||
|
||||
It("updates smart playlist rules", func() {
|
||||
mockPlsRepo.Data["smart-1"] = &model.Playlist{
|
||||
ID: "smart-1",
|
||||
@ -219,6 +237,156 @@ var _ = Describe("REST Adapter", func() {
|
||||
err := repo.Update("nonexistent", pls)
|
||||
Expect(err).To(Equal(rest.ErrNotFound))
|
||||
})
|
||||
|
||||
// Regression tests for #5541: partial REST updates (e.g. bulk "Make Public")
|
||||
// must only touch the fields the client actually sent. The cols list from
|
||||
// rest.Put names those fields; fields outside it must be left alone, even
|
||||
// when the deserialized entity has zero values for them.
|
||||
Context("with partial updates (cols)", func() {
|
||||
BeforeEach(func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
mockPlsRepo.Data["partial"] = &model.Playlist{
|
||||
ID: "partial",
|
||||
Name: "Original Name",
|
||||
Comment: "Original comment",
|
||||
OwnerID: "user-1",
|
||||
Public: false,
|
||||
}
|
||||
})
|
||||
|
||||
It("preserves name and comment when only public is sent (bulk Make Public)", func() {
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Public: true}, "public")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Original Name"))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
|
||||
Expect(mockPlsRepo.Last.Public).To(BeTrue())
|
||||
})
|
||||
|
||||
It("preserves name when only sync is sent for a file-backed playlist", func() {
|
||||
mockPlsRepo.Data["file-partial"] = &model.Playlist{
|
||||
ID: "file-partial",
|
||||
Name: "Keep Me",
|
||||
OwnerID: "user-1",
|
||||
Path: "/music/p.m3u",
|
||||
Sync: true,
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("file-partial", &model.Playlist{Sync: false}, "sync")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Keep Me"))
|
||||
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
|
||||
})
|
||||
|
||||
It("renames the playlist when only name is sent", func() {
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "name")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Renamed"))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
|
||||
Expect(mockPlsRepo.Last.Public).To(BeFalse())
|
||||
})
|
||||
|
||||
It("clears the comment when an empty comment is sent explicitly", func() {
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Comment: ""}, "comment")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Comment).To(BeEmpty())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Original Name"))
|
||||
})
|
||||
|
||||
It("updates rules-only on a smart playlist (Feishin-style edit)", func() {
|
||||
mockPlsRepo.Data["smart-partial"] = &model.Playlist{
|
||||
ID: "smart-partial",
|
||||
Name: "Smart Original",
|
||||
Comment: "smart comment",
|
||||
OwnerID: "user-1",
|
||||
Public: true,
|
||||
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"}
|
||||
err := repo.Update("smart-partial", &model.Playlist{Rules: newRules}, "rules")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Original"))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment"))
|
||||
Expect(mockPlsRepo.Last.Public).To(BeTrue())
|
||||
})
|
||||
|
||||
It("updates name and rules together (smart-playlist Edit form)", func() {
|
||||
mockPlsRepo.Data["smart-edit"] = &model.Playlist{
|
||||
ID: "smart-edit",
|
||||
Name: "Smart Original",
|
||||
Comment: "smart comment",
|
||||
OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"}
|
||||
err := repo.Update("smart-edit",
|
||||
&model.Playlist{Name: "Smart Renamed", Rules: newRules},
|
||||
"name", "rules")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Renamed"))
|
||||
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment"))
|
||||
})
|
||||
|
||||
It("does not bump the saved rules on an idempotent rules-only PUT", func() {
|
||||
rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
|
||||
mockPlsRepo.Data["smart-idempotent"] = &model.Playlist{
|
||||
ID: "smart-idempotent",
|
||||
Name: "Smart Idempotent",
|
||||
OwnerID: "user-1",
|
||||
Rules: rules,
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
// Same rules sent back — rulesEqual should report no change and
|
||||
// the request should no-op (no Put call).
|
||||
sameRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
|
||||
err := repo.Update("smart-idempotent", &model.Playlist{Rules: sameRules}, "rules")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).To(BeNil()) // no Put happened
|
||||
})
|
||||
|
||||
It("preserves rules when only public is sent (smart playlist + bulk Make Public)", func() {
|
||||
rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
|
||||
mockPlsRepo.Data["smart-public"] = &model.Playlist{
|
||||
ID: "smart-public",
|
||||
Name: "Smart Public",
|
||||
OwnerID: "user-1",
|
||||
Public: false,
|
||||
Rules: rules,
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("smart-public", &model.Playlist{Public: true}, "public")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Public).To(BeTrue())
|
||||
Expect(mockPlsRepo.Last.Rules).To(Equal(rules))
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Public"))
|
||||
})
|
||||
|
||||
It("does not treat a missing ownerId as an ownership transfer attempt", func() {
|
||||
// A non-admin user sending only {public:true} should not be blocked
|
||||
// just because OwnerID is the zero value in the deserialized entity.
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Public: true}, "public")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("matches cols case-insensitively (mirrors json decoder behavior)", func() {
|
||||
// Go's json decoder populates struct fields from case-variant keys
|
||||
// like {"Name":"x"}, but rest.Put's field-name extraction is
|
||||
// case-sensitive. sentFields normalizes both sides so a request
|
||||
// with {"Name":"Renamed"} is honored, not silently ignored.
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "Name")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Renamed"))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
78
core/scrobbler/nowplaying_worker.go
Normal file
78
core/scrobbler/nowplaying_worker.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
64
core/scrobbler/playbackreport_worker.go
Normal file
64
core/scrobbler/playbackreport_worker.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
135
core/stream/limiter.go
Normal file
@ -0,0 +1,135 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// ErrTooManyTranscodes is returned by TranscodeLimiter.Acquire when the
|
||||
// configured concurrency cap has been reached. Callers should translate this
|
||||
// into an HTTP 429 response so well-behaved clients back off and retry.
|
||||
var ErrTooManyTranscodes = errors.New("too many concurrent transcodes")
|
||||
|
||||
// RetryAfterSeconds is the value returned in the HTTP Retry-After header when
|
||||
// a request is rejected with ErrTooManyTranscodes. Most transcodes finish well
|
||||
// within this window, so retrying after this delay typically succeeds.
|
||||
const RetryAfterSeconds = 5
|
||||
|
||||
// TranscodeLimiter gates the number of concurrent ffmpeg transcodes. It enforces
|
||||
// both a global cap (to protect the host from process exhaustion) and an optional
|
||||
// per-user cap (to keep one client from starving the others). Acquire never
|
||||
// blocks: it either reserves a slot or returns ErrTooManyTranscodes immediately.
|
||||
type TranscodeLimiter interface {
|
||||
// Acquire reserves a slot for the given user. On success it returns a release
|
||||
// function that must be called exactly once when the transcode is done.
|
||||
// Calling release more than once is safe and idempotent.
|
||||
Acquire(ctx context.Context, user string) (release func(), err error)
|
||||
|
||||
// Enabled reports whether the limiter actually enforces any cap. Callers
|
||||
// can use it to decide whether to bind ffmpeg's lifetime to the request
|
||||
// context so disconnects free slots quickly, rather than letting the
|
||||
// process drain to completion in the background.
|
||||
Enabled() bool
|
||||
}
|
||||
|
||||
// NewTranscodeLimiter returns a limiter enforcing the given caps. Each cap is
|
||||
// independent: a value of zero or less disables that cap. When both caps are
|
||||
// disabled the limiter is a no-op.
|
||||
func NewTranscodeLimiter(maxConcurrent, maxPerUser int) TranscodeLimiter {
|
||||
if maxConcurrent <= 0 && maxPerUser <= 0 {
|
||||
return noopLimiter{}
|
||||
}
|
||||
l := &transcodeLimiter{maxPerUser: maxPerUser}
|
||||
if maxConcurrent > 0 {
|
||||
l.global = make(chan struct{}, maxConcurrent)
|
||||
}
|
||||
if maxPerUser > 0 {
|
||||
l.perUser = make(map[string]int)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// releasingReadCloser wraps an io.ReadCloser so that closing it also releases
|
||||
// the limiter slot exactly once. release must be the function returned by
|
||||
// TranscodeLimiter.Acquire; its own idempotency makes double-Close safe too.
|
||||
type releasingReadCloser struct {
|
||||
io.ReadCloser
|
||||
release func()
|
||||
}
|
||||
|
||||
func (r *releasingReadCloser) Close() error {
|
||||
err := r.ReadCloser.Close()
|
||||
r.release()
|
||||
return err
|
||||
}
|
||||
|
||||
type noopLimiter struct{}
|
||||
|
||||
func (noopLimiter) Acquire(context.Context, string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
func (noopLimiter) Enabled() bool { return false }
|
||||
|
||||
type transcodeLimiter struct {
|
||||
maxPerUser int
|
||||
global chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
perUser map[string]int
|
||||
}
|
||||
|
||||
func (*transcodeLimiter) Enabled() bool { return true }
|
||||
|
||||
func (l *transcodeLimiter) Acquire(_ context.Context, user string) (func(), error) {
|
||||
// Reserve a per-user slot first so a noisy user can't burn through
|
||||
// global slots only to be rejected later. An empty user key means
|
||||
// "anonymous" (e.g. public share viewers); we skip the per-user cap
|
||||
// entirely so unrelated anonymous clients do not share a bucket.
|
||||
perUserActive := l.maxPerUser > 0 && user != ""
|
||||
if perUserActive {
|
||||
l.mu.Lock()
|
||||
if l.perUser[user] >= l.maxPerUser {
|
||||
l.mu.Unlock()
|
||||
return nil, ErrTooManyTranscodes
|
||||
}
|
||||
l.perUser[user]++
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
if l.global != nil {
|
||||
select {
|
||||
case l.global <- struct{}{}:
|
||||
default:
|
||||
if perUserActive {
|
||||
l.releasePerUser(user)
|
||||
}
|
||||
return nil, ErrTooManyTranscodes
|
||||
}
|
||||
}
|
||||
|
||||
var released atomic.Bool
|
||||
return func() {
|
||||
if !released.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
if l.global != nil {
|
||||
<-l.global
|
||||
}
|
||||
if perUserActive {
|
||||
l.releasePerUser(user)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *transcodeLimiter) releasePerUser(user string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.perUser[user]--
|
||||
if l.perUser[user] <= 0 {
|
||||
delete(l.perUser, user)
|
||||
}
|
||||
}
|
||||
186
core/stream/limiter_test.go
Normal file
186
core/stream/limiter_test.go
Normal file
@ -0,0 +1,186 @@
|
||||
package stream_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TranscodeLimiter", func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
Describe("Disabled (both caps <= 0)", func() {
|
||||
It("never blocks and never returns ErrTooManyTranscodes", func() {
|
||||
lim := stream.NewTranscodeLimiter(0, 0)
|
||||
for range 100 {
|
||||
rel, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(rel).ToNot(BeNil())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Per-user cap only (no global cap)", func() {
|
||||
It("still enforces the per-user limit when MaxConcurrent is disabled", func() {
|
||||
lim := stream.NewTranscodeLimiter(0, 2)
|
||||
|
||||
rel1, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rel2, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
|
||||
// Other users have their own buckets.
|
||||
rel3, err := lim.Acquire(ctx, "bob")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel1()
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel2()
|
||||
rel3()
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Global cap", func() {
|
||||
It("rejects requests beyond MaxConcurrent with ErrTooManyTranscodes", func() {
|
||||
lim := stream.NewTranscodeLimiter(2, 0)
|
||||
|
||||
rel1, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rel2, err := lim.Acquire(ctx, "bob")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = lim.Acquire(ctx, "carol")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
|
||||
rel1()
|
||||
_, err = lim.Acquire(ctx, "carol")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel2()
|
||||
})
|
||||
|
||||
It("releases a slot only once even if release is called multiple times", func() {
|
||||
lim := stream.NewTranscodeLimiter(1, 0)
|
||||
|
||||
rel, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel()
|
||||
rel()
|
||||
rel()
|
||||
|
||||
// After releases, exactly one slot should be available.
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Per-user cap", func() {
|
||||
It("rejects a user beyond MaxConcurrentPerUser even if global slots remain", func() {
|
||||
lim := stream.NewTranscodeLimiter(10, 2)
|
||||
|
||||
rel1, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rel2, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
|
||||
// A different user is unaffected.
|
||||
rel3, err := lim.Acquire(ctx, "bob")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel1()
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel2()
|
||||
rel3()
|
||||
})
|
||||
|
||||
It("skips the per-user cap for anonymous users (empty key)", func() {
|
||||
// Anonymous requests (e.g. public share viewers) deliberately
|
||||
// bypass the per-user cap so unrelated anonymous clients are not
|
||||
// collapsed into a single shared bucket. The global cap remains
|
||||
// the only ceiling on anonymous traffic.
|
||||
lim := stream.NewTranscodeLimiter(10, 1)
|
||||
|
||||
rels := make([]func(), 0, 5)
|
||||
for range 5 {
|
||||
rel, err := lim.Acquire(ctx, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rels = append(rels, rel)
|
||||
}
|
||||
for _, rel := range rels {
|
||||
rel()
|
||||
}
|
||||
})
|
||||
|
||||
It("still applies the global cap to anonymous users", func() {
|
||||
lim := stream.NewTranscodeLimiter(2, 1)
|
||||
|
||||
rel1, err := lim.Acquire(ctx, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rel2, err := lim.Acquire(ctx, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = lim.Acquire(ctx, "")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
|
||||
rel1()
|
||||
rel2()
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Concurrent safety", func() {
|
||||
It("survives parallel Acquire/release with consistent counts", func() {
|
||||
lim := stream.NewTranscodeLimiter(5, 0)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var acquired int64
|
||||
var rejected int64
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := range 50 {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
rel, err := lim.Acquire(ctx, "alice")
|
||||
mu.Lock()
|
||||
if err == nil {
|
||||
acquired++
|
||||
mu.Unlock()
|
||||
rel()
|
||||
} else {
|
||||
rejected++
|
||||
mu.Unlock()
|
||||
}
|
||||
_ = i
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
Expect(acquired + rejected).To(Equal(int64(50)))
|
||||
// After all releases, all 5 slots should be free again.
|
||||
for range 5 {
|
||||
_, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
_, err := lim.Acquire(ctx, "alice")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2,6 +2,7 @@ package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
@ -28,13 +29,19 @@ type MediaStreamer interface {
|
||||
type TranscodingCache cache.FileCache
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds, transcoder: t, cache: cache}
|
||||
return &mediaStreamer{
|
||||
ds: ds,
|
||||
transcoder: t,
|
||||
cache: cache,
|
||||
limiter: NewTranscodeLimiter(conf.Server.Transcoding.MaxConcurrent, conf.Server.Transcoding.MaxConcurrentPerUser),
|
||||
}
|
||||
}
|
||||
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
transcoder ffmpeg.FFmpeg
|
||||
cache cache.FileCache
|
||||
limiter TranscodeLimiter
|
||||
}
|
||||
|
||||
type streamJob struct {
|
||||
@ -104,7 +111,12 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req
|
||||
}
|
||||
r, err := ms.cache.Get(ctx, job)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
|
||||
// Rate-limit rejections are already logged at warn level by the
|
||||
// producer; treating them as cache failures here would both
|
||||
// double-log and mask actual cache problems.
|
||||
if !errors.Is(err, ErrTooManyTranscodes) {
|
||||
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
cached = r.Cached
|
||||
@ -217,15 +229,31 @@ func NewTranscodingCache() TranscodingCache {
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
|
||||
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
|
||||
// This is where we decide whether transcoding processes should be cancellable or not.
|
||||
release, err := job.ms.limiter.Acquire(ctx, limiterKey(ctx))
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Refusing transcode: concurrent transcode limit reached",
|
||||
"id", job.mf.ID, "user", userName(ctx),
|
||||
"maxConcurrent", conf.Server.Transcoding.MaxConcurrent,
|
||||
"maxPerUser", conf.Server.Transcoding.MaxConcurrentPerUser)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Choose the context that drives the ffmpeg process.
|
||||
//
|
||||
// When the limiter is enabled, force the request context so a
|
||||
// client disconnect cancels ffmpeg and frees the slot promptly.
|
||||
// Otherwise a client could open many transcodes, disconnect
|
||||
// immediately, and still leave the configured cap's worth of
|
||||
// ffmpeg processes draining in the background — which is exactly
|
||||
// the DoS the limiter is meant to prevent.
|
||||
//
|
||||
// When the limiter is disabled, preserve the legacy behavior
|
||||
// governed by Transcoding.EnableCancellation so unchanged configs
|
||||
// keep their previous observable behavior.
|
||||
var transcodingCtx context.Context
|
||||
if conf.Server.EnableTranscodingCancellation {
|
||||
// Use the request context directly, allowing cancellation when client disconnects
|
||||
if job.ms.limiter.Enabled() || conf.Server.Transcoding.EnableCancellation {
|
||||
transcodingCtx = ctx
|
||||
} else {
|
||||
// Use background context with request values preserved.
|
||||
// This prevents cancellation but maintains request metadata (user, client, etc.)
|
||||
transcodingCtx = request.AddValues(context.Background(), ctx)
|
||||
}
|
||||
|
||||
@ -240,10 +268,14 @@ func NewTranscodingCache() TranscodingCache {
|
||||
Offset: job.offset,
|
||||
})
|
||||
if err != nil {
|
||||
release()
|
||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
return out, nil
|
||||
// Tie the slot to the ffmpeg process: copyAndClose calls Close
|
||||
// on this reader after io.Copy returns, which is exactly when
|
||||
// ffmpeg has exited (either EOF or context cancellation).
|
||||
return &releasingReadCloser{ReadCloser: out, release: release}, nil
|
||||
})
|
||||
}
|
||||
|
||||
@ -255,3 +287,16 @@ func userName(ctx context.Context) string {
|
||||
return user.UserName
|
||||
}
|
||||
}
|
||||
|
||||
// limiterKey returns the per-user bucket key used by the transcode limiter.
|
||||
// For anonymous requests (e.g. public shares) it returns the empty string,
|
||||
// which signals the limiter to skip the per-user cap entirely — otherwise
|
||||
// every anonymous viewer of a public share would collide on the same key
|
||||
// and starve each other within MaxConcurrentPerUser slots. The global cap
|
||||
// still applies and remains the protection against runaway anonymous load.
|
||||
func limiterKey(ctx context.Context) string {
|
||||
if user, ok := request.UserFrom(ctx); ok {
|
||||
return user.UserName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package stream_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
@ -10,6 +11,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
2
db/db.go
2
db/db.go
@ -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)
|
||||
|
||||
55
db/migrations/20260513173954_move_ss_before_input.go
Normal file
55
db/migrations/20260513173954_move_ss_before_input.go
Normal 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
|
||||
}
|
||||
@ -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
52
go.mod
@ -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
96
go.sum
@ -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=
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -11,8 +11,7 @@ import (
|
||||
var _ = Describe("ArtworkID", func() {
|
||||
Describe("NewArtworkID()", func() {
|
||||
It("creates a valid parseable ArtworkID", func() {
|
||||
now := time.Now()
|
||||
id := model.NewArtworkID(model.KindAlbumArtwork, "1234", &now)
|
||||
id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now()))
|
||||
parsedId, err := model.ParseArtworkID(id.String())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(parsedId.Kind).To(Equal(id.Kind))
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
package criteria
|
||||
|
||||
var StartOfPeriod = startOfPeriod
|
||||
|
||||
type UnmarshalConjunctionType = unmarshalConjunctionType
|
||||
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -8,14 +8,13 @@ import (
|
||||
|
||||
var _ = Describe("ToLyrics", func() {
|
||||
It("should parse tags with spaces", func() {
|
||||
num := int64(1551)
|
||||
lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Lang).To(Equal("eng"))
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.DisplayArtist).To(Equal("An artist"))
|
||||
Expect(lyrics.DisplayTitle).To(Equal("A title"))
|
||||
Expect(lyrics.Offset).To(Equal(&num))
|
||||
Expect(lyrics.Offset).To(Equal(new(int64(1551))))
|
||||
})
|
||||
|
||||
It("Should ignore bad offset", func() {
|
||||
@ -25,39 +24,36 @@ var _ = Describe("ToLyrics", func() {
|
||||
})
|
||||
|
||||
It("should accept lines with no text and weird times", func() {
|
||||
a, b, c, d := int64(0), int64(10040), int64(40000), int64(1000*60*60)
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: &a, Value: "Hi there"},
|
||||
{Start: &b, Value: ""},
|
||||
{Start: &c, Value: "Test"},
|
||||
{Start: &d, Value: "late"},
|
||||
{Start: new(int64(0)), Value: "Hi there"},
|
||||
{Start: new(int64(10040)), Value: ""},
|
||||
{Start: new(int64(40000)), Value: "Test"},
|
||||
{Start: new(int64(1000 * 60 * 60)), Value: "late"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("Should support multiple timestamps per line", func() {
|
||||
a, b, c, d := int64(0), int64(10000), int64(13*60*1000), int64(1000*60*60*51)
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: &a, Value: "Repeated"},
|
||||
{Start: &b, Value: "Repeated"},
|
||||
{Start: &c, Value: ""},
|
||||
{Start: &d, Value: ""},
|
||||
{Start: new(int64(0)), Value: "Repeated"},
|
||||
{Start: new(int64(10000)), Value: "Repeated"},
|
||||
{Start: new(int64(13 * 60 * 1000)), Value: ""},
|
||||
{Start: new(int64(1000 * 60 * 60 * 51)), Value: ""},
|
||||
}))
|
||||
})
|
||||
|
||||
It("Should support parsing multiline string", func() {
|
||||
a, b := int64(0), int64(10*60*1000+1)
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: &a, Value: "This is\na multiline\n\n[:0] string"},
|
||||
{Start: &b, Value: "This is\nalso one"},
|
||||
{Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"},
|
||||
{Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"},
|
||||
}))
|
||||
})
|
||||
|
||||
@ -71,49 +67,45 @@ var _ = Describe("ToLyrics", func() {
|
||||
})
|
||||
|
||||
It("Allows timestamp in middle of line if also at beginning", func() {
|
||||
a, b := int64(0), int64(1000)
|
||||
lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: &a, Value: "This is [00:00:00] be a synced file"},
|
||||
{Start: &b, Value: "Line 2"},
|
||||
{Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"},
|
||||
{Start: new(int64(1000)), Value: "Line 2"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("Ignores lines in synchronized lyric prior to first timestamp", func() {
|
||||
a := int64(0)
|
||||
lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: &a, Value: "Text"},
|
||||
{Start: new(int64(0)), Value: "Text"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("Handles all possible ms cases", func() {
|
||||
a, b, c := int64(1), int64(10), int64(100)
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: &a, Value: "a"},
|
||||
{Start: &b, Value: "b"},
|
||||
{Start: &c, Value: "c"},
|
||||
{Start: new(int64(1)), Value: "a"},
|
||||
{Start: new(int64(10)), Value: "b"},
|
||||
{Start: new(int64(100)), Value: "c"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("Properly sorts repeated lyrics out of order", func() {
|
||||
a, b, c, d, e := int64(0), int64(10000), int64(40000), int64(13*60*1000), int64(1000*60*60*51)
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: &a, Value: "Repeated"},
|
||||
{Start: &b, Value: "Test"},
|
||||
{Start: &c, Value: "Not repeated"},
|
||||
{Start: &d, Value: "Repeated"},
|
||||
{Start: &e, Value: "Test"},
|
||||
{Start: new(int64(0)), Value: "Repeated"},
|
||||
{Start: new(int64(10000)), Value: "Test"},
|
||||
{Start: new(int64(40000)), Value: "Not repeated"},
|
||||
{Start: new(int64(13 * 60 * 1000)), Value: "Repeated"},
|
||||
{Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@ -8,7 +8,6 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -108,8 +107,8 @@ var _ = Describe("ToMediaFile", func() {
|
||||
|
||||
expected := model.LyricList{
|
||||
{Lang: "eng", Line: []model.Line{
|
||||
{Value: "This is", Start: P(int64(0))},
|
||||
{Value: "English SYLT", Start: P(int64(2500))},
|
||||
{Value: "This is", Start: new(int64(0))},
|
||||
{Value: "English SYLT", Start: new(int64(2500))},
|
||||
}, Synced: true},
|
||||
{Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false},
|
||||
}
|
||||
|
||||
@ -684,6 +684,26 @@ var _ = Describe("Participants", func() {
|
||||
Expect(composers[2].Name).To(Equal("The Album Artist"))
|
||||
})
|
||||
})
|
||||
|
||||
// Sibling fix to https://github.com/navidrome/navidrome/issues/5065: when
|
||||
// multiple frames map to the same role tag (e.g. TIPL producer entries),
|
||||
// the configured split separator must still apply to each value.
|
||||
When("the tag has multiple values", func() {
|
||||
It("should split each value individually", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"COMPOSER": {"John Doe/Jane Doe", "Someone Else"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
|
||||
composers := participants[model.RoleComposer]
|
||||
Expect(composers).To(ConsistOf(
|
||||
HaveField("Name", "John Doe"),
|
||||
HaveField("Name", "Jane Doe"),
|
||||
HaveField("Name", "Someone Else"),
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MBID tags", func() {
|
||||
|
||||
@ -8,7 +8,6 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -130,6 +129,21 @@ var _ = Describe("Metadata", func() {
|
||||
|
||||
Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"}))
|
||||
})
|
||||
|
||||
// Regression test for https://github.com/navidrome/navidrome/issues/5065
|
||||
//
|
||||
// MP3s with both an ID3v2 TMOO frame and a TXXX:MOOD frame are surfaced by
|
||||
// TagLib's PropertyMap as a single "mood" key with multiple values. The split
|
||||
// configuration must still apply to each value individually.
|
||||
It("should split values from multiple frames mapping to the same tag", func() {
|
||||
props.Tags = model.RawTags{
|
||||
// Same shape as the bug report: two frames, comma-separated content.
|
||||
"mood": {"Love, Emotional, Ballad", "Love; Emotional; Ballad"},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.Strings(model.TagMood)).To(ConsistOf("Love", "Emotional", "Ballad"))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("Date",
|
||||
@ -274,8 +288,8 @@ var _ = Describe("Metadata", func() {
|
||||
mf := createMF("replaygain_track_gain", tagValue)
|
||||
Expect(mf.RGTrackGain).To(Equal(expected))
|
||||
},
|
||||
Entry("0", "0", gg.P(0.0)),
|
||||
Entry("1.2dB", "1.2dB", gg.P(1.2)),
|
||||
Entry("0", "0", new(0.0)),
|
||||
Entry("1.2dB", "1.2dB", new(1.2)),
|
||||
Entry("Infinity", "Infinity", nil),
|
||||
Entry("Invalid value", "INVALID VALUE", nil),
|
||||
Entry("NaN", "NaN", nil),
|
||||
@ -285,9 +299,9 @@ var _ = Describe("Metadata", func() {
|
||||
mf := createMF("replaygain_track_peak", tagValue)
|
||||
Expect(mf.RGTrackPeak).To(Equal(expected))
|
||||
},
|
||||
Entry("0", "0", gg.P(0.0)),
|
||||
Entry("1.0", "1.0", gg.P(1.0)),
|
||||
Entry("0.5", "0.5", gg.P(0.5)),
|
||||
Entry("0", "0", new(0.0)),
|
||||
Entry("1.0", "1.0", new(1.0)),
|
||||
Entry("0.5", "0.5", new(0.5)),
|
||||
Entry("Invalid dB suffix", "0.7dB", nil),
|
||||
Entry("Infinity", "Infinity", nil),
|
||||
Entry("Invalid value", "INVALID VALUE", nil),
|
||||
@ -299,8 +313,8 @@ var _ = Describe("Metadata", func() {
|
||||
Expect(mf.RGTrackGain).To(Equal(expected))
|
||||
|
||||
},
|
||||
Entry("0", "0", gg.P(5.0)),
|
||||
Entry("-3776", "-3776", gg.P(-9.75)),
|
||||
Entry("0", "0", new(5.0)),
|
||||
Entry("-3776", "-3776", new(-9.75)),
|
||||
Entry("Infinity", "Infinity", nil),
|
||||
Entry("Invalid value", "INVALID VALUE", nil),
|
||||
)
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -34,23 +34,25 @@ type TagConf struct {
|
||||
SplitRx *regexp.Regexp `yaml:"-"`
|
||||
}
|
||||
|
||||
// SplitTagValue splits a tag value by the split separators, but only if it has a single value.
|
||||
// SplitTagValue splits tag values by the configured split separators.
|
||||
// Each value in the input slice is individually split and trimmed.
|
||||
func (c TagConf) SplitTagValue(values []string) []string {
|
||||
// If there's not exactly one value or no separators, return early.
|
||||
if len(values) != 1 || c.SplitRx == nil {
|
||||
if c.SplitRx == nil || len(values) == 0 {
|
||||
return values
|
||||
}
|
||||
tag := values[0]
|
||||
|
||||
// Replace all occurrences of any separator with the zero-width space.
|
||||
tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp)
|
||||
var result []string
|
||||
for _, tag := range values {
|
||||
// Replace all occurrences of any separator with the zero-width space.
|
||||
tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp)
|
||||
|
||||
// Split by the zero-width space and trim each substring.
|
||||
parts := strings.Split(tag, consts.Zwsp)
|
||||
for i, part := range parts {
|
||||
parts[i] = strings.TrimSpace(part)
|
||||
// Split by the zero-width space and trim each substring.
|
||||
parts := strings.SplitSeq(tag, consts.Zwsp)
|
||||
for part := range parts {
|
||||
result = append(result, strings.TrimSpace(part))
|
||||
}
|
||||
}
|
||||
return parts
|
||||
return result
|
||||
}
|
||||
|
||||
type TagType string
|
||||
|
||||
64
model/tag_mappings_test.go
Normal file
64
model/tag_mappings_test.go
Normal file
@ -0,0 +1,64 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TagConf", func() {
|
||||
Describe("SplitTagValue", func() {
|
||||
var conf TagConf
|
||||
|
||||
BeforeEach(func() {
|
||||
conf = TagConf{Split: []string{";", "/", ","}}
|
||||
conf.SplitRx = compileSplitRegex("test", conf.Split)
|
||||
})
|
||||
|
||||
It("splits a single value on configured separators", func() {
|
||||
Expect(conf.SplitTagValue([]string{"Rock/Pop;Punk"})).To(Equal([]string{"Rock", "Pop", "Punk"}))
|
||||
})
|
||||
|
||||
It("trims whitespace around split values", func() {
|
||||
Expect(conf.SplitTagValue([]string{"Love, Emotional, Ballad"})).To(Equal([]string{"Love", "Emotional", "Ballad"}))
|
||||
})
|
||||
|
||||
// Regression test for https://github.com/navidrome/navidrome/issues/5065
|
||||
//
|
||||
// When multiple ID3v2 frames map to the same logical tag (e.g. TMOO + TXXX:MOOD),
|
||||
// TagLib's PropertyMap merges them into a slice with several entries. Previously
|
||||
// SplitTagValue had a `len(values) != 1` guard that skipped splitting in this case.
|
||||
It("splits each value individually when given multiple inputs", func() {
|
||||
input := []string{"Love, Emotional, Ballad", "Love; Emotional; Ballad"}
|
||||
Expect(conf.SplitTagValue(input)).To(Equal([]string{
|
||||
"Love", "Emotional", "Ballad",
|
||||
"Love", "Emotional", "Ballad",
|
||||
}))
|
||||
})
|
||||
|
||||
It("matches separators case-insensitively when the split pattern allows", func() {
|
||||
c := TagConf{Split: []string{" AND "}}
|
||||
c.SplitRx = compileSplitRegex("test", c.Split)
|
||||
Expect(c.SplitTagValue([]string{"foo and bar AND baz"})).To(Equal([]string{"foo", "bar", "baz"}))
|
||||
})
|
||||
|
||||
It("returns values unchanged when no separators are configured", func() {
|
||||
c := TagConf{}
|
||||
Expect(c.SplitTagValue([]string{"Foo, Bar"})).To(Equal([]string{"Foo, Bar"}))
|
||||
Expect(c.SplitTagValue([]string{"a", "b"})).To(Equal([]string{"a", "b"}))
|
||||
})
|
||||
|
||||
It("returns an empty slice for empty input", func() {
|
||||
Expect(conf.SplitTagValue([]string{})).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("handles a value with no separator as a single-element result", func() {
|
||||
Expect(conf.SplitTagValue([]string{"JustOneMood"})).To(Equal([]string{"JustOneMood"}))
|
||||
})
|
||||
|
||||
It("produces empty strings when separators are adjacent (dedup happens downstream)", func() {
|
||||
// SplitTagValue itself does not filter empties; that is the job of
|
||||
// filterDuplicatedOrEmptyValues in the metadata pipeline.
|
||||
Expect(conf.SplitTagValue([]string{"Rock//Pop"})).To(Equal([]string{"Rock", "", "Pop"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -18,7 +18,6 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
@ -219,7 +218,7 @@ func (r *artistRepository) Exists(id string) (bool, error) {
|
||||
|
||||
func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error {
|
||||
dba := &dbArtist{Artist: a}
|
||||
dba.CreatedAt = P(time.Now())
|
||||
dba.CreatedAt = new(time.Now())
|
||||
dba.UpdatedAt = dba.CreatedAt
|
||||
_, err := r.put(dba.ID, dba, colsToUpdate...)
|
||||
return err
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
236
persistence/criteria_sql_benchmark_test.go
Normal file
236
persistence/criteria_sql_benchmark_test.go
Normal file
@ -0,0 +1,236 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
const (
|
||||
benchNumArtists = 1_000
|
||||
benchNumTracks = 40_000
|
||||
benchNumPatterns = 500
|
||||
benchArtistsPerTrack = 3
|
||||
)
|
||||
|
||||
// BenchmarkSmartPlaylistRole compares role-based smart playlist query performance
|
||||
// between the current implementation (merged join-table via criteria pipeline) and
|
||||
// the old baseline (unmerged json_tree subqueries).
|
||||
func BenchmarkSmartPlaylistRole(b *testing.B) {
|
||||
configtest.SetupConfig()
|
||||
tmpDir := b.TempDir()
|
||||
conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl.db")
|
||||
cleanup := db.Init(context.Background())
|
||||
defer cleanup()
|
||||
log.SetLevel(log.LevelFatal)
|
||||
|
||||
conn := dbx.NewFromDB(db.Db(), db.Dialect)
|
||||
ctx := log.NewContext(context.Background())
|
||||
user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true}
|
||||
ctx = request.WithUser(ctx, user)
|
||||
|
||||
setupBenchData(b, ctx, conn, user)
|
||||
criteria.AddRoles([]string{"artist"})
|
||||
|
||||
// Build the criteria expression: 500 "contains artist" patterns in an OR group
|
||||
anyExprs := make(criteria.Any, benchNumPatterns)
|
||||
for i := range benchNumPatterns {
|
||||
anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist %04d", i)}
|
||||
}
|
||||
expr := criteria.Criteria{Expression: anyExprs, Sort: "title", Limit: 500}
|
||||
|
||||
b.Run("Current", func(b *testing.B) {
|
||||
benchmarkCriteriaPipeline(b, ctx, expr)
|
||||
})
|
||||
b.Run("Baseline_UnmergedJSONTree", func(b *testing.B) {
|
||||
benchmarkUnmergedJSONTree(b, ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// benchmarkCriteriaPipeline runs the criteria through the actual production code path:
|
||||
// newSmartPlaylistCriteria → Where() → ToSql(), then executes the resulting query.
|
||||
func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.Criteria) {
|
||||
b.Helper()
|
||||
|
||||
cSQL := newSmartPlaylistCriteria(expr)
|
||||
|
||||
// Build the full query matching buildSmartPlaylistQuery + addCriteria
|
||||
sq := squirrel.Select("media_file.id").From("media_file")
|
||||
cond, err := cSQL.Where()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
sq = sq.Where(cond)
|
||||
if expr.Limit > 0 {
|
||||
sq = sq.Limit(uint64(expr.Limit))
|
||||
}
|
||||
if order := cSQL.OrderBy(); order != "" {
|
||||
sq = sq.OrderBy(order)
|
||||
}
|
||||
|
||||
query, args, err := sq.PlaceholderFormat(squirrel.Question).ToSql()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
runBenchQuery(b, ctx, query, args)
|
||||
}
|
||||
|
||||
// benchmarkUnmergedJSONTree builds the old-style query with N separate json_tree EXISTS
|
||||
// subqueries (the pre-optimization baseline).
|
||||
func benchmarkUnmergedJSONTree(b *testing.B, ctx context.Context) {
|
||||
b.Helper()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("SELECT media_file.id FROM media_file WHERE (")
|
||||
args := make([]any, 0, benchNumPatterns)
|
||||
for i := range benchNumPatterns {
|
||||
if i > 0 {
|
||||
sb.WriteString(" OR ")
|
||||
}
|
||||
sb.WriteString("exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)")
|
||||
args = append(args, fmt.Sprintf("%%Artist %04d%%", i))
|
||||
}
|
||||
sb.WriteString(") ORDER BY media_file.title LIMIT 500")
|
||||
|
||||
runBenchQuery(b, ctx, sb.String(), args)
|
||||
}
|
||||
|
||||
func runBenchQuery(b *testing.B, ctx context.Context, query string, args []any) {
|
||||
b.Helper()
|
||||
sqlDB := db.Db()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
rows, err := sqlDB.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
_ = rows.Scan(&id)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupBenchData(b *testing.B, ctx context.Context, conn *dbx.DB, user model.User) {
|
||||
b.Helper()
|
||||
|
||||
sqlDB := db.Db()
|
||||
|
||||
ur := NewUserRepository(ctx, conn)
|
||||
if err := ur.Put(&user); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := ur.SetUserLibraries(user.ID, []int{1}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
tx, err := sqlDB.Begin()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
// Create artists
|
||||
artistStmt, err := tx.Prepare("INSERT INTO artist (id, name) VALUES (?, ?)")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
for i := range benchNumArtists {
|
||||
if _, err := artistStmt.Exec(fmt.Sprintf("artist-%04d", i), fmt.Sprintf("Artist %04d", i)); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
artistStmt.Close()
|
||||
|
||||
// Ensure folder exists
|
||||
folderID := "bench-folder"
|
||||
if _, err := tx.Exec("INSERT OR IGNORE INTO folder (id, library_id, path, name, parent_id) VALUES (?, 1, '.', '.', '')", folderID); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
// Create media files with participants JSON, cycling through artists
|
||||
mfStmt, err := tx.Prepare(`INSERT INTO media_file (id, path, title, album, artist, artist_id, album_id,
|
||||
duration, year, size, suffix, tags, participants, lyrics, library_id, folder_id, pid, codec)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
// Populate media_file_artists join table
|
||||
mfaStmt, err := tx.Prepare("INSERT INTO media_file_artists (media_file_id, artist_id, role, sub_role) VALUES (?, ?, ?, ?)")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
for i := range benchNumTracks {
|
||||
trackID := fmt.Sprintf("track-%05d", i)
|
||||
|
||||
// Assign benchArtistsPerTrack artists to each track, cycling through the pool
|
||||
artistEntries := make([]map[string]string, benchArtistsPerTrack)
|
||||
for a := range benchArtistsPerTrack {
|
||||
artistIdx := (i + a) % benchNumArtists
|
||||
artistEntries[a] = map[string]string{
|
||||
"id": fmt.Sprintf("artist-%04d", artistIdx),
|
||||
"name": fmt.Sprintf("Artist %04d", artistIdx),
|
||||
}
|
||||
}
|
||||
primaryArtistIdx := i % benchNumArtists
|
||||
primaryArtistID := fmt.Sprintf("artist-%04d", primaryArtistIdx)
|
||||
primaryArtistName := fmt.Sprintf("Artist %04d", primaryArtistIdx)
|
||||
|
||||
participants := map[string][]map[string]string{"artist": artistEntries}
|
||||
participantsJSON, _ := json.Marshal(participants)
|
||||
|
||||
if _, err := mfStmt.Exec(
|
||||
trackID,
|
||||
fmt.Sprintf("music/%s.mp3", trackID),
|
||||
fmt.Sprintf("Track %05d", i),
|
||||
"Bench Album",
|
||||
primaryArtistName,
|
||||
primaryArtistID,
|
||||
"bench-album",
|
||||
180, 2024, 5000000, "mp3",
|
||||
"{}",
|
||||
string(participantsJSON),
|
||||
"[]",
|
||||
1, folderID, trackID, "mp3",
|
||||
); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
// Insert all artist associations into the join table
|
||||
for a := range benchArtistsPerTrack {
|
||||
artistIdx := (i + a) % benchNumArtists
|
||||
artistID := fmt.Sprintf("artist-%04d", artistIdx)
|
||||
if _, err := mfaStmt.Exec(trackID, artistID, "artist", ""); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
mfStmt.Close()
|
||||
mfaStmt.Close()
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
b.Logf("Setup complete: %d artists, %d tracks (%d artists/track), %d patterns",
|
||||
benchNumArtists, benchNumTracks, benchArtistsPerTrack, benchNumPatterns)
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -56,9 +58,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
Loading…
x
Reference in New Issue
Block a user