Merge branch 'master' into master

This commit is contained in:
Deluan Quintão 2026-06-05 18:31:54 -04:00 committed by GitHub
commit a93bd97d03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
365 changed files with 17223 additions and 4600 deletions

View File

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

View File

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

View File

@ -120,6 +120,79 @@ jobs:
go build -o ndpgen .
./ndpgen --help
go-windows:
name: Test Go code (Windows)
runs-on: windows-2022
env:
FFMPEG_VERSION: "7.1"
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
install: mingw-w64-x86_64-gcc
update: false
- name: Add mingw64 to PATH
shell: bash
run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
- name: Cache ffmpeg
id: ffmpeg-cache
uses: actions/cache@v5
with:
path: C:\ffmpeg
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
- name: Download ffmpeg
if: steps.ffmpeg-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
$asset = "ffmpeg-n${env:FFMPEG_VERSION}-latest-win64-gpl-${env:FFMPEG_VERSION}"
$url = "https://github.com/${env:FFMPEG_REPOSITORY}/releases/download/latest/$asset.zip"
Invoke-WebRequest -Uri $url -OutFile ffmpeg.zip
Expand-Archive ffmpeg.zip -DestinationPath C:\ffmpeg-extracted
New-Item -ItemType Directory -Force -Path C:\ffmpeg\bin | Out-Null
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffmpeg.exe" C:\ffmpeg\bin
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffprobe.exe" C:\ffmpeg\bin
- name: Add ffmpeg to PATH
shell: bash
run: echo "C:/ffmpeg/bin" >> $GITHUB_PATH
- name: Verify toolchain
shell: pwsh
run: |
go version
where.exe gcc
gcc --version
ffmpeg -version
ffprobe -version
- name: Download dependencies
shell: bash
run: go mod download
- name: Test
shell: bash
env:
CGO_ENABLED: "1"
run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
- name: Test ndpgen
shell: pwsh
run: |
cd plugins\cmd\ndpgen
go test -shuffle=on -v
go build -o ndpgen.exe .
.\ndpgen.exe --help
js:
name: Test JS code
runs-on: ubuntu-latest
@ -184,7 +257,7 @@ jobs:
build:
name: Build
needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled]
strategy:
matrix:
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]

View File

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

3
.gitignore vendored
View File

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

View File

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

View File

@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
GOLANGCI_LINT_VERSION ?= v2.11.1
GOLANGCI_LINT_VERSION ?= v2.12.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
@ -75,8 +75,8 @@ test-i18n: ##@Development Validate all translations files
install-golangci-lint: ##@Development Install golangci-lint if not present
@INSTALL=false; \
if PATH=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \
CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
if PATH=./bin:$$PATH which golangci-lint > /dev/null 2>&1; then \
CURRENT_VERSION=$$(PATH=./bin:$$PATH golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
@ -93,7 +93,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
.PHONY: install-golangci-lint
lint: install-golangci-lint ##@Development Lint Go code
PATH=$$PATH:./bin golangci-lint run --timeout 5m
PATH=./bin:$$PATH golangci-lint run --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import (
"os"
"strings"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -213,6 +214,7 @@ var _ = Describe("Extractor", func() {
// Only run permission tests if we are not root
RegularUserContext("when run without root privileges", func() {
BeforeEach(func() {
tests.SkipOnWindows("uses Unix file permission bits")
// Use root fs for absolute paths in temp directory
e = &extractor{fs: os.DirFS("/")}
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")

View File

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

View File

@ -77,6 +77,13 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
return
}
resp["status"] = key != ""
linkToken, err := createLinkToken(u.ID)
if err != nil {
log.Error(r.Context(), "Could not create LastFM link token", "userId", u.ID, err)
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
resp["linkToken"] = linkToken
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
}
@ -97,11 +104,17 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
return
}
uid, err := p.String("uid")
linkToken, err := p.String("uid")
if err != nil {
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
return
}
uid, err := verifyLinkToken(linkToken)
if err != nil {
log.Warn(r.Context(), "Rejected LastFM callback with invalid link token", "requestId", middleware.GetReqID(r.Context()), err)
_ = rest.RespondWithError(w, http.StatusBadRequest, "invalid link token")
return
}
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
// automatically contain any user info

View File

@ -0,0 +1,218 @@
package lastfm
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"time"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("auth_router", func() {
var (
ds *tests.MockDataStore
userProps *tests.MockedUserPropsRepo
httpClient *tests.FakeHttpClient
router *Router
)
const (
victimID = "victim-user-id"
attackerID = "attacker-user-id"
)
BeforeEach(func() {
userProps = &tests.MockedUserPropsRepo{}
ds = &tests.MockDataStore{
MockedProperty: &tests.MockedPropertyRepo{},
MockedUserProps: userProps,
}
auth.Init(ds)
httpClient = &tests.FakeHttpClient{}
router = &Router{
ds: ds,
apiKey: "API_KEY",
secret: "SECRET",
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
router.client = newClient(router.apiKey, router.secret, httpClient)
router.Handler = router.routes()
})
storedSessionKey := func(userID string) string {
key, _ := userProps.Get(userID, sessionKeyProperty)
return key
}
stubGetSessionOK := func(sessionKey string) {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)),
StatusCode: 200,
}
}
Describe("getLinkStatus", func() {
It("includes a signed linkToken for the authenticated user", func() {
req := httptest.NewRequest(http.MethodGet, "/link", nil)
ctx := request.WithUser(req.Context(), model.User{ID: victimID})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
router.getLinkStatus(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
var body map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed())
Expect(body["apiKey"]).To(Equal("API_KEY"))
Expect(body["status"]).To(Equal(false))
token, ok := body["linkToken"].(string)
Expect(ok).To(BeTrue())
Expect(token).ToNot(BeEmpty())
verified, err := verifyLinkToken(token)
Expect(err).ToNot(HaveOccurred())
Expect(verified).To(Equal(victimID))
})
})
Describe("callback", func() {
It("stores the session key under the user encoded in the signed token", func() {
stubGetSessionOK("LEGIT_SESSION")
linkToken, err := createLinkToken(victimID)
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION"))
})
It("rejects a raw (unsigned) uid value", func() {
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(storedSessionKey(victimID)).To(BeEmpty())
Expect(httpClient.SavedRequest).To(BeNil())
})
It("rejects an expired link token", func() {
expiredToken, err := auth.EncodeToken(map[string]any{
"uid": victimID,
"scope": linkTokenScope,
"exp": time.Now().Add(-1 * time.Minute).UTC().Unix(),
})
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(storedSessionKey(victimID)).To(BeEmpty())
Expect(httpClient.SavedRequest).To(BeNil())
})
It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() {
sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"})
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(storedSessionKey(attackerID)).To(BeEmpty())
Expect(httpClient.SavedRequest).To(BeNil())
})
It("writes only under the user encoded in the token, regardless of query manipulation", func() {
// An attacker holds a legitimate link token for their own account.
// They attempt to call the callback hoping to overwrite the victim's
// session key — but the handler must derive the user ID from the
// signed token, not from any other input.
stubGetSessionOK("ATTACKER_SESSION")
attackerToken, err := createLinkToken(attackerID)
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION"))
Expect(storedSessionKey(victimID)).To(BeEmpty())
})
It("returns 400 when uid is missing", func() {
req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
It("returns 400 when token is missing", func() {
linkToken, err := createLinkToken(victimID)
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
})
Describe("link token helpers", func() {
It("round-trips a freshly issued token", func() {
token, err := createLinkToken(victimID)
Expect(err).ToNot(HaveOccurred())
uid, err := verifyLinkToken(token)
Expect(err).ToNot(HaveOccurred())
Expect(uid).To(Equal(victimID))
})
It("rejects garbage", func() {
_, err := verifyLinkToken("not-a-jwt")
Expect(err).To(HaveOccurred())
})
It("rejects a token whose scope claim is wrong", func() {
wrongScopeToken, err := auth.EncodeToken(map[string]any{
"uid": victimID,
"scope": "some-other-scope",
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
})
Expect(err).ToNot(HaveOccurred())
_, err = verifyLinkToken(wrongScopeToken)
Expect(err).To(MatchError("invalid link token scope"))
})
It("rejects a scoped token that has no expiration", func() {
nonExpiringToken, err := auth.EncodeToken(map[string]any{
"uid": victimID,
"scope": linkTokenScope,
})
Expect(err).ToNot(HaveOccurred())
_, err = verifyLinkToken(nonExpiringToken)
Expect(err).To(MatchError("link token missing expiration"))
})
})
})

View File

@ -0,0 +1,50 @@
package lastfm
import (
"errors"
"time"
"github.com/navidrome/navidrome/core/auth"
)
const (
linkTokenScope = "lastfm-link"
linkTokenTTL = 5 * time.Minute
)
// createLinkToken issues a signed token binding the Last.fm callback to the
// user who initiated the OAuth flow. It travels back through Last.fm via the
// `cb` URL in place of the previously-trusted raw `uid` query parameter.
func createLinkToken(userID string) (string, error) {
claims := map[string]any{
"uid": userID,
"scope": linkTokenScope,
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
}
return auth.EncodeToken(claims)
}
// verifyLinkToken validates a signed link token and returns the encoded user ID.
// It enforces both the signature/expiry (via the underlying JWT verifier) and a
// dedicated scope claim, preventing tokens minted for other purposes (e.g. a
// regular session JWT) from being accepted here.
func verifyLinkToken(tokenStr string) (string, error) {
token, err := auth.DecodeAndVerifyToken(tokenStr)
if err != nil {
return "", err
}
// jwtauth treats a token without `exp` as non-expiring; require it
// explicitly so an accidental regression cannot mint permanent tokens.
if exp, ok := token.Expiration(); !ok || exp.IsZero() {
return "", errors.New("link token missing expiration")
}
var scope string
if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope {
return "", errors.New("invalid link token scope")
}
var uid string
if err := token.Get("uid", &uid); err != nil || uid == "" {
return "", errors.New("invalid link token user ID")
}
return uid, nil
}

View File

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

View File

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

View File

@ -32,17 +32,17 @@ var inspectCmd = &cobra.Command{
},
}
var marshalers = map[string]func(interface{}) ([]byte, error){
var marshalers = map[string]func(any) ([]byte, error){
"pretty": prettyMarshal,
"toml": toml.Marshal,
"yaml": yaml.Marshal,
"json": json.Marshal,
"jsonindent": func(v interface{}) ([]byte, error) {
"jsonindent": func(v any) ([]byte, error) {
return json.MarshalIndent(v, "", " ")
},
}
func prettyMarshal(v interface{}) ([]byte, error) {
func prettyMarshal(v any) ([]byte, error) {
out := v.([]core.InspectOutput)
var res strings.Builder
for i := range out {

View File

@ -7,11 +7,19 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/ioutils"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
"github.com/spf13/cobra"
)
@ -20,6 +28,7 @@ var (
outputFile string
userID string
outputFormat string
syncFlag bool
)
type displayPlaylist struct {
@ -41,6 +50,15 @@ func init() {
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
plsCmd.AddCommand(listCommand)
exportCommand.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID")
exportCommand.Flags().StringVarP(&outputFile, "output", "o", "", "output directory")
exportCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
plsCmd.AddCommand(exportCommand)
importCommand.Flags().StringVarP(&userID, "user", "u", "", "owner username or ID (default: first admin)")
importCommand.Flags().BoolVar(&syncFlag, "sync", false, "mark imported playlists as synced")
plsCmd.AddCommand(importCommand)
}
var (
@ -60,72 +78,165 @@ var (
runList(cmd.Context())
},
}
exportCommand = &cobra.Command{
Use: "export",
Short: "Export playlists to M3U files",
Long: "Export one or more Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
runExport(cmd.Context())
},
}
importCommand = &cobra.Command{
Use: "import [files...]",
Short: "Import M3U playlists",
Long: "Import one or more M3U files as Navidrome playlists",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runImport(cmd.Context(), args)
},
}
)
func runExporter(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.Playlists {
options := model.QueryOptions{Sort: sort}
if userID != "" {
user, err := getUser(ctx, userID, ds)
if err != nil {
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
options.Filters = squirrel.Eq{"owner_id": user.ID}
}
pls, err := ds.Playlist(ctx).GetAll(options)
if err != nil {
log.Fatal(ctx, "Failed to retrieve playlists", err)
}
return pls
}
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving playlist", "name", playlistID, err)
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
}
if errors.Is(err, model.ErrNotFound) {
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}})
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
if err != nil {
log.Fatal("Error retrieving playlist", "name", playlistID, err)
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
}
if len(playlists) > 0 {
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
if err != nil {
log.Fatal("Error retrieving playlist", "name", playlistID, err)
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
}
}
}
if playlist == nil {
log.Fatal("Playlist not found", "name", playlistID)
log.Fatal("Playlist not found", "name", nameOrID)
}
return playlist
}
func runExporter(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
playlist := findPlaylist(ctx, ds, playlistID)
pls := playlist.ToM3U8()
if outputFile == "-" || outputFile == "" {
println(pls)
return
}
err = os.WriteFile(outputFile, []byte(pls), 0600)
err := os.WriteFile(outputFile, []byte(pls), 0600)
if err != nil {
log.Fatal("Error writing to the output file", "file", outputFile, err)
}
}
func runExport(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
if playlistID != "" && outputFile == "" {
playlist := findPlaylist(ctx, ds, playlistID)
println(playlist.ToM3U8())
return
}
if outputFile == "" {
log.Fatal("Output directory (-o) is required for bulk export or when filtering by user")
}
info, err := os.Stat(outputFile)
if err != nil || !info.IsDir() {
log.Fatal("Output path must be an existing directory", "path", outputFile)
}
if playlistID != "" {
pls := findPlaylist(ctx, ds, playlistID)
filename := str.SanitizeFilename(pls.Name) + ".m3u"
path := filepath.Join(outputFile, filename)
err := os.WriteFile(path, []byte(pls.ToM3U8()), 0600)
if err != nil {
log.Fatal("Error writing playlist", "file", path, err)
}
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
return
}
allPls := fetchPlaylists(ctx, ds, "name")
nameCounts := make(map[string]int)
for _, pls := range allPls {
nameCounts[str.SanitizeFilename(pls.Name)]++
}
exported := 0
for _, pls := range allPls {
plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
if err != nil {
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
continue
}
sanitized := str.SanitizeFilename(pls.Name)
filename := sanitized + ".m3u"
if nameCounts[sanitized] > 1 {
shortID := pls.ID
if len(shortID) > 6 {
shortID = shortID[:6]
}
filename = sanitized + "_" + shortID + ".m3u"
}
path := filepath.Join(outputFile, filename)
err = os.WriteFile(path, []byte(plsWithTracks.ToM3U8()), 0600)
if err != nil {
log.Error("Error writing playlist", "file", path, err)
continue
}
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
exported++
}
fmt.Printf("\nExported %d playlists to %s\n", exported, outputFile)
}
func runList(ctx context.Context) {
if outputFormat != "csv" && outputFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
}
ds, ctx := getAdminContext(ctx)
options := model.QueryOptions{Sort: "owner_name"}
if userID != "" {
user, err := getUser(ctx, userID, ds)
if err != nil {
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
options.Filters = squirrel.Eq{"owner_id": user.ID}
}
playlists, err := ds.Playlist(ctx).GetAll(options)
if err != nil {
log.Fatal(ctx, "Failed to retrieve playlists", err)
}
allPls := fetchPlaylists(ctx, ds, "owner_name")
if outputFormat == "csv" {
w := csv.NewWriter(os.Stdout)
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
for _, playlist := range playlists {
for _, playlist := range allPls {
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
}
w.Flush()
} else {
display := make(displayPlaylists, len(playlists))
for idx, playlist := range playlists {
display := make(displayPlaylists, len(allPls))
for idx, playlist := range allPls {
display[idx].Id = playlist.ID
display[idx].Name = playlist.Name
display[idx].OwnerId = playlist.OwnerID
@ -137,3 +248,62 @@ func runList(ctx context.Context) {
fmt.Printf("%s\n", j)
}
}
func runImport(ctx context.Context, files []string) {
ds, ctx := getAdminContext(ctx)
if userID != "" {
user, err := getUser(ctx, userID, ds)
if err != nil {
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
ctx = request.WithUser(ctx, *user)
}
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
for _, file := range files {
absPath, err := filepath.Abs(file)
if err != nil {
log.Error("Error resolving path", "file", file, err)
fmt.Fprintf(os.Stderr, "Error: could not resolve path %s: %v\n", file, err)
continue
}
totalLines := countM3UTrackLines(absPath)
imported, err := pls.ImportFile(ctx, absPath, syncFlag)
if err != nil {
log.Error("Error importing playlist", "file", absPath, err)
fmt.Fprintf(os.Stderr, "Error importing %s: %v\n", file, err)
continue
}
matched := len(imported.Tracks)
if totalLines > 0 {
notFound := totalLines - matched
fmt.Printf("Imported \"%s\" — %d/%d tracks matched (%d not found)\n", imported.Name, matched, totalLines, notFound)
} else {
fmt.Printf("Imported \"%s\" — %d tracks\n", imported.Name, matched)
}
}
}
func countM3UTrackLines(path string) int {
file, err := os.Open(path)
if err != nil {
return 0
}
defer file.Close()
count := 0
reader := ioutils.UTF8Reader(file)
for line := range slice.LinesFrom(reader) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
count++
}
return count
}

View File

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

View File

@ -22,6 +22,7 @@ import (
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
@ -110,7 +111,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(manager)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
return router
}
@ -219,7 +221,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View File

@ -15,6 +15,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
@ -43,9 +44,11 @@ var allProviders = wire.NewSet(
metrics.GetPrometheusInstance,
db.Db,
plugins.GetManager,
sonic.New,
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),

View File

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

View File

@ -2,6 +2,7 @@ package conf
import (
"cmp"
"encoding/json"
"fmt"
"net/url"
"os"
@ -14,6 +15,7 @@ import (
"github.com/bmatcuk/doublestar/v4"
"github.com/dustin/go-humanize"
"github.com/go-viper/encoding/ini"
"github.com/go-viper/mapstructure/v2"
"github.com/kr/pretty"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
@ -27,9 +29,10 @@ type configOptions struct {
Address string
Port int
UnixSocketPerm string
EnforceNonRootUser bool
MusicFolder string
DataFolder string
CacheFolder string
DataFolder Dir
CacheFolder Dir
DbPath string
LogLevel string
LogFile string
@ -44,7 +47,6 @@ type configOptions struct {
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
EnableTranscodingCancellation bool
EnableDownloads bool
EnableExternalServices bool
EnableM3UExternalAlbumArt bool
@ -60,8 +62,8 @@ type configOptions struct {
SmartPlaylistRefreshDelay time.Duration
AutoTranscodeDownload bool
DefaultDownsamplingFormat string
Search searchOptions `json:",omitzero"`
SimilarSongsMatchThreshold int
Search searchOptions `json:",omitzero"`
Matcher matcherOptions `json:",omitzero"`
RecentlyAddedByModTime bool
PreferSortTags bool
IgnoredArticles string
@ -94,6 +96,7 @@ type configOptions struct {
EnableReplayGain bool
EnableCoverAnimation bool
EnableNowPlaying bool
UIPlaybackReportInterval time.Duration
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
@ -109,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"`
@ -132,6 +136,7 @@ type configOptions struct {
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtworkThrottleBuffered bool
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
@ -160,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
@ -226,7 +237,7 @@ type jukeboxOptions struct {
type backupOptions struct {
Count int
Path string
Path Dir
Schedule string
}
@ -244,7 +255,7 @@ type inspectOptions struct {
type pluginsOptions struct {
Enabled bool
Folder string
Folder Dir
CacheSize string
AutoReload bool
LogLevel string
@ -261,6 +272,11 @@ type searchOptions struct {
FullString bool
}
type matcherOptions struct {
PreferStarred bool
FuzzyThreshold int
}
// logFatal prints a fatal error message to stderr and exits.
// Overridden in tests to allow testing fatal paths.
var logFatal = func(args ...any) {
@ -268,11 +284,33 @@ var logFatal = func(args ...any) {
os.Exit(1)
}
var getEUID = os.Geteuid
var currentGOOS = func() string {
return runtime.GOOS
}
var (
Server = &configOptions{}
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()
@ -291,54 +329,47 @@ func Load(noConfigDump bool) {
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
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)
}
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
if err != nil {
logFatal("Error creating data path:", err)
// Validate non-root user early, before any filesystem operations
if err := validateEnforceNonRootUser(); err != nil {
logFatal(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()))
@ -424,6 +455,8 @@ func Load(noConfigDump bool) {
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
// Removed options
logRemovedOptions("Spotify.ID", "Spotify.Secret")
@ -592,6 +625,18 @@ func validateMaxImageUploadSize() error {
return nil
}
func validateEnforceNonRootUser() error {
if !Server.EnforceNonRootUser || currentGOOS() == "windows" {
return nil
}
if getEUID() == 0 {
return fmt.Errorf("EnforceNonRootUser is enabled but Navidrome is running as root")
}
return nil
}
func validateScanSchedule() error {
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
Server.Scanner.Schedule = ""
@ -603,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
}
@ -691,6 +736,7 @@ func setViperDefaults() {
viper.SetDefault("address", "0.0.0.0")
viper.SetDefault("port", 4533)
viper.SetDefault("unixsocketperm", "0660")
viper.SetDefault("enforcenonrootuser", false)
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
viper.SetDefault("baseurl", "")
viper.SetDefault("tlscert", "")
@ -699,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)
@ -716,7 +761,8 @@ func setViperDefaults() {
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
viper.SetDefault("search.fullstring", false)
viper.SetDefault("search.backend", "fts")
viper.SetDefault("similarsongsmatchthreshold", 85)
viper.SetDefault("matcher.preferstarred", true)
viper.SetDefault("matcher.fuzzythreshold", 85)
viper.SetDefault("recentlyaddedbymodtime", false)
viper.SetDefault("prefersorttags", false)
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
@ -743,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)
@ -782,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)
@ -826,6 +876,7 @@ func setViperDefaults() {
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("devartworkthrottlebuffered", true)
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
viper.SetDefault("devexternalscanner", true)

View File

@ -186,27 +186,12 @@ var _ = Describe("Configuration", func() {
}).To(PanicWith(ContainSubstring("Error reading config file")))
})
It("is called when DataFolder is not writable", func() {
viper.SetDefault("datafolder", invalidPath)
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error creating data path")))
})
It("is called when CacheFolder is not writable", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("cachefolder", invalidPath)
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error creating cache path")))
})
It("is called when LogFile path is not writable", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt"))
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error opening log file")))
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
})
It("is called when BaseURL is invalid", func() {
@ -250,6 +235,49 @@ var _ = Describe("Configuration", func() {
)
})
Describe("EnforceNonRootUser", func() {
It("defaults to false", func() {
conf.Load(true)
Expect(conf.Server.EnforceNonRootUser).To(BeFalse())
})
It("allows startup for non-root users when enabled", func() {
DeferCleanup(conf.SetRuntimeInfoForTest("linux", 1000))
viper.Set("enforcenonrootuser", true)
conf.Load(true)
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
})
It("exits when enabled and running as root without having created a data folder", func() {
// Create a path that doesn't exist yet
tempBase := GinkgoT().TempDir()
nonExistentDataFolder := filepath.Join(tempBase, "nonexistent", "data")
DeferCleanup(conf.SetRuntimeInfoForTest("linux", 0))
viper.Set("enforcenonrootuser", true)
viper.Set("datafolder", nonExistentDataFolder)
// Attempt to load config as root user - should fail before creating directories
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("EnforceNonRootUser is enabled but Navidrome is running as root")))
// Verify that the data folder was NOT created
Expect(nonExistentDataFolder).ToNot(BeAnExistingFile())
})
It("is a no-op on non-unix platforms", func() {
DeferCleanup(conf.SetRuntimeInfoForTest("windows", 0))
viper.Set("enforcenonrootuser", true)
conf.Load(true)
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
})
})
DescribeTable("should load configuration from",
func(format string) {
filename := filepath.Join("testdata", "cfg."+format)

77
conf/dir.go Normal file
View File

@ -0,0 +1,77 @@
package conf
import (
"cmp"
"fmt"
"os"
)
// Dir wraps a directory path and creates the directory on demand. Dir is a
// plain value type — safe to copy, compare, and print via reflection-based
// formatters (pretty.Sprintf("%# v", ...)) without any concurrency hazards.
// Directory creation is delegated to os.MkdirAll on every Path() call;
// MkdirAll is idempotent, so repeated calls cost one stat syscall when the
// directory already exists.
type Dir struct {
path string
perm os.FileMode
}
// NewDir creates a new Dir with the given path and default permissions (os.ModePerm).
func NewDir(path string) Dir {
return Dir{path: path, perm: os.ModePerm}
}
// NewDirWithPerm creates a new Dir with the given path and permissions.
// A perm of 0 is treated as "default" and resolves to os.ModePerm at
// directory-creation time; pass an explicit non-zero mode to constrain the
// permissions.
func NewDirWithPerm(path string, perm os.FileMode) Dir {
return Dir{path: path, perm: perm}
}
// String returns the raw path without creating the directory. Satisfies fmt.Stringer.
func (d Dir) String() string {
return d.path
}
// Path ensures the directory exists and returns its path. Safe to call
// repeatedly; an empty path is returned as-is with no error.
func (d Dir) Path() (string, error) {
if d.path == "" {
return "", nil
}
if err := os.MkdirAll(d.path, cmp.Or(d.perm, os.ModePerm)); err != nil {
return d.path, fmt.Errorf("creating directory %q: %w", d.path, err)
}
return d.path, nil
}
// MustPath calls Path() and calls logFatal on error.
func (d Dir) MustPath() string {
path, err := d.Path()
if err != nil {
logFatal("creating directory:", err)
}
return path
}
// GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf)
// prints the path string instead of the internal struct fields.
func (d Dir) GoString() string {
return fmt.Sprintf("%q", d.path)
}
// MarshalText returns the raw path bytes. No side effects.
func (d Dir) MarshalText() ([]byte, error) {
return []byte(d.path), nil
}
// UnmarshalText sets the path from bytes. No side effects.
func (d *Dir) UnmarshalText(text []byte) error {
d.path = string(text)
if d.perm == 0 {
d.perm = os.ModePerm
}
return nil
}

164
conf/dir_test.go Normal file
View File

@ -0,0 +1,164 @@
package conf_test
import (
"os"
"sync"
"github.com/kr/pretty"
"github.com/navidrome/navidrome/conf"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Dir", func() {
Describe("NewDir", func() {
It("creates a Dir with the given path without side effects", func() {
d := conf.NewDir("/some/path")
Expect(d.String()).To(Equal("/some/path"))
})
})
Describe("String", func() {
It("returns the raw path without creating the directory", func() {
d := conf.NewDir("/nonexistent/path/that/should/not/be/created")
Expect(d.String()).To(Equal("/nonexistent/path/that/should/not/be/created"))
})
})
Describe("Path", func() {
It("creates the directory and returns the path on first call", func() {
dir := GinkgoT().TempDir()
target := dir + "/subdir/nested"
d := conf.NewDir(target)
path, err := d.Path()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(target))
Expect(target).To(BeADirectory())
})
It("is idempotent on subsequent calls", func() {
dir := GinkgoT().TempDir()
target := dir + "/idempotent"
d := conf.NewDir(target)
path1, err1 := d.Path()
path2, err2 := d.Path()
Expect(err1).ToNot(HaveOccurred())
Expect(err2).ToNot(HaveOccurred())
Expect(path1).To(Equal(path2))
Expect(target).To(BeADirectory())
})
It("returns an error when directory cannot be created", func() {
f := GinkgoT().TempDir()
blocker := f + "/blocker"
By("creating a file that blocks directory creation")
Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed())
invalid := blocker + "/subdir"
d := conf.NewDir(invalid)
_, pathErr := d.Path()
Expect(pathErr).To(HaveOccurred())
})
It("returns empty path and no error for empty path", func() {
d := conf.NewDir("")
path, err := d.Path()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(BeEmpty())
})
})
Describe("MustPath", func() {
It("returns the path when directory is created successfully", func() {
dir := GinkgoT().TempDir()
target := dir + "/mustpath"
d := conf.NewDir(target)
path := d.MustPath()
Expect(path).To(Equal(target))
Expect(target).To(BeADirectory())
})
It("calls logFatal on error", func() {
var fatalMsg []any
restore := conf.SetLogFatal(func(args ...any) {
fatalMsg = args
panic("logFatal called")
})
DeferCleanup(restore)
f := GinkgoT().TempDir() + "/blocker"
Expect(os.WriteFile(f, []byte("x"), 0600)).To(Succeed())
invalid := f + "/subdir"
d := conf.NewDir(invalid)
Expect(func() { d.MustPath() }).To(Panic())
Expect(fatalMsg).ToNot(BeEmpty())
})
})
Describe("MarshalText", func() {
It("returns the raw path bytes without side effects", func() {
d := conf.NewDir("/marshal/path")
b, err := d.MarshalText()
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).To(Equal("/marshal/path"))
})
})
Describe("UnmarshalText", func() {
It("sets the path from bytes without side effects", func() {
d := conf.NewDir("")
err := d.UnmarshalText([]byte("/unmarshal/path"))
Expect(err).ToNot(HaveOccurred())
Expect(d.String()).To(Equal("/unmarshal/path"))
})
It("allows round-trip marshal/unmarshal", func() {
d1 := conf.NewDir("/round/trip")
b, err := d1.MarshalText()
Expect(err).ToNot(HaveOccurred())
var d2 conf.Dir
err = d2.UnmarshalText(b)
Expect(err).ToNot(HaveOccurred())
Expect(d2.String()).To(Equal(d1.String()))
})
})
Describe("GoString", func() {
// Regression: pretty.Sprintf("%# v", ...) is used by the
// configuration dump. It must render Dir as a quoted path via
// GoString, not dump the internal struct fields.
It("renders Dir as a quoted path under pretty.Sprintf", func() {
type host struct {
DataFolder conf.Dir
}
h := host{DataFolder: conf.NewDir("./data")}
out := pretty.Sprintf("%# v", h)
Expect(out).To(ContainSubstring(`DataFolder: "./data"`))
Expect(out).ToNot(ContainSubstring("perm:"))
Expect(out).ToNot(ContainSubstring("path:"))
})
It("is safe to copy and use concurrently", func() {
// Regression for the Windows "sync: unlock of unlocked mutex"
// crash that was caused by copying a Dir embedding sync.Once.
// Dir is a plain value type now, but keep the concurrent stress
// test to lock in the property.
dir := GinkgoT().TempDir()
d := conf.NewDir(dir + "/race")
var wg sync.WaitGroup
for range 10 {
wg.Go(func() {
copy1 := d
_ = pretty.Sprintf("%# v", copy1)
_, _ = copy1.Path()
})
}
wg.Wait()
})
})
})

View File

@ -16,6 +16,17 @@ var ToPascalCase = toPascalCase
var ValidateMaxImageUploadSize = validateMaxImageUploadSize
func SetRuntimeInfoForTest(goos string, euid int) func() {
oldGOOS := currentGOOS
oldEUID := getEUID
currentGOOS = func() string { return goos }
getEUID = func() int { return euid }
return func() {
currentGOOS = oldGOOS
getEUID = oldEUID
}
}
func SetLogFatal(f func(...any)) func() {
old := logFatal
logFatal = f

View File

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

View File

@ -3,6 +3,7 @@ package core
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"os"
@ -14,6 +15,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
type Archiver interface {
@ -59,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()
@ -87,7 +97,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
if isMultiDisc {
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
}
return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file)
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
}
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
@ -119,14 +129,19 @@ 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
}
// Add M3U file if requested
if addM3U && len(zippedMfs) > 0 {
plsName := sanitizeName(name)
plsName := str.SanitizeFilename(name)
w, err := z.CreateHeader(&zip.FileHeader{
Name: plsName + ".m3u",
Modified: mfs[0].UpdatedAt,
@ -156,15 +171,32 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
if format != "" && format != "raw" {
ext = format
}
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
}
func sanitizeName(target string) string {
return strings.ReplaceAll(target, "/", "_")
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
}
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,
@ -175,23 +207,6 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
return err
}
var r io.ReadCloser
if format != "raw" && format != "" {
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
} else {
r, err = os.Open(path)
}
if err != nil {
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
return err
}
defer func() {
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
}
}()
_, err = io.Copy(w, r)
if err != nil {
log.Error(ctx, "Error zipping file", "file", path, err)

View File

@ -89,6 +89,32 @@ var _ = Describe("Archiver", func() {
})
})
Context("when the transcode limiter rejects a file", func() {
It("aborts the archive instead of continuing with empty entries", func() {
mfs := model.MediaFiles{
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
}
mfRepo := &mockMediaFileRepository{}
mfRepo.On("GetAll", []model.QueryOptions{{
Filters: squirrel.Eq{"album_id": "1"},
Sort: "album",
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
Return(nil, stream.ErrTooManyTranscodes).Once()
out := new(bytes.Buffer)
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
Expect(err).To(MatchError(stream.ErrTooManyTranscodes))
// NewStream should only have been called once: the loop must bail
// out on the rejection instead of trying every remaining track.
ms.AssertNumberOfCalls(GinkgoT(), "NewStream", 1)
})
})
Context("ZipShare", func() {
It("zips a share correctly", func() {
mfs := model.MediaFiles{

View File

@ -7,12 +7,11 @@ import (
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"time"
_ "github.com/gen2brain/webp"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
@ -38,10 +37,15 @@ var _ = Describe("Artwork", func() {
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
folderRepo = &fakeFolderRepo{}
libRepo := &tests.MockLibraryRepo{}
repoRoot, _ := os.Getwd()
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
ds = &tests.MockDataStore{
MockedTranscoding: &tests.MockTranscodingRepo{},
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
// Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB.
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}}
@ -146,13 +150,61 @@ var _ = Describe("Artwork", func() {
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"),
)
})
Context("LastUpdated", func() {
// Regression test for #5377: LastUpdated feeds the HTTP Last-Modified header.
// It must return max(album.UpdatedAt, ImagesUpdatedAt) so browsers revalidate
// cached cover art when only the image file changes.
now := time.Now().Truncate(time.Second)
DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt",
func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) {
album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt}
folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album})
ar, err := newAlbumArtworkReader(ctx, aw, album.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
Expect(ar.LastUpdated()).To(Equal(expected))
},
Entry("album newer than images", now, now.Add(-1*time.Hour), now),
Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)),
Entry("equal timestamps", now, now, now),
)
})
})
Describe("discArtworkReader", func() {
Context("LastUpdated", func() {
// Regression test for #5377: same bug as albumArtworkReader — disc covers
// must also revalidate when the image file changes, not only when media files do.
now := time.Now().Truncate(time.Second)
DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt",
func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) {
album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt}
folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "al1", DiscNumber: 1, Path: "tests/fixtures/test.mp3"},
})
artID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("al1", 1), nil)
dr, err := newDiscArtworkReader(ctx, aw, artID)
Expect(err).ToNot(HaveOccurred())
Expect(dr.LastUpdated()).To(Equal(expected))
},
Entry("album newer than images", now, now.Add(-1*time.Hour), now),
Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)),
Entry("equal timestamps", now, now, now),
)
})
})
Describe("artistArtworkReader", func() {
Context("Multiple covers", func() {
BeforeEach(func() {
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
LibraryPath: testFileLibPath(repoRoot),
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
}}
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
arMultipleCovers,
@ -171,7 +223,7 @@ var _ = Describe("Artwork", func() {
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(expected))
Expect(filepath.ToSlash(path)).To(HaveSuffix(expected))
},
Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"),
Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"),
@ -415,7 +467,10 @@ var _ = Describe("Artwork", func() {
Name: "Only external",
FolderIDs: []string{"tmp"},
}
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}}
rootLibRepo := &tests.MockLibraryRepo{}
rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}})
ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alCover,
})
@ -503,7 +558,10 @@ var _ = Describe("Artwork", func() {
Name: "Only external",
FolderIDs: []string{"tmp"},
}
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{"cover.png"}}}
folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}}
rootLibRepo := &tests.MockLibraryRepo{}
rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}})
ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover})
conf.Server.CoverArtPriority = "cover.png"

View File

@ -1,9 +1,17 @@
package artwork
import (
"io/fs"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -15,3 +23,49 @@ func TestArtwork(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Artwork Suite")
}
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
// ReadTags is not used by albumArtworkReader, so it is left as a stub.
type osDirFS struct{ fs.FS }
func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil }
// testFileScheme is the URL scheme registered to expose a tempdir as a
// storage.MusicFS for artwork integration tests.
const testFileScheme = "testfile"
// testFileLibPath builds a `testfile://` library URL for the given absolute
// filesystem path. On Windows, the native path (e.g. `C:\foo`) has no leading
// slash after ToSlash, which makes url.Parse treat the drive letter as a
// host. We prepend a `/` so parsing yields `u.Path == /C:/foo`, and the
// registered constructor below strips that leading slash back off.
func testFileLibPath(absPath string) string {
p := filepath.ToSlash(absPath)
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return testFileScheme + "://" + p
}
func init() {
// Register the testfile storage scheme (os.DirFS-backed MusicFS). Used by
// integration tests that need real files but not the taglib extractor.
storage.Register(testFileScheme, func(u url.URL) storage.Storage {
root := u.Path
// Undo the leading slash added by testFileLibPath on Windows so that
// os.Stat / os.DirFS receive a native path like `C:\foo`.
if runtime.GOOS == "windows" && len(root) >= 3 && root[0] == '/' && root[2] == ':' {
root = root[1:]
}
return &osDirStorage{root: filepath.FromSlash(root)}
})
}
type osDirStorage struct{ root string }
func (s *osDirStorage) FS() (storage.MusicFS, error) {
if _, err := os.Stat(s.root); err != nil {
return nil, err
}
return osDirFS{os.DirFS(s.root)}, nil
}

View File

@ -52,7 +52,7 @@ func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID
// Configure cache
conf.Server.ImageCacheSize = cacheSize
conf.Server.CacheFolder = tmpDir
conf.Server.CacheFolder = conf.NewDir(tmpDir)
conf.Server.CoverArtQuality = 75
conf.Server.CoverArtPriority = "cover.*"
@ -169,7 +169,7 @@ func BenchmarkArtworkGetE2EConcurrent(b *testing.B) {
for i := 0; i < b.N; i++ {
var wg sync.WaitGroup
wg.Add(n)
for g := 0; g < n; g++ {
for range n {
go func() {
defer wg.Done()
r, _, err := aw.Get(context.Background(), artID, 300, true)

View File

@ -35,8 +35,8 @@ func generatePNG(t testing.TB, width, height int) []byte {
// generateGradientImage creates an RGBA image with a diagonal gradient pattern.
func generateGradientImage(width, height int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
for y := range height {
for x := range width {
r := uint8((x * 255) / width)
g := uint8((y * 255) / height)
b := uint8(((x + y) * 255) / (width + height))

View File

@ -0,0 +1,377 @@
package artworke2e_test
import (
"testing/fstest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const (
defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external"
defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded"
)
var _ = Describe("Album artwork resolution", func() {
BeforeEach(func() {
setupHarness()
})
When("an album has a single folder with cover.jpg at the album root", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── cover.jpg ← matched by cover.*
It("returns the album-root cover", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
// 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 ← should not win
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg
// └── cover.jpg ← should win (album-root fallback)
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"),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
"Artist/Album/cover.jpg": imageFile("album-root"),
"Artist/Album/CD1/cover.jpg": imageFile("disc1"),
"Artist/Album/CD2/cover.jpg": imageFile("disc2"),
})
scan()
al := firstAlbum()
Expect(al.FolderIDs).To(HaveLen(2),
"sanity check: scanner should treat the two disc subfolders as one multi-disc album")
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
// 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 ← should not win
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── folder.jpg ← should win (album-root fallback)
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"),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
"Artist/Album/folder.jpg": imageFile("album-root"),
"Artist/Album/CD1/folder.jpg": imageFile("disc1"),
"Artist/Album/CD2/folder.jpg": imageFile("disc2"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
// 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)
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"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
// 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/
// ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded")
// └── cover.jpg
It("returns the embedded image", func() {
conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
"Artist/Album/cover.jpg": imageFile("external"),
})
scan()
// Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream.
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
})
})
When("CoverArtPriority lists external first but no external file is present", func() {
// Artist/
// └── Album/
// └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded")
It("falls through to embedded artwork", func() {
conf.Server.CoverArtPriority = "external, embedded"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
})
scan()
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
})
})
When("the only cover file uses uppercase extension and a different case in its name", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── Cover.JPG ← matched case-insensitively by cover.*
It("matches case-insensitively against cover.*", func() {
conf.Server.CoverArtPriority = "cover.*, folder.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/Cover.JPG": imageFile("case-insensitive"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive")))
})
})
When("two cover files have basenames that tie under the natural-sort tiebreaker", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── cover.jpg ← wins (no numeric suffix)
// └── cover.1.jpg
It("prefers the file without a numeric suffix", func() {
conf.Server.CoverArtPriority = "cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("primary"),
"Artist/Album/cover.1.jpg": imageFile("secondary"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
})
})
When("the album has no cover and CoverArtPriority lists only file patterns", func() {
// Artist/
// └── Album/
// └── 01 - Track.mp3 (no image files — returns ErrUnavailable)
It("returns ErrUnavailable", func() {
conf.Server.CoverArtPriority = "cover.*, folder.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
})
scan()
al := firstAlbum()
_, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt))
Expect(err).To(HaveOccurred())
})
})
// Doc scenarios from:
// https://www.navidrome.org/docs/usage/library/artwork/#albums
// Default CoverArtPriority is "cover.*, folder.*, front.*, embedded, external".
When("only folder.jpg is present (cover.* and front.* missing)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── folder.jpg ← matched by folder.*
It("falls through to folder.jpg", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/folder.jpg": imageFile("folder"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
})
})
When("only front.jpg is present (cover.* and folder.* missing)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── front.jpg ← matched by front.*
It("falls through to front.jpg", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/front.jpg": imageFile("front"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front")))
})
})
When("cover.*, folder.*, and front.* all exist in the same folder", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── cover.jpg ← wins (cover.* is first in priority)
// ├── folder.jpg
// └── front.jpg
It("prefers cover.* (first in CoverArtPriority)", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("cover"),
"Artist/Album/folder.jpg": imageFile("folder"),
"Artist/Album/front.jpg": imageFile("front"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
})
})
When("only folder.* and front.* exist (priority order check)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── folder.jpg ← wins (folder.* comes before front.*)
// └── front.jpg
It("prefers folder.* over front.*", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/folder.jpg": imageFile("folder"),
"Artist/Album/front.jpg": imageFile("front"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
})
})
When("three cover files tie by basename and differ only by numeric suffix", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── cover.jpg ← wins (no numeric suffix)
// ├── cover.1.jpg
// └── cover.2.jpg
It("selects the unsuffixed file first regardless of numeric-suffix order", func() {
conf.Server.CoverArtPriority = "cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.2.jpg": imageFile("second"),
"Artist/Album/cover.jpg": imageFile("primary"),
"Artist/Album/cover.1.jpg": imageFile("first"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
})
})
When("CoverArtPriority contains an unknown pattern before a matching one", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── cover.jpg ← wins (unknown "bogus.*" is skipped)
It("skips the unknown pattern and falls through to the matching one", func() {
conf.Server.CoverArtPriority = "bogus.*, cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("cover"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
})
})
When("embedded is first in CoverArtPriority but the track has no embedded art", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3 (no embedded picture)
// └── cover.jpg ← wins (embedded skipped, falls through)
It("falls through to the next priority entry", func() {
conf.Server.CoverArtPriority = "embedded, cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("cover"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
})
})
})

View File

@ -0,0 +1,167 @@
package artworke2e_test
import (
"os"
"path/filepath"
"testing/fstest"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Doc reference:
// https://www.navidrome.org/docs/usage/library/artwork/#artists
// Default ArtistArtPriority is "artist.*, album/artist.*, external".
var _ = Describe("Artist artwork resolution", func() {
BeforeEach(func() {
setupHarness()
})
When("the artist folder contains an artist.jpg", func() {
// Artist/
// ├── artist.jpg ← matched by artist.*
// └── Album/
// └── 01 - Track.mp3
It("returns the artist.* image from the artist folder", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/artist.jpg": imageFile("artist-folder"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
})
})
When("artist.* only exists inside an album folder", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── artist.jpg ← matched by album/artist.*
It("falls through to album/artist.* and returns that image", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/Album/artist.jpg": imageFile("album-artist"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
})
})
When("both the artist folder and an album folder have an artist.* image", func() {
// Artist/
// ├── artist.jpg ← wins (artist.* before album/artist.*)
// └── Album/
// ├── 01 - Track.mp3
// └── artist.jpg
It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/artist.jpg": imageFile("artist-folder"),
"Artist/Album/artist.jpg": imageFile("album-artist"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
})
})
When("an artist has an uploaded image and a matching artist.* file", func() {
// <DataFolder>/
// └── artwork/
// └── artist/
// └── <id>_upload.jpg ← wins (uploaded image beats the priority chain)
// Library:
// Artist/
// ├── artist.jpg (ignored — uploaded image comes first)
// └── Album/
// └── 01 - Track.mp3
It("prefers the uploaded image over any priority-chain match", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/artist.jpg": imageFile("artist-folder"),
})
scan()
ar := soleArtist()
uploaded := ar.ID + "_upload.jpg"
writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded"))
ar.UploadedImage = uploaded
Expect(ds.Artist(ctx).Put(&ar)).To(Succeed())
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded")))
})
})
When("ArtistArtPriority uses album/<arbitrary pattern> (not just album/artist.*)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── artist.jpg ← matched by album/artist.*
It("resolves the pattern against the artist's album image files", func() {
conf.Server.ArtistArtPriority = "album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/Album/artist.jpg": imageFile("album-artist"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
})
})
When("ArtistArtPriority starts with image-folder and ArtistImageFolder has a name-matching image", func() {
// <ArtistImageFolder>/
// └── Artist.jpg ← matched by artist name (image-folder source)
// Library:
// Artist/
// └── Album/
// └── 01 - Track.mp3 (no artist.* present in library)
It("returns the image from the configured artist image folder", func() {
imgFolder := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed())
conf.Server.ArtistImageFolder = imgFolder
conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder")))
})
})
})
func soleArtist() model.Artist {
GinkgoHelper()
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"artist.name": "Artist"},
})
Expect(err).ToNot(HaveOccurred())
if len(artists) == 0 {
Fail("sole artist not found")
return model.Artist{}
}
return artists[0]
}

View File

@ -0,0 +1,371 @@
package artworke2e_test
import (
"fmt"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Disc artwork resolution", func() {
BeforeEach(func() {
setupHarness()
})
When("the album is single-disc with a disc1.jpg in the only folder", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── disc1.jpg ← matched by disc*.*
It("returns the disc1.jpg image (matched as disc*.*)", func() {
conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/disc1.jpg": imageFile("disc1-image"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image")))
})
})
When("the album has no per-disc image and no album cover", func() {
// Artist/
// └── Album/
// └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable)
It("returns ErrUnavailable for the disc lookup", func() {
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
conf.Server.CoverArtPriority = "cover.*, folder.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
_, err := readArtworkOrErr(discID)
Expect(err).To(HaveOccurred())
})
})
When("the album has no per-disc image but has an album cover", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── cover.jpg ← album-level fallback (no disc art present)
It("falls back to the album cover", func() {
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("album-cover"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")))
})
})
When("multiple disc images exist in the same folder (disc1 vs disc10)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── disc1.jpg ← matches request for disc 1
// └── disc10.jpg
It("matches the requested disc number, not a higher-numbered one", func() {
conf.Server.DiscArtPriority = "disc*.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/disc1.jpg": imageFile("disc-one"),
"Artist/Album/disc10.jpg": imageFile("disc-ten"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one")))
})
})
When("a multi-disc album has per-disc covers", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── disc1.jpg ← matches request for disc 1
// └── CD2/
// ├── 01 - Track.mp3
// └── disc2.jpg ← matches request for disc 2
It("returns the requested disc's image", func() {
conf.Server.DiscArtPriority = "disc*.*"
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/disc2.jpg": imageFile("disc-2"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2")))
})
})
// Doc scenarios from:
// https://www.navidrome.org/docs/usage/library/artwork/#disc-cover-art
// Default DiscArtPriority is "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded".
When("a disc subfolder has a cd2.png image", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── disc1.jpg
// └── CD2/
// ├── 01 - Track.mp3
// └── cd2.png ← matched by cd*.* for disc 2
It("matches via the cd*.* pattern", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2")))
})
})
When("a disc subfolder has cover.jpg but no disc*.*/cd*.* image", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg ← matched by cover.* inside disc folder
// └── CD2/
// ├── 01 - Track.mp3
// └── cover.jpg
It("falls through to cover.* inside the disc folder", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"),
"Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover")))
})
})
When("DiscArtPriority is the empty string", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── disc1.jpg (ignored — DiscArtPriority is empty)
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── cd2.png (ignored — DiscArtPriority is empty)
// └── cover.jpg ← used for every disc (album-level fallback)
It("skips every disc-level source and returns the album cover", func() {
conf.Server.DiscArtPriority = ""
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
"Artist/Album/cover.jpg": imageFile("album-cover"),
})
scan()
al := firstAlbum()
for _, n := range []int{1, 2} {
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")),
"disc %d should use the album cover when DiscArtPriority is empty", n)
}
})
})
When("the documented multi-disc layout is used (disc1.jpg + cd2.png + album-root cover.jpg)", func() {
// Artist/
// └── Album/
// ├── disc1/
// │ ├── disc1.jpg ← matched by disc*.* for disc 1
// │ ├── 01 - Track.mp3
// │ └── 02 - Track.mp3
// ├── disc2/
// │ ├── cd2.png ← matched by cd*.* for disc 2
// │ ├── 01 - Track.mp3
// │ └── 02 - Track.mp3
// └── cover.jpg (album-level fallback, unused here)
It("matches the per-disc image for each disc", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}),
"Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}),
"Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}),
"Artist/Album/disc1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/disc2/cd2.png": imageFile("cd-2"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
al := firstAlbum()
disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1")))
Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2")))
})
})
When("discsubtitle keyword matches an image whose stem equals the disc's subtitle", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
// └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword
It("selects the subtitle-named image", func() {
conf.Server.DiscArtPriority = "discsubtitle"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
"Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks")))
})
})
// 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/
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
// └── cover.jpg ← wins (discsubtitle has no match, falls through)
It("falls through to the next priority entry", func() {
conf.Server.DiscArtPriority = "discsubtitle, cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
"Artist/Album/cover.jpg": imageFile("cover"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("cover")))
})
})
})

View File

@ -0,0 +1,184 @@
package artworke2e_test
import (
"bytes"
"context"
_ "embed"
"errors"
"hash/fnv"
"image"
"image/color"
"image/png"
"io"
"maps"
"net/url"
"os"
"path/filepath"
"testing/fstest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.senan.xyz/taglib"
)
// realMP3WithEmbeddedArt is the bytes of the canonical test fixture that
// contains a valid MP3 stream with an embedded picture. Used in the
// embedded-art e2e scenarios where FakeFS's JSON-encoded tag data isn't
// readable by taglib. Swap this into fakeFS.MapFS *after* scanning so the
// scanner still populates EmbedArtPath via the JSON-tagged track, and the
// artwork reader gets real bytes when it calls libFS.Open.
//
//go:embed testdata/embedded_art.mp3
var realMP3WithEmbeddedArt []byte
// embeddedArtBytes is the exact image payload that the artwork reader will
// extract from realMP3WithEmbeddedArt. Computed once via taglib so tests can
// assert byte-for-byte equality — if this ever differs it means the reader
// pulled from a different source.
var embeddedArtBytes = extractEmbeddedArt(realMP3WithEmbeddedArt)
func extractEmbeddedArt(mp3 []byte) []byte {
tf, err := taglib.OpenStream(bytes.NewReader(mp3))
if err != nil {
panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error())
}
defer tf.Close()
images := tf.Properties().Images
if len(images) == 0 {
panic("embedded-art fixture has no embedded images")
}
data, err := tf.Image(0)
if err != nil || len(data) == 0 {
panic("embedded-art fixture: could not read image 0")
}
return data
}
// replaceWithRealMP3 swaps the FakeFS entry at the given library-relative
// path so libFS.Open returns an MP3 stream taglib can parse.
func replaceWithRealMP3(relPath string) {
GinkgoHelper()
fakeFS.MapFS[relPath] = &fstest.MapFile{Data: realMP3WithEmbeddedArt}
}
// placeholderBytes returns the bundled album-placeholder image bytes — the
// same stream the artwork reader emits when every source falls through.
func placeholderBytes() []byte {
GinkgoHelper()
r, err := resources.FS().Open(consts.PlaceholderAlbumArt)
Expect(err).ToNot(HaveOccurred())
defer r.Close()
data, err := io.ReadAll(r)
Expect(err).ToNot(HaveOccurred())
return data
}
// writeUploadedImage drops `filename` into <DataFolder>/artwork/<entity>/ with
// the given bytes, matching the on-disk layout expected by
// model.UploadedImagePath.
func writeUploadedImage(entity, filename string, data []byte) {
GinkgoHelper()
dir := filepath.Dir(model.UploadedImagePath(entity, filename))
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, filename), data, 0600)).To(Succeed())
}
func newNoopFFmpeg() *tests.MockFFmpeg {
ff := tests.NewMockFFmpeg("")
ff.Error = errors.New("noop")
return ff
}
// trackFile builds a FakeFS MP3 entry with optional tag overrides.
func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile {
tags := storagetest.Track(num, title)
for _, e := range extra {
maps.Copy(tags, e)
}
return storagetest.MP3(tags)
}
// imageFile builds a label-keyed image entry. The bytes are deterministic
// per-label so tests can assert which file won.
func imageFile(label string) *fstest.MapFile {
return &fstest.MapFile{Data: []byte("image:" + label)}
}
// realPNG builds a minimal 2x2 PNG with a color derived from label. Needed by
// tests that feed the bytes into image.Decode (e.g. playlist tiled covers).
func realPNG(label string) *fstest.MapFile {
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
// Derive a deterministic color per label.
h := fnv.New32a()
_, _ = h.Write([]byte(label))
sum := h.Sum32()
c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255}
for y := range 2 {
for x := range 2 {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
Expect(png.Encode(&buf, img)).To(Succeed())
return &fstest.MapFile{Data: buf.Bytes()}
}
// imageBytes returns the bytes that imageFile(label) writes.
func imageBytes(label string) []byte { return imageFile(label).Data }
// setLayout populates fakeFS with the given map. Call after setupHarness.
// All paths must be forward-slash and relative (no leading "/").
func setLayout(files fstest.MapFS) {
GinkgoHelper()
fakeFS.SetFiles(files)
}
func readArtwork(artID model.ArtworkID) []byte {
GinkgoHelper()
r, _, err := aw.Get(ctx, artID, 0, false)
Expect(err).ToNot(HaveOccurred())
defer r.Close()
b, err := io.ReadAll(r)
Expect(err).ToNot(HaveOccurred())
return b
}
func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) {
r, _, err := aw.Get(ctx, artID, 0, false)
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
// noopProvider implements external.Provider with not-found returns so the
// "external" priority entry never produces a result.
type noopProvider struct{}
func (n *noopProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) {
return nil, model.ErrNotFound
}
func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) {
return nil, model.ErrNotFound
}
func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
return nil, nil
}
func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) {
return nil, nil
}
func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) {
return nil, model.ErrNotFound
}
func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) {
return nil, model.ErrNotFound
}
var _ external.Provider = (*noopProvider)(nil)

View File

@ -0,0 +1,110 @@
package artworke2e_test
import (
"testing/fstest"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Doc reference:
// https://www.navidrome.org/docs/usage/library/artwork/#mediafiles
// Navidrome resolves mediafile artwork in this order:
// 1. Embedded image from the mediafile itself
// 2. For multi-disc albums, disc-level artwork
// 3. Album cover art
//
// FakeFS cannot synthesize taglib-readable embedded JPEGs, so scenario (1)
// is covered by the existing embedded-art album tests (which currently
// Skip under FakeFS). The tests below cover (2) and (3): the fallback
// chain for tracks without embedded art.
var _ = Describe("MediaFile artwork fallback", func() {
BeforeEach(func() {
setupHarness()
})
When("a multi-disc album track has no embedded art", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── disc1.jpg
// ├── CD2/
// │ ├── 01 - Track.mp3 ← track requested
// │ └── disc2.jpg ← wins (disc-level before album-level)
// └── cover.jpg
It("falls back to the disc-level artwork (not the album cover)", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/disc2.jpg": imageFile("disc-2"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3")
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2")))
})
})
When("a single-disc album track has no embedded art", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3 ← track requested
// └── cover.jpg ← wins (album-level fallback, no disc subfolder)
It("falls back to the album cover", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("album-cover"),
})
scan()
mf := mediafileOn("Artist/Album/01 - Track.mp3")
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover")))
})
})
When("a multi-disc album track has no embedded art and the disc has no disc-level image", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ └── 01 - Track.mp3
// ├── CD2/
// │ └── 01 - Track.mp3 ← track requested
// └── cover.jpg ← wins (no disc image → album-level fallback)
It("falls through from disc to album cover", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3")
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
})
func mediafileOn(relPath string) model.MediaFile {
GinkgoHelper()
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Like{"media_file.path": relPath},
})
Expect(err).ToNot(HaveOccurred())
if len(mfs) == 0 {
Fail("mediafile not found: " + relPath)
return model.MediaFile{}
}
return mfs[0]
}

View File

@ -0,0 +1,158 @@
package artworke2e_test
import (
"os"
"path/filepath"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Playlist artwork resolves in this priority order:
// 1. Uploaded image (<DataFolder>/artwork/playlist/<file>)
// 2. Sidecar image next to the .m3u file (same basename, any image ext)
// 3. ExternalImageURL (http/https requires EnableM3UExternalAlbumArt; local path always allowed)
// 4. Generated 2x2 tiled cover from the playlist's albums
// 5. Album placeholder image
//
// The library FS is FakeFS, but uploaded/sidecar/local-external images are
// real files on disk — the reader reads them via os.Open, so the tests
// place them in a real tempdir under DataFolder.
var _ = Describe("Playlist artwork resolution", func() {
BeforeEach(func() {
setupHarness()
})
When("a playlist has an uploaded image", func() {
// <DataFolder>/
// └── artwork/
// └── playlist/
// └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority)
It("returns the uploaded image bytes", func() {
writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", imageBytes("playlist-upload"))
pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"})
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload")))
})
})
When("a playlist has no uploaded image but a sidecar image beside its .m3u file", func() {
// <tempdir>/
// ├── MyList.m3u
// └── MyList.jpg ← matched by sidecar (same basename, case-insensitive)
It("returns the sidecar image", func() {
dir := GinkgoT().TempDir()
m3uPath := filepath.Join(dir, "MyList.m3u")
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), imageBytes("sidecar"), 0600)).To(Succeed())
pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath})
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar")))
})
})
When("a playlist's sidecar uses a different extension case", func() {
// <tempdir>/
// ├── MyList.m3u
// └── MyList.PNG ← matched case-insensitively
It("matches case-insensitively", func() {
dir := GinkgoT().TempDir()
m3uPath := filepath.Join(dir, "MyList.m3u")
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), imageBytes("sidecar-png"), 0600)).To(Succeed())
pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath})
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png")))
})
})
When("a playlist has an ExternalImageURL pointing to a local file", func() {
// <tempdir>/
// └── cover.jpg ← absolute path stored in ExternalImageURL
It("returns the local file regardless of EnableM3UExternalAlbumArt", func() {
conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, imageBytes("external-local"), 0600)).To(Succeed())
pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath})
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("external-local")))
})
})
When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() {
// (no local files — http source is gated off, reader falls through to placeholder)
It("skips the URL and falls through to the bundled placeholder", func() {
conf.Server.EnableM3UExternalAlbumArt = false
pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"})
Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes()))
})
})
When("a playlist has no images and no tracks", func() {
// (reader falls all the way through to the bundled album placeholder)
It("returns the album placeholder", func() {
pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"})
Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes()))
})
})
When("a playlist has no uploaded/sidecar/external image but has tracks with album covers", func() {
// Library:
// Artist/
// ├── AlbumA/
// │ ├── 01 - Track.mp3
// │ └── cover.png (real PNG — wins as tile 1 source)
// └── AlbumB/
// ├── 01 - Track.mp3
// └── cover.png (real PNG — wins as tile 2 source)
// Playlist "pl-7" references tracks from both albums, so the reader
// generates a 2x2 tiled cover from 2 distinct album art tiles (the
// tiled generator mirrors when it has fewer than 4 unique tiles).
It("generates a tiled cover from album art", func() {
conf.Server.CoverArtPriority = "cover.*"
setLayout(fstest.MapFS{
"Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}),
"Artist/AlbumA/cover.png": realPNG("albumA"),
"Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}),
"Artist/AlbumB/cover.png": realPNG("albumB"),
})
scan()
// Pull the scanned mediafile IDs so we can attach them to the playlist.
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(2))
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed())
data := readArtwork(pl.CoverArtID())
// The tiled cover is a PNG-encoded 600x600 image (tileSize const).
// Exact bytes vary (random album order), so assert format + non-trivial size.
Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}))
Expect(len(data)).To(BeNumerically(">", 1000))
})
})
})
func putPlaylist(pl model.Playlist) model.Playlist {
GinkgoHelper()
if pl.OwnerID == "" {
pl.OwnerID = "admin-1"
}
Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed())
return pl
}

View File

@ -0,0 +1,42 @@
package artworke2e_test
import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Radio artwork resolution", func() {
BeforeEach(func() {
setupHarness()
})
When("a radio has an uploaded image", func() {
// <DataFolder>/
// └── artwork/
// └── radio/
// └── rd-1_logo.jpg ← matched by UploadedImagePath()
It("returns the uploaded image bytes", func() {
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", imageBytes("radio-logo"))
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
Expect(ds.Radio(ctx).Put(&rd)).To(Succeed())
artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("radio-logo")))
})
})
When("a radio has no uploaded image", func() {
// (no files on disk — reader has no sources to fall back to)
It("returns ErrUnavailable", func() {
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
Expect(ds.Radio(ctx).Put(&rd)).To(Succeed())
artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil)
_, err := readArtworkOrErr(artID)
Expect(err).To(HaveOccurred())
})
})
})

View File

@ -0,0 +1,106 @@
package artworke2e_test
import (
"context"
"path/filepath"
"testing"
_ "github.com/navidrome/navidrome/adapters/gotaglib"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestArtworkE2E(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Artwork E2E Suite")
}
const fakeLibScheme = "artworkfake"
const fakeLibPath = fakeLibScheme + ":///music"
var (
ctx context.Context
ds *tests.MockDataStore
aw artwork.Artwork
fakeFS *storagetest.FakeFS
)
// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps
// the file open for the whole suite, and Ginkgo's per-spec TempDir cleanup
// can't unlink a file with a live handle on Windows. A suite-level tempdir
// combined with an AfterSuite close avoids the lock conflict.
var suiteDBTempDir string
var _ = BeforeSuite(func() {
suiteDBTempDir = GinkgoT().TempDir()
})
var _ = AfterSuite(func() {
db.Close(GinkgoT().Context())
})
func setupHarness() {
DeferCleanup(configtest.SetupConfig())
tempDir := GinkgoT().TempDir()
// 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 = conf.NewDir(tempDir)
conf.Server.MusicFolder = fakeLibPath
conf.Server.DevExternalScanner = false
conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call
conf.Server.EnableExternalServices = false
db.Db().SetMaxOpenConns(1)
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true})
db.Init(ctx)
DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) })
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
fakeFS = &storagetest.FakeFS{}
storagetest.Register(fakeLibScheme, fakeFS)
aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{})
}
func scan() {
GinkgoHelper()
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
_, err := s.ScanAll(ctx, true)
Expect(err).ToNot(HaveOccurred())
}
func firstAlbum() model.Album {
GinkgoHelper()
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
return albums[0]
}

Binary file not shown.

View File

@ -0,0 +1,44 @@
package artwork
import (
"context"
"path/filepath"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/model"
)
// libraryView bundles the MusicFS for a library with its absolute root path,
// so readers can open library-relative paths through FS and compose absolute
// paths (for ffmpeg, which is path-based) via Abs.
type libraryView struct {
FS storage.MusicFS
absRoot string
}
// Abs returns the absolute path for a library-relative path. Returns "" for an
// empty rel so callers (fromFFmpegTag) can treat it as "no path available".
func (v libraryView) Abs(rel string) string {
if rel == "" {
return ""
}
return filepath.Join(v.absRoot, rel)
}
// loadLibraryView resolves the MusicFS and absolute root path in a single
// library lookup.
func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) {
lib, err := ds.Library(ctx).Get(libID)
if err != nil {
return libraryView{}, err
}
s, err := storage.For(lib.Path)
if err != nil {
return libraryView{}, err
}
fs, err := s.FS()
if err != nil {
return libraryView{}, err
}
return libraryView{FS: fs, absRoot: lib.Path}, nil
}

View File

@ -0,0 +1,45 @@
package artwork
import (
"context"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("loadLibraryView", Ordered, func() {
var ctx context.Context
var ds *tests.MockDataStore
BeforeAll(func() {
storagetest.Register("fake", &storagetest.FakeFS{})
})
BeforeEach(func() {
ctx = GinkgoT().Context()
ds = &tests.MockDataStore{MockedLibrary: &tests.MockLibraryRepo{}}
})
It("returns a view for a library backed by registered storage", func() {
Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed())
lib, err := loadLibraryView(ctx, ds, 1)
Expect(err).ToNot(HaveOccurred())
Expect(lib.FS).ToNot(BeNil())
Expect(lib.absRoot).To(Equal("fake:///music"))
})
It("returns an error when the library does not exist", func() {
_, err := loadLibraryView(ctx, ds, 999)
Expect(err).To(HaveOccurred())
})
It("returns an error when the library path uses an unregistered scheme", func() {
Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed())
_, err := loadLibraryView(ctx, ds, 2)
Expect(err).To(HaveOccurred())
})
})

View File

@ -7,29 +7,29 @@ import (
"errors"
"fmt"
"io"
"path/filepath"
"path"
"slices"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external"
"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"
)
type albumArtworkReader struct {
cacheKey
a *artwork
provider external.Provider
album model.Album
updatedAt *time.Time
imgFiles []string
rootFolder string
a *artwork
provider external.Provider
album model.Album
updatedAt *time.Time
imgFiles []string // library-relative, forward-slash, no leading slash
lib libraryView
}
func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) {
@ -41,19 +41,22 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
if err != nil {
return nil, err
}
lib, err := loadLibraryView(ctx, artwork.ds, al.LibraryID)
if err != nil {
return nil, err
}
a := &albumArtworkReader{
a: artwork,
provider: provider,
album: *al,
updatedAt: imagesUpdateAt,
imgFiles: imgFiles,
rootFolder: core.AbsolutePath(ctx, artwork.ds, al.LibraryID, ""),
a: artwork,
provider: provider,
album: *al,
updatedAt: imagesUpdateAt,
imgFiles: imgFiles,
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
}
@ -72,7 +75,7 @@ func (a *albumArtworkReader) Key() string {
)
}
func (a *albumArtworkReader) LastUpdated() time.Time {
return a.album.UpdatedAt
return a.lastUpdate
}
func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
@ -86,12 +89,15 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":
embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath)
ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath))
embedRel := a.album.EmbedArtPath
ff = append(ff,
fromTag(ctx, a.lib.FS, embedRel),
fromFFmpegTag(ctx, ffmpeg, a.lib.Abs(embedRel)),
)
case pattern == "external":
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider))
case len(a.imgFiles) > 0:
ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern))
ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, pattern))
}
}
return ff
@ -112,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)
}
}
}
@ -132,13 +141,13 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
var imgFiles []string
var updatedAt time.Time
for _, f := range folders {
path := f.AbsolutePath()
paths = append(paths, path)
paths = append(paths, f.AbsolutePath())
if f.ImagesUpdatedAt.After(updatedAt) {
updatedAt = f.ImagesUpdatedAt
}
rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/")
for _, img := range f.ImageFiles {
imgFiles = append(imgFiles, filepath.Join(path, img))
imgFiles = append(imgFiles, path.Join(rel, img))
}
}
@ -150,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
@ -168,23 +186,21 @@ 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)
b = strings.ToLower(b)
// Extract base filenames without extensions
baseA := strings.TrimSuffix(filepath.Base(a), filepath.Ext(a))
baseB := strings.TrimSuffix(filepath.Base(b), filepath.Ext(b))
baseA := strings.TrimSuffix(path.Base(a), path.Ext(a))
baseB := strings.TrimSuffix(path.Base(b), path.Ext(b))
// Compare base names first, then full paths if equal
// Compare base names first, then prefer shallower paths, then full path as tiebreaker
return cmp.Or(
natural.Compare(baseA, baseB),
cmp.Compare(strings.Count(a, "/"), strings.Count(b, "/")),
natural.Compare(a, b),
)
}

View File

@ -3,7 +3,6 @@ package artwork
import (
"context"
"errors"
"path/filepath"
"time"
"github.com/navidrome/navidrome/model"
@ -69,11 +68,11 @@ var _ = Describe("Album Artwork Reader", func() {
// Files should be sorted by base filename without extension, then by full path
// "back" < "cover", so back.jpg comes first
// Then all cover.jpg files, sorted by path
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg")))
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg")))
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg")))
Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg")))
Expect(imgFiles[4]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.1.jpg")))
Expect(imgFiles[0]).To(Equal("Artist/Album/Disc1/back.jpg"))
Expect(imgFiles[1]).To(Equal("Artist/Album/Disc1/cover.jpg"))
Expect(imgFiles[2]).To(Equal("Artist/Album/Disc2/cover.jpg"))
Expect(imgFiles[3]).To(Equal("Artist/Album/Disc10/cover.jpg"))
Expect(imgFiles[4]).To(Equal("Artist/Album/Disc1/cover.1.jpg"))
})
It("prioritizes files without numeric suffixes", func() {
@ -92,9 +91,9 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(imgFiles).To(HaveLen(3))
// cover.jpg should come first because "cover" < "cover.1" < "cover.2"
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.1.jpg")))
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/cover.2.jpg")))
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
Expect(imgFiles[1]).To(Equal("Artist/Album/cover.1.jpg"))
Expect(imgFiles[2]).To(Equal("Artist/Album/cover.2.jpg"))
})
It("handles case-insensitive sorting", func() {
@ -113,9 +112,9 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(imgFiles).To(HaveLen(3))
// Files should be sorted case-insensitively: BACK, cover, Folder
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/BACK.jpg")))
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg")))
Expect(imgFiles[0]).To(Equal("Artist/Album/BACK.jpg"))
Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg"))
Expect(imgFiles[2]).To(Equal("Artist/Album/Folder.jpg"))
})
It("includes images from parent folder for multi-disc albums", func() {
@ -142,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"},
}
@ -151,8 +151,8 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(err).ToNot(HaveOccurred())
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
Expect(imgFiles).To(HaveLen(2))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/back.jpg")))
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
Expect(imgFiles[0]).To(Equal("Artist/Album/back.jpg"))
Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg"))
})
It("does not query parent when parent ID is already in album folders", func() {
@ -179,7 +179,7 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
// Get should not have been called (parent already in folder set)
Expect(repo.getCallCount).To(Equal(0))
})
@ -209,14 +209,88 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist1/Album/part1/cover.jpg")))
Expect(imgFiles[0]).To(Equal("Artist1/Album/part1/cover.jpg"))
// Get should not have been called (different parents)
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,11 +306,39 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
// Get should not have been called (single folder, no parent lookup)
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
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{
{
@ -290,7 +392,7 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/CD1/cover.jpg")))
Expect(imgFiles[0]).To(Equal("Artist/Album/CD1/cover.jpg"))
Expect(repo.getCallCount).To(Equal(1))
})
})

View File

@ -7,6 +7,7 @@ import (
"io"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"strings"
@ -35,6 +36,7 @@ type artistReader struct {
artistFolder string
imgFiles []string
imgFolderImgPath string // cached path from ArtistImageFolder lookup
lib libraryView
}
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
@ -60,12 +62,20 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A
if err != nil {
return nil, err
}
var lib libraryView
if len(als) > 0 {
lib, err = loadLibraryView(ctx, artwork.ds, als[0].LibraryID)
if err != nil {
return nil, err
}
}
a := &artistReader{
a: artwork,
provider: provider,
artist: *ar,
artistFolder: artistFolder,
imgFiles: imgFiles,
lib: lib,
}
// TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can
// change _after_ retrieving from external sources, making the key invalid
@ -124,38 +134,62 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
case pattern == "image-folder":
ff = append(ff, a.fromArtistImageFolder(ctx))
case strings.HasPrefix(pattern, "album/"):
ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
if a.lib.FS != nil {
ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
}
default:
ff = append(ff, fromArtistFolder(ctx, a.artistFolder, pattern))
ff = append(ff, fromArtistFolder(ctx, a.lib.FS, a.lib.absRoot, a.artistFolder, pattern))
}
}
return ff
}
func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
// fromArtistFolder walks up from artistFolder toward libPath looking for a
// file matching pattern. Traversal is bounded by both maxArtistFolderTraversalDepth
// and the library root: once we reach libPath (or if artistFolder is outside
// libPath), the walk stops. All reads go through libFS, which keeps artwork
// resolution scoped to the configured library.
func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if libFS == nil {
return nil, "", fmt.Errorf("artist folder lookup unavailable")
}
rel, err := filepath.Rel(libPath, artistFolder)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath)
}
// fs.Glob / path.Join below expect forward-slash paths; filepath.Rel may
// return backslash separators on Windows.
rel = filepath.ToSlash(rel)
current := artistFolder
for range maxArtistFolderTraversalDepth {
if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
return reader, path, nil
reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern)
if err == nil {
return reader, hit, nil
}
parent := filepath.Dir(current)
if parent == current {
break
if rel == "." {
break // reached library root; don't traverse above it
}
current = parent
rel = path.Dir(rel)
current = filepath.Dir(current)
}
return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories`, pattern, artistFolder)
return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder)
}
}
func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadCloser, string, error) {
log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", folder)
fsys := os.DirFS(folder)
matches, err := fs.Glob(fsys, pattern)
// findImageInFolder globs libFS at relFolder for pattern and returns the first
// matching image. absFolder is used only for the returned display path and log
// messages so callers see absolute-looking paths consistent with the rest of
// the artwork pipeline.
func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) {
log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", absFolder)
globPattern := pattern
if relFolder != "." {
globPattern = path.Join(escapeGlobLiteral(relFolder), pattern)
}
matches, err := fs.Glob(libFS, globPattern)
if err != nil {
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", folder, err)
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err)
return nil, "", err
}
@ -172,18 +206,30 @@ func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadClos
// suffixes (e.g., artist.jpg before artist.1.jpg)
slices.SortFunc(imagePaths, compareImageFiles)
// Try to open files in sorted order
for _, p := range imagePaths {
filePath := filepath.Join(folder, p)
f, err := os.Open(filePath)
f, err := libFS.Open(p)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
log.Warn(ctx, "Could not open cover art file", "file", p, err)
continue
}
return f, filePath, nil
_, name := path.Split(p)
return f, filepath.Join(absFolder, name), nil
}
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, folder)
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder)
}
func escapeGlobLiteral(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch r {
case '\\', '*', '?', '[', ']':
b.WriteByte('\\')
}
b.WriteRune(r)
}
return b.String()
}
func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) {

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"time"
@ -66,7 +67,7 @@ var _ = Describe("artistArtworkReader", func() {
}
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
Expect(err).ToNot(HaveOccurred())
Expect(folder).To(Equal("/music/artist"))
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
Expect(upd).To(Equal(expectedUpdTime))
})
})
@ -92,7 +93,7 @@ var _ = Describe("artistArtworkReader", func() {
}
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
Expect(err).ToNot(HaveOccurred())
Expect(folder).To(Equal("/music/artist"))
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
Expect(upd).To(Equal(expectedUpdTime))
})
})
@ -117,12 +118,14 @@ var _ = Describe("artistArtworkReader", func() {
var (
ctx context.Context
tempDir string
libFS fs.FS
testFunc sourceFunc
)
BeforeEach(func() {
ctx = context.Background()
tempDir = GinkgoT().TempDir()
libFS = os.DirFS(tempDir)
})
When("artist folder contains matching image", func() {
@ -134,7 +137,7 @@ var _ = Describe("artistArtworkReader", func() {
artistImagePath := filepath.Join(artistDir, "artist.jpg")
Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("finds and returns the image", func() {
@ -151,6 +154,30 @@ var _ = Describe("artistArtworkReader", func() {
})
})
When("artist folder name contains glob metacharacters", func() {
BeforeEach(func() {
artistDir := filepath.Join(tempDir, "Artist [Live]")
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
artistImagePath := filepath.Join(artistDir, "artist.jpg")
Expect(os.WriteFile(artistImagePath, []byte("bracketed artist image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("treats the folder path literally when globbing through the library fs", func() {
reader, path, err := testFunc()
Expect(err).ToNot(HaveOccurred())
Expect(reader).ToNot(BeNil())
Expect(path).To(ContainSubstring("Artist [Live]" + string(filepath.Separator) + "artist.jpg"))
data, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(Equal("bracketed artist image"))
reader.Close()
})
})
When("artist folder is empty but parent contains image", func() {
BeforeEach(func() {
// Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/
@ -163,7 +190,7 @@ var _ = Describe("artistArtworkReader", func() {
artistImagePath := filepath.Join(parentDir, "artist.jpg")
Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("finds image in parent directory", func() {
@ -191,7 +218,7 @@ var _ = Describe("artistArtworkReader", func() {
artistImagePath := filepath.Join(grandparentDir, "artist.jpg")
Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("finds image in grandparent directory", func() {
@ -220,7 +247,7 @@ var _ = Describe("artistArtworkReader", func() {
Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("prioritizes the closest (artist folder) image", func() {
@ -246,7 +273,7 @@ var _ = Describe("artistArtworkReader", func() {
Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("returns the first valid image file in sorted order", func() {
@ -273,7 +300,7 @@ var _ = Describe("artistArtworkReader", func() {
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() {
@ -301,7 +328,7 @@ var _ = Describe("artistArtworkReader", func() {
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "*.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "*.*")
})
It("sorts case-insensitively", func() {
@ -327,7 +354,7 @@ var _ = Describe("artistArtworkReader", func() {
// Create non-matching files
Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("returns an error", func() {
@ -346,7 +373,7 @@ var _ = Describe("artistArtworkReader", func() {
artistDir := filepath.Join(tempDir, "artist")
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("handles root boundary gracefully", func() {
@ -367,7 +394,7 @@ var _ = Describe("artistArtworkReader", func() {
restrictedFile := filepath.Join(artistDir, "artist.jpg")
Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("logs warning and continues searching", func() {
@ -397,7 +424,7 @@ var _ = Describe("artistArtworkReader", func() {
Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed())
// The fromArtistFolder is called with the artist folder path
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("finds artist.jpg in artist folder for single album artist", func() {
@ -425,7 +452,7 @@ var _ = Describe("artistArtworkReader", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tempDir = GinkgoT().TempDir()
conf.Server.DataFolder = tempDir
conf.Server.DataFolder = conf.NewDir(tempDir)
// Create the artwork/artist directory
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed())

View File

@ -5,7 +5,7 @@ import (
"crypto/md5"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
@ -13,10 +13,10 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
)
type discArtworkReader struct {
@ -24,10 +24,11 @@ type discArtworkReader struct {
a *artwork
album model.Album
discNumber int
imgFiles []string
discFolders map[string]bool
imgFiles []string // library-relative, forward-slash, no leading slash
discFoldersRel map[string]bool // library-relative folder paths
isMultiFolder bool
firstTrackPath string
firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs
lib libraryView
updatedAt *time.Time
}
@ -57,18 +58,23 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
return nil, err
}
// Build disc folder set and find first track
discFolders := make(map[string]bool)
var firstTrackPath string
lib, err := loadLibraryView(ctx, a.ds, al.LibraryID)
if err != nil {
return nil, err
}
// Build disc folder set and find first track. mf.Path is already library-relative.
var firstTrackRel string
allFolderIDs := make(map[string]bool)
for _, mf := range mfs {
allFolderIDs[mf.FolderID] = true
if firstTrackPath == "" {
firstTrackPath = mf.Path
if firstTrackRel == "" {
firstTrackRel = filepath.ToSlash(mf.Path)
}
}
// Resolve folder IDs to absolute paths
// Resolve folder IDs to library-relative paths
discFoldersRel := make(map[string]bool)
if len(allFolderIDs) > 0 {
folderIDs := make([]string, 0, len(allFolderIDs))
for id := range allFolderIDs {
@ -81,7 +87,8 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
return nil, err
}
for _, f := range folders {
discFolders[f.AbsolutePath()] = true
rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/")
discFoldersRel[rel] = true
}
}
@ -92,16 +99,16 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
album: *al,
discNumber: discNumber,
imgFiles: imgFiles,
discFolders: discFolders,
discFoldersRel: discFoldersRel,
isMultiFolder: isMultiFolder,
firstTrackPath: core.AbsolutePath(ctx, a.ds, al.LibraryID, firstTrackPath),
firstTrackRel: firstTrackRel,
lib: lib,
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
}
@ -116,7 +123,7 @@ func (d *discArtworkReader) Key() string {
}
func (d *discArtworkReader) LastUpdated() time.Time {
return d.album.UpdatedAt
return d.lastUpdate
}
func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
@ -133,7 +140,10 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":
ff = append(ff, fromTag(ctx, d.firstTrackPath), fromFFmpegTag(ctx, ffmpeg, d.firstTrackPath))
ff = append(ff,
fromTag(ctx, d.lib.FS, d.firstTrackRel),
fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)),
)
case pattern == "external":
// Not supported for disc art, silently ignore
case pattern == "discsubtitle":
@ -152,12 +162,12 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp
func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc {
return func() (io.ReadCloser, string, error) {
for _, file := range d.imgFiles {
_, name := filepath.Split(file)
stem := strings.TrimSuffix(name, filepath.Ext(name))
name := path.Base(file)
stem := strings.TrimSuffix(name, path.Ext(name))
if !strings.EqualFold(stem, subtitle) {
continue
}
f, err := os.Open(file)
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Could not open disc art file", "file", file, err)
continue
@ -214,8 +224,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
return func() (io.ReadCloser, string, error) {
var fallbacks []string
for _, file := range d.imgFiles {
_, name := filepath.Split(file)
name = strings.ToLower(name)
name := strings.ToLower(path.Base(file))
match, err := filepath.Match(pattern, name)
if err != nil {
log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file)
@ -230,7 +239,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
if num != d.discNumber {
continue
}
f, err := os.Open(file)
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Could not open disc art file", "file", file, err)
continue
@ -239,14 +248,14 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
}
}
if d.isMultiFolder && !d.discFolders[filepath.Dir(file)] {
if d.isMultiFolder && !d.discFoldersRel[path.Dir(file)] {
continue
}
fallbacks = append(fallbacks, file)
}
for _, file := range fallbacks {
f, err := os.Open(file)
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Could not open disc art file", "file", file, err)
continue

View File

@ -74,20 +74,27 @@ var _ = Describe("Disc Artwork Reader", func() {
tmpDir = GinkgoT().TempDir()
})
createFile := func(path string) string {
fullPath := filepath.Join(tmpDir, filepath.FromSlash(path))
// createFile creates the file on disk and returns its library-relative forward-slash path.
createFile := func(relPath string) string {
fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
return fullPath
return relPath
}
// removeFile removes a library-relative file from disk.
removeFile := func(relPath string) {
Expect(os.Remove(filepath.Join(tmpDir, filepath.FromSlash(relPath)))).To(Succeed())
}
It("matches file with disc number in single-folder album", func() {
f1 := createFile("album/disc1.jpg")
f2 := createFile("album/disc2.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: 1,
imgFiles: []string{f1, f2},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
@ -101,9 +108,10 @@ var _ = Describe("Disc Artwork Reader", func() {
It("matches file without number in single-folder album (shared disc art)", func() {
f1 := createFile("album/cover.png")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: 1,
imgFiles: []string{f1},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "cover.*")
@ -118,9 +126,10 @@ var _ = Describe("Disc Artwork Reader", func() {
f1 := createFile("album/shellac.png")
makeReader := func(discNum int) *discArtworkReader {
return &discArtworkReader{
discNumber: discNum,
imgFiles: []string{f1},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: discNum,
imgFiles: []string{f1},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
}
@ -139,9 +148,10 @@ var _ = Describe("Disc Artwork Reader", func() {
f2 := createFile("album/disc1.jpg")
f3 := createFile("album/disc2.jpg")
reader := &discArtworkReader{
discNumber: 2,
imgFiles: []string{f1, f2, f3},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: 2,
imgFiles: []string{f1, f2, f3},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
@ -163,9 +173,10 @@ var _ = Describe("Disc Artwork Reader", func() {
f1 := createFile("album/cover.png")
f2 := createFile("album/disc1.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: 1,
imgFiles: []string{f1, f2},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*")
@ -191,9 +202,10 @@ var _ = Describe("Disc Artwork Reader", func() {
createFile("album/disc2.jpg"),
}
reader := &discArtworkReader{
discNumber: discNumber,
imgFiles: files,
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: discNumber,
imgFiles: files,
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
@ -210,12 +222,13 @@ var _ = Describe("Disc Artwork Reader", func() {
It("tries the next fallback candidate when the first one cannot be opened", func() {
f1 := createFile("album/cover.jpg")
f2 := createFile("album/cover.png")
// Remove f1 so os.Open will fail on it; f2 should still win.
Expect(os.Remove(f1)).To(Succeed())
// Remove f1 so Open will fail on it; f2 should still win.
removeFile(f1)
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: 1,
imgFiles: []string{f1, f2},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "cover.*")
@ -234,15 +247,16 @@ var _ = Describe("Disc Artwork Reader", func() {
// that first file is unreadable.
f1 := createFile("album/stale/cover.png")
f2 := createFile("album/cover.png")
Expect(os.Remove(f1)).To(Succeed())
removeFile(f1)
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFolders: map[string]bool{
filepath.Join(tmpDir, "album"): true,
filepath.Join(tmpDir, "album/stale"): true,
discFoldersRel: map[string]bool{
"album": true,
"album/stale": true,
},
isMultiFolder: true,
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "cover.png")
@ -260,9 +274,10 @@ var _ = Describe("Disc Artwork Reader", func() {
createFile("album/disc2.jpg"),
}
reader := &discArtworkReader{
discNumber: discNumber,
imgFiles: files,
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: discNumber,
imgFiles: files,
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, pattern)
@ -282,10 +297,11 @@ var _ = Describe("Disc Artwork Reader", func() {
f1 := createFile("album/cd1/disc.jpg")
f2 := createFile("album/cd2/disc.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true},
isMultiFolder: true,
discNumber: 1,
imgFiles: []string{f1, f2},
discFoldersRel: map[string]bool{"album/cd1": true},
isMultiFolder: true,
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
@ -300,10 +316,11 @@ var _ = Describe("Disc Artwork Reader", func() {
// disc2.jpg in cd1 folder should match disc 2, not disc 1
f1 := createFile("album/cd1/disc2.jpg")
reader := &discArtworkReader{
discNumber: 2,
imgFiles: []string{f1},
discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true},
isMultiFolder: true,
discNumber: 2,
imgFiles: []string{f1},
discFoldersRel: map[string]bool{"album/cd1": true},
isMultiFolder: true,
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
@ -317,9 +334,10 @@ var _ = Describe("Disc Artwork Reader", func() {
It("does not match disc2.jpg when looking for disc 1", func() {
f1 := createFile("album/disc2.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: 1,
imgFiles: []string{f1},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
@ -339,11 +357,11 @@ var _ = Describe("Disc Artwork Reader", func() {
tmpDir = GinkgoT().TempDir()
})
createFile := func(path string) string {
fullPath := filepath.Join(tmpDir, filepath.FromSlash(path))
createFile := func(relPath string) string {
fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
return fullPath
return relPath
}
It("matches image file whose stem equals the disc subtitle (case-insensitive)", func() {
@ -351,6 +369,7 @@ var _ = Describe("Disc Artwork Reader", func() {
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
@ -366,6 +385,7 @@ var _ = Describe("Disc Artwork Reader", func() {
reader := &discArtworkReader{
discNumber: 2,
imgFiles: []string{f1},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromDiscSubtitle(ctx, "Bonus Tracks")
@ -381,6 +401,7 @@ var _ = Describe("Disc Artwork Reader", func() {
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
@ -394,6 +415,7 @@ var _ = Describe("Disc Artwork Reader", func() {
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
@ -407,19 +429,24 @@ var _ = Describe("Disc Artwork Reader", func() {
Describe("discArtworkReader", func() {
Describe("fromDiscArtPriority", func() {
var reader *discArtworkReader
var (
reader *discArtworkReader
tmpDir string
)
BeforeEach(func() {
tmpDir = GinkgoT().TempDir()
reader = &discArtworkReader{
discNumber: 2,
isMultiFolder: true,
discFolders: map[string]bool{"/music/album/cd2": true},
discNumber: 2,
isMultiFolder: true,
discFoldersRel: map[string]bool{"music/album/cd2": true},
imgFiles: []string{
"/music/album/cd1/disc.jpg",
"/music/album/cd2/disc.jpg",
"/music/album/cd2/disc2.jpg",
"music/album/cd1/disc.jpg",
"music/album/cd2/disc.jpg",
"music/album/cd2/disc2.jpg",
},
firstTrackPath: "/music/album/cd2/track1.flac",
firstTrackRel: "music/album/cd2/track1.flac",
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
})

View File

@ -15,6 +15,7 @@ type mediafileArtworkReader struct {
a *artwork
mediafile model.MediaFile
album model.Album
lib libraryView
}
func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) {
@ -30,10 +31,15 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode
if err != nil {
return nil, err
}
lib, err := loadLibraryView(ctx, artwork.ds, mf.LibraryID)
if err != nil {
return nil, err
}
a := &mediafileArtworkReader{
a: artwork,
mediafile: *mf,
album: *al,
lib: lib,
}
a.cacheKey.artID = artID
a.cacheKey.lastUpdate = mf.UpdatedAt
@ -60,10 +66,9 @@ func (a *mediafileArtworkReader) LastUpdated() time.Time {
func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
var ff []sourceFunc
if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork {
path := a.mediafile.AbsolutePath()
ff = []sourceFunc{
fromTag(ctx, path),
fromFFmpegTag(ctx, a.a.ffmpeg, path),
fromTag(ctx, a.lib.FS, a.mediafile.Path),
fromFFmpegTag(ctx, a.a.ffmpeg, a.lib.Abs(a.mediafile.Path)),
}
}
// For multi-disc albums, fall back to disc artwork first; for single-disc albums,

View File

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

View File

@ -5,9 +5,9 @@ import (
"context"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"regexp"
@ -53,7 +53,7 @@ func (f sourceFunc) String() string {
return name
}
func fromExternalFile(ctx context.Context, files []string, pattern string) sourceFunc {
func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc {
return func() (io.ReadCloser, string, error) {
for _, file := range files {
_, name := filepath.Split(file)
@ -65,12 +65,12 @@ func fromExternalFile(ctx context.Context, files []string, pattern string) sourc
if !match {
continue
}
f, err := os.Open(file)
f, err := libFS.Open(file)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", file, err)
continue
}
return f, file, err
return f, file, nil
}
return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files)
}
@ -83,28 +83,43 @@ var picTypeRegexes = []*regexp.Regexp{
regexp.MustCompile(`(?i).*cover.*`),
}
func fromTag(ctx context.Context, path string) sourceFunc {
func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {
if relPath == "" {
return nil, "", nil
}
f, err := taglib.OpenReadOnly(path, taglib.WithReadStyle(taglib.ReadStyleFast))
f, err := libFS.Open(relPath)
if err != nil {
return nil, "", err
}
rs, ok := f.(io.ReadSeeker)
if !ok {
f.Close()
return nil, "", fmt.Errorf("FS file %s is not seekable; cannot read tags", relPath)
}
tf, err := taglib.OpenStream(rs,
taglib.WithReadStyle(taglib.ReadStyleFast),
taglib.WithFilename(relPath),
)
if err != nil {
f.Close()
return nil, "", err
}
// Close in LIFO order: tf first (it holds rs internally), then f.
defer f.Close()
defer tf.Close()
images := f.Properties().Images
images := tf.Properties().Images
if len(images) == 0 {
return nil, "", fmt.Errorf("no embedded image found in %s", path)
return nil, "", fmt.Errorf("no embedded image found in %s", relPath)
}
imageIndex := findBestImageIndex(ctx, images, path)
data, err := f.Image(imageIndex)
imageIndex := findBestImageIndex(ctx, images, relPath)
data, err := tf.Image(imageIndex)
if err != nil || len(data) == 0 {
return nil, "", fmt.Errorf("could not load embedded image from %s", path)
return nil, "", fmt.Errorf("could not load embedded image from %s", relPath)
}
return io.NopCloser(bytes.NewReader(data)), path, nil
return io.NopCloser(bytes.NewReader(data)), relPath, nil
}
}
@ -121,6 +136,13 @@ func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path str
return 0
}
// fromFFmpegTag is intentionally absolute-path-based. ffmpeg is a subprocess
// and cannot read from arbitrary fs.FS implementations; piping via stdin is a
// non-trivial refactor with stream/seek implications.
//
// TODO(artwork-musicfs): when the storage backing the library is not local
// (e.g. a future S3 backend, or FakeFS in tests), short-circuit this source
// func to return (nil, "", nil) so callers fall through cleanly.
func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {

View File

@ -0,0 +1,92 @@
package artwork
import (
"bytes"
"errors"
"io"
"io/fs"
"os"
"testing/fstest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("fromExternalFile", func() {
It("opens a matching file via the library FS", func() {
fsys := fstest.MapFS{
"Artist/Album/cover.jpg": &fstest.MapFile{Data: []byte("cover-bytes")},
}
f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/cover.jpg"}, "cover.*")
r, path, err := f()
Expect(err).ToNot(HaveOccurred())
defer r.Close()
b, _ := io.ReadAll(r)
Expect(b).To(Equal([]byte("cover-bytes")))
Expect(path).To(Equal("Artist/Album/cover.jpg"))
})
It("returns an error when no file matches", func() {
fsys := fstest.MapFS{
"Artist/Album/something.txt": &fstest.MapFile{Data: []byte("x")},
}
f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/something.txt"}, "cover.*")
_, _, err := f()
Expect(err).To(HaveOccurred())
})
It("skips files that fail to open and tries the next match", func() {
fsys := fstest.MapFS{
"a/cover.jpg": &fstest.MapFile{Data: []byte("a")},
}
// "missing/cover.jpg" is in candidates but not in the FS — should be skipped.
f := fromExternalFile(GinkgoT().Context(), fsys, []string{"missing/cover.jpg", "a/cover.jpg"}, "cover.*")
r, path, err := f()
Expect(err).ToNot(HaveOccurred())
defer r.Close()
b, _ := io.ReadAll(r)
Expect(b).To(Equal([]byte("a")))
Expect(path).To(Equal("a/cover.jpg"))
})
})
var _ = Describe("fromTag", func() {
It("opens an embedded image via fs.FS", func() {
fsys := os.DirFS("tests/fixtures/artist/an-album")
f := fromTag(GinkgoT().Context(), fsys, "test.mp3")
r, path, err := f()
Expect(err).ToNot(HaveOccurred())
defer r.Close()
Expect(path).To(Equal("test.mp3"))
b, _ := io.ReadAll(r)
Expect(b).ToNot(BeEmpty())
})
It("returns nil reader when the relative path is empty", func() {
f := fromTag(GinkgoT().Context(), os.DirFS("."), "")
r, _, err := f()
Expect(err).ToNot(HaveOccurred())
Expect(r).To(BeNil())
})
It("errors when the FS file is not seekable", func() {
fsys := nonSeekableFS{data: []byte("garbage")}
f := fromTag(GinkgoT().Context(), fsys, "x.mp3")
_, _, err := f()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not seekable"))
})
})
// nonSeekableFS is a single-file fs.FS whose Open returns a non-seekable file.
type nonSeekableFS struct{ data []byte }
func (n nonSeekableFS) Open(name string) (fs.File, error) {
return &nonSeekableFile{r: bytes.NewReader(n.data)}, nil
}
type nonSeekableFile struct{ r *bytes.Reader }
func (n *nonSeekableFile) Read(p []byte) (int, error) { return n.r.Read(p) }
func (n *nonSeekableFile) Close() error { return nil }
func (n *nonSeekableFile) Stat() (fs.FileInfo, error) { return nil, errors.New("not implemented") }

View File

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

View File

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

View File

@ -21,6 +21,7 @@ type Claims struct {
ID string // "id" - artwork/mediafile ID
Format string // "f" - audio format
BitRate int // "b" - audio bitrate
ShareID string // "sid" - share ID for share stream tokens
}
// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode().
@ -54,6 +55,9 @@ func (c Claims) ToMap() map[string]any {
if c.BitRate != 0 {
m["b"] = c.BitRate
}
if c.ShareID != "" {
m["sid"] = c.ShareID
}
return m
}
@ -92,5 +96,9 @@ func ClaimsFromToken(token jwt.Token) Claims {
c.BitRate = int(bf)
}
}
var sid string
if err := token.Get("sid", &sid); err == nil {
c.ShareID = sid
}
return c
}

View File

@ -28,6 +28,7 @@ var _ = Describe("Claims", func() {
Expect(m).NotTo(HaveKey("id"))
Expect(m).NotTo(HaveKey("f"))
Expect(m).NotTo(HaveKey("b"))
Expect(m).NotTo(HaveKey("sid"))
})
It("includes expiration and issued-at when set", func() {
@ -52,6 +53,12 @@ var _ = Describe("Claims", func() {
Expect(m).To(HaveKeyWithValue("f", "mp3"))
Expect(m).To(HaveKeyWithValue("b", 192))
})
It("includes share ID claim when set", func() {
c := auth.Claims{ShareID: "abc1234567"}
m := c.ToMap()
Expect(m).To(HaveKeyWithValue("sid", "abc1234567"))
})
})
Describe("ClaimsFromToken", func() {
@ -84,6 +91,7 @@ var _ = Describe("Claims", func() {
ID: "al-456",
Format: "opus",
BitRate: 128,
ShareID: "abc1234567",
}
token, _, err := tokenAuth.Encode(original.ToMap())
Expect(err).NotTo(HaveOccurred())
@ -91,6 +99,7 @@ var _ = Describe("Claims", func() {
c := auth.ClaimsFromToken(token)
Expect(c.Issuer).To(Equal("ND"))
Expect(c.ID).To(Equal("al-456"))
Expect(c.ShareID).To(Equal("abc1234567"))
Expect(c.Format).To(Equal("opus"))
Expect(c.BitRate).To(Equal(128))
})

View File

@ -41,6 +41,7 @@ var _ = Describe("common.go", func() {
})
It("returns the absolute path when library exists", func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-core)")
ctx := context.Background()
abs := AbsolutePath(ctx, ds, libId, path)
Expect(abs).To(Equal("/library/root/music/file.mp3"))

View File

@ -153,7 +153,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
return album, err
}
album.ExternalInfoUpdatedAt = P(time.Now())
album.ExternalInfoUpdatedAt = new(time.Now())
album.ExternalUrl = info.URL
if info.Description != "" {
@ -269,7 +269,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
return artist, ctx.Err()
}
artist.ExternalInfoUpdatedAt = P(time.Now())
artist.ExternalInfoUpdatedAt = new(time.Now())
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
if err != nil {
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
@ -302,7 +302,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode
}
if err == nil && len(songs) > 0 {
return e.matcher.MatchSongsToLibrary(ctx, songs, count)
return e.matcher.MatchSongs(ctx, songs, count)
}
// Fallback to existing similar artists + top songs algorithm
@ -481,7 +481,7 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
}
}
mfs, err := e.matcher.MatchSongsToLibrary(ctx, songs, count)
mfs, err := e.matcher.MatchSongs(ctx, songs, count)
if err != nil {
return nil, err
}

View File

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

View File

@ -30,7 +30,7 @@ var _ = Describe("Provider - TopSongs", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
conf.Server.SimilarSongsMatchThreshold = 100
conf.Server.Matcher.FuzzyThreshold = 100
ctx = GinkgoT().Context()

View File

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

View File

@ -13,7 +13,6 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
@ -105,6 +104,29 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
ag.AssertExpectations(GinkgoT())
})
It("preserves decoded plain text in biography storage", func() {
originalArtist := &model.Artist{
ID: "ar-encoded-bio",
Name: "Encoded Bio Artist",
}
mockArtistRepo.SetData(model.Artists{*originalArtist})
expectedMBID := "mbid-encoded-bio"
expectedBio := "R&amp;B"
ag.On("GetArtistMBID", ctx, "ar-encoded-bio", "Encoded Bio Artist").Return(expectedMBID, nil).Once()
ag.On("GetArtistImages", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(nil, nil).Maybe()
ag.On("GetArtistBiography", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(expectedBio, nil).Once()
ag.On("GetArtistURL", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return("", nil).Maybe()
ag.On("GetSimilarArtists", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID, 100).Return(nil, nil).Maybe()
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-encoded-bio", 10, false)
Expect(err).NotTo(HaveOccurred())
Expect(updatedArtist).NotTo(BeNil())
Expect(updatedArtist.Biography).To(Equal("R&B"))
})
It("returns cached info when artist exists and info is not expired", func() {
now := time.Now()
originalArtist := &model.Artist{
@ -114,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"},
@ -151,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"},
},
@ -182,7 +204,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
originalArtist := &model.Artist{
ID: "ar-similar-test",
Name: "Similar Test Artist",
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
SimilarArtists: model.Artists{
{ID: "ar-sim-present", Name: "Similar Present"},
{ID: "", Name: "Similar Absent Raw"},

View File

@ -10,9 +10,11 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
@ -57,6 +59,11 @@ func New() FFmpeg {
return &ffmpeg{}
}
// ErrAnimatedWebPUnsupported is returned by ConvertAnimatedImage when the
// ffmpeg binary lacks the libwebp_anim encoder. Callers can use errors.Is to
// detect this specific case and fall back to static resize.
var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder — install an ffmpeg build with libwebp")
const (
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
probeCmd = "ffmpeg %s -f ffmetadata"
@ -86,6 +93,9 @@ func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, max
if err != nil {
return nil, err
}
if !animWebP.has(cmdPath, "libwebp_anim") {
return nil, ErrAnimatedWebPUnsupported
}
args := []string{cmdPath, "-i", "pipe:0"}
if maxSize > 0 {
@ -98,6 +108,19 @@ func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, max
return e.start(ctx, args, reader)
}
// parseEncodersOutput scans the stdout of `ffmpeg -encoders` for a whole-word
// match of encoder name. The output has rows like " V....D libwebp_anim ..."
// where the name is the 2nd whitespace-separated field.
func parseEncodersOutput(out []byte, name string) bool {
for line := range strings.SplitSeq(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && fields[1] == name {
return true
}
}
return false
}
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
@ -303,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
@ -372,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 {
@ -469,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))
@ -538,6 +570,49 @@ func ffmpegCmd() (string, error) {
return ffmpegPath, ffmpegErr
}
type encoderProbeState uint8
const (
encoderProbeUnknown encoderProbeState = iota
encoderProbeAvailable
encoderProbeUnavailable
)
type encoderProbe struct {
mu sync.Mutex
state encoderProbeState
}
func (p *encoderProbe) has(cmdPath, encoder string) bool {
p.mu.Lock()
defer p.mu.Unlock()
switch p.state {
case encoderProbeAvailable:
return true
case encoderProbeUnavailable:
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, cmdPath, "-hide_banner", "-encoders").Output() // #nosec
if err != nil {
log.Warn(ctx, "Could not probe ffmpeg encoders; will retry on next animated cover", err)
return false
}
if parseEncodersOutput(out, encoder) {
p.state = encoderProbeAvailable
return true
}
p.state = encoderProbeUnavailable
log.Warn(ctx, "ffmpeg has no libwebp_anim encoder; animated covers will be served as static images",
"path", cmdPath, "hint", "install ffmpeg built with libwebp (e.g. `brew install ffmpeg@7`)")
return false
}
// These variables are accessible here for tests. Do not use them directly in production code. Use ffmpegCmd() instead.
var (
ffOnce sync.Once
@ -545,4 +620,5 @@ var (
ffmpegErr error
probeOnce sync.Once
probeAvail bool
animWebP encoderProbe
)

View File

@ -3,9 +3,11 @@ package ffmpeg
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
sync "sync"
"strings"
"sync"
"testing"
"time"
@ -45,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", "-"}))
})
})
})
@ -80,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())
@ -163,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",
@ -693,4 +696,57 @@ var _ = Describe("ffmpeg", func() {
})
})
})
Describe("parseEncodersOutput", func() {
const sample = `Encoders:
V..... = Video
------
V....D apng APNG (Animated Portable Network Graphics) image
V....D libwebp_anim libwebp WebP image (codec webp)
V....D libwebp libwebp WebP image (codec webp)
A....D aac AAC (Advanced Audio Coding)
`
It("returns true when the encoder is present", func() {
Expect(parseEncodersOutput([]byte(sample), "libwebp_anim")).To(BeTrue())
Expect(parseEncodersOutput([]byte(sample), "libwebp")).To(BeTrue())
Expect(parseEncodersOutput([]byte(sample), "aac")).To(BeTrue())
})
It("returns false when the encoder is absent", func() {
Expect(parseEncodersOutput([]byte(sample), "libwebp_missing")).To(BeFalse())
Expect(parseEncodersOutput([]byte(sample), "")).To(BeFalse())
})
It("does not match partial names", func() {
// libwebp is a prefix of libwebp_anim; the parser must treat names as whole-word.
stripped := `Encoders:
V....D libwebp libwebp WebP image (codec webp)
`
Expect(parseEncodersOutput([]byte(stripped), "libwebp_anim")).To(BeFalse())
})
It("handles empty output", func() {
Expect(parseEncodersOutput(nil, "libwebp_anim")).To(BeFalse())
Expect(parseEncodersOutput([]byte(""), "libwebp_anim")).To(BeFalse())
})
})
Describe("ConvertAnimatedImage", func() {
// Point ffmpegCmd at a stand-in binary that produces empty `-encoders`
// output so hasAnimatedWebPEncoder returns false. /usr/bin/true is
// portable across POSIX systems.
It("returns ErrAnimatedWebPUnsupported when the binary lacks libwebp_anim", func() {
truePath, err := exec.LookPath("true")
if err != nil {
Skip("true(1) not available")
}
origPath, origErr := ffmpegPath, ffmpegErr
ffmpegPath = truePath
ffmpegErr = nil
defer func() {
ffmpegPath, ffmpegErr = origPath, origErr
}()
ff := &ffmpeg{}
_, err = ff.ConvertAnimatedImage(GinkgoT().Context(), strings.NewReader("x"), 100, 75)
Expect(err).To(MatchError(ErrAnimatedWebPUnsupported))
})
})
})

View File

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

View File

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

View File

@ -10,8 +10,8 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/lyrics"
"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"
)
@ -31,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,
},
}
@ -93,6 +93,7 @@ var _ = Describe("sources", func() {
var accessForbiddenFile string
BeforeEach(func() {
tests.SkipOnWindows("uses Unix file permission bits")
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)

View File

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

View File

@ -23,7 +23,7 @@ func New(ds model.DataStore) *Matcher {
return &Matcher{ds: ds}
}
// MatchSongsToLibrary matches agent song results to local library tracks using a multi-phase
// MatchSongs matches agent song results to local library tracks using a multi-phase
// matching algorithm that prioritizes accuracy over recall.
//
// # Algorithm Overview
@ -46,18 +46,20 @@ func New(ds model.DataStore) *Matcher {
// # Fuzzy Matching Details
//
// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable
// via SimilarSongsMatchThreshold, default 85%). Matches are ranked by:
// via Matcher.FuzzyThreshold, default 85%). Matches are ranked by:
//
// 1. Title similarity (Jaro-Winkler score, 0.0-1.0)
// 2. Duration proximity (closer duration = higher score, 1.0 if unknown)
// 3. Specificity level (0-5, based on metadata precision):
// 3. Preferred track flag (enabled by Matcher.PreferStarred; prioritized when the track is
// starred or has rating >= 4)
// 4. Specificity level (0-5, based on metadata precision):
// - Level 5: Title + Artist MBID + Album MBID (most specific)
// - Level 4: Title + Artist MBID + Album name (fuzzy)
// - Level 3: Title + Artist name + Album name (fuzzy)
// - Level 2: Title + Artist MBID
// - Level 1: Title + Artist name
// - Level 0: Title only
// 4. Album similarity (Jaro-Winkler, as final tiebreaker)
// 5. Album similarity (Jaro-Winkler, as final tiebreaker)
//
// # Examples
//
@ -105,25 +107,58 @@ func New(ds model.DataStore) *Matcher {
//
// Returns up to 'count' MediaFiles from the library that best match the input songs,
// preserving the original order from the agent. Songs that cannot be matched are skipped.
func (m *Matcher) MatchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
idMatches, err := m.loadTracksByID(ctx, songs)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
}
mbidMatches, err := m.loadTracksByMBID(ctx, songs, idMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
}
isrcMatches, err := m.loadTracksByISRC(ctx, songs, idMatches, mbidMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
}
titleMatches, err := m.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
if len(songs) == 0 {
return nil, nil
}
return m.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
if err != nil {
return nil, err
}
return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil
}
// MatchSongsIndexed matches agent song results to local library tracks and returns a map
// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map.
// This preserves original indices, allowing callers to correlate results back to the input slice.
func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) {
if len(songs) == 0 {
return nil, nil
}
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
if err != nil {
return nil, err
}
result := make(map[int]model.MediaFile, len(songs))
for i, t := range songs {
if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found {
result[i] = mf
}
}
return result, nil
}
func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) {
byID, err = m.loadTracksByID(ctx, songs)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err)
}
byMBID, err = m.loadTracksByMBID(ctx, songs, byID)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
}
byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
}
byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err)
}
return byID, byMBID, byISRC, byTitle, nil
}
// songMatchedIn checks if a song has already been matched in any of the provided match maps.
@ -250,6 +285,7 @@ type songQuery struct {
type matchScore struct {
titleSimilarity float64
durationProximity float64
preferredMatch bool
albumSimilarity float64
specificityLevel int
}
@ -262,6 +298,9 @@ func (s matchScore) betterThan(other matchScore) bool {
if s.durationProximity != other.durationProximity {
return s.durationProximity > other.durationProximity
}
if s.preferredMatch != other.preferredMatch {
return s.preferredMatch
}
if s.specificityLevel != other.specificityLevel {
return s.specificityLevel > other.specificityLevel
}
@ -322,7 +361,7 @@ func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents
return map[string]model.MediaFile{}, nil
}
threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0
threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0
byArtist := map[string][]songQuery{}
for _, q := range queries {
@ -393,6 +432,7 @@ func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, t
score := matchScore{
titleSimilarity: titleSim,
durationProximity: durationProximity(q.durationMs, t.mf.Duration),
preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf),
albumSimilarity: albumSim,
specificityLevel: computeSpecificityLevel(q, t, threshold),
}
@ -406,6 +446,10 @@ func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, t
return bestMatch, found
}
func isPreferredTrack(mf *model.MediaFile) bool {
return mf.Starred || mf.Rating >= 4
}
// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching.
func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
var queries []songQuery

View File

@ -75,10 +75,10 @@ var _ = Describe("Matcher", func() {
Return(artistTracks, nil).Maybe()
}
Describe("MatchSongsToLibrary", func() {
Describe("MatchSongs", func() {
Context("matching by direct ID", func() {
It("matches songs with an ID field to MediaFiles by ID", func() {
conf.Server.SimilarSongsMatchThreshold = 100
conf.Server.Matcher.FuzzyThreshold = 100
songs := []agents.Song{
{ID: "track-1", Name: "Some Song", Artist: "Some Artist"},
}
@ -87,7 +87,7 @@ var _ = Describe("Matcher", func() {
}
expectIDPhase(model.MediaFiles{idMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-1"))
@ -96,7 +96,7 @@ var _ = Describe("Matcher", func() {
Context("matching by MBID", func() {
It("matches songs with MBID to tracks with matching mbz_recording_id", func() {
conf.Server.SimilarSongsMatchThreshold = 100
conf.Server.Matcher.FuzzyThreshold = 100
songs := []agents.Song{
{Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"},
}
@ -106,7 +106,7 @@ var _ = Describe("Matcher", func() {
}
expectMBIDPhase(model.MediaFiles{mbidMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-mbid"))
@ -115,7 +115,7 @@ var _ = Describe("Matcher", func() {
Context("matching by ISRC", func() {
It("matches songs with ISRC to tracks with matching ISRC tag", func() {
conf.Server.SimilarSongsMatchThreshold = 100
conf.Server.Matcher.FuzzyThreshold = 100
songs := []agents.Song{
{Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"},
}
@ -125,7 +125,7 @@ var _ = Describe("Matcher", func() {
}
expectISRCPhase(model.MediaFiles{isrcMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-isrc"))
@ -134,7 +134,7 @@ var _ = Describe("Matcher", func() {
Context("fuzzy title+artist matching", func() {
It("matches songs by title and artist name", func() {
conf.Server.SimilarSongsMatchThreshold = 100
conf.Server.Matcher.FuzzyThreshold = 100
songs := []agents.Song{
{Name: "Enjoy the Silence", Artist: "Depeche Mode"},
}
@ -142,14 +142,14 @@ var _ = Describe("Matcher", func() {
ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode",
}
setupTitleOnlyExpectations(model.MediaFiles{titleMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-title"))
})
It("matches songs with fuzzy title similarity", func() {
conf.Server.SimilarSongsMatchThreshold = 85
conf.Server.Matcher.FuzzyThreshold = 85
songs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen"},
}
@ -157,14 +157,14 @@ var _ = Describe("Matcher", func() {
ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
}
setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-fuzzy"))
})
It("does not match completely different titles", func() {
conf.Server.SimilarSongsMatchThreshold = 85
conf.Server.Matcher.FuzzyThreshold = 85
songs := []agents.Song{
{Name: "Yesterday", Artist: "The Beatles"},
}
@ -172,7 +172,7 @@ var _ = Describe("Matcher", func() {
{ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"},
}
setupTitleOnlyExpectations(differentTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
@ -180,7 +180,7 @@ var _ = Describe("Matcher", func() {
Context("deduplication", func() {
It("removes duplicates when different input songs match the same library track", func() {
conf.Server.SimilarSongsMatchThreshold = 85
conf.Server.Matcher.FuzzyThreshold = 85
songs := []agents.Song{
{Name: "Bohemian Rhapsody (Live)", Artist: "Queen"},
{Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"},
@ -189,14 +189,14 @@ var _ = Describe("Matcher", func() {
ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
}
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("br-live"))
})
It("preserves duplicates when identical input songs match the same library track", func() {
conf.Server.SimilarSongsMatchThreshold = 85
conf.Server.Matcher.FuzzyThreshold = 85
songs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
@ -205,7 +205,7 @@ var _ = Describe("Matcher", func() {
ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera",
}
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(result[0].ID).To(Equal("br"))
@ -215,7 +215,7 @@ var _ = Describe("Matcher", func() {
Context("priority ordering", func() {
It("prefers ID match over MBID match", func() {
conf.Server.SimilarSongsMatchThreshold = 100
conf.Server.Matcher.FuzzyThreshold = 100
// Song has both ID and MBID set. The matcher should resolve via ID
// and short-circuit the MBID phase entirely, so no MBID fetch should
// occur even though an mbz_recording_id exists in the input.
@ -227,7 +227,7 @@ var _ = Describe("Matcher", func() {
}
expectIDPhase(model.MediaFiles{idMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-id"))
@ -236,7 +236,7 @@ var _ = Describe("Matcher", func() {
Context("count limit", func() {
It("returns at most 'count' results", func() {
conf.Server.SimilarSongsMatchThreshold = 100
conf.Server.Matcher.FuzzyThreshold = 100
songs := []agents.Song{
{Name: "Song A", Artist: "Artist"},
{Name: "Song B", Artist: "Artist"},
@ -248,7 +248,7 @@ var _ = Describe("Matcher", func() {
{ID: "c", Title: "Song C", Artist: "Artist"},
}
setupTitleOnlyExpectations(tracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 2)
result, err := m.MatchSongs(ctx, songs, 2)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
})
@ -256,16 +256,63 @@ var _ = Describe("Matcher", func() {
Context("empty input", func() {
It("returns empty results for no songs", func() {
result, err := m.MatchSongsToLibrary(ctx, []agents.Song{}, 5)
result, err := m.MatchSongs(ctx, []agents.Song{}, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
})
Describe("MatchSongsIndexed", func() {
It("returns index-keyed map of matched songs", func() {
songs := []agents.Song{
{ID: "track-1", Name: "Song One", Artist: "Artist A"},
{ID: "track-2", Name: "Song Two", Artist: "Artist B"},
{ID: "track-3", Name: "Song Three", Artist: "Artist C"},
}
mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"}
mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"}
expectIDPhase(model.MediaFiles{mf1, mf2})
allowOtherPhases()
result, err := m.MatchSongsIndexed(ctx, songs)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(result[0].ID).To(Equal("track-1"))
Expect(result[1].ID).To(Equal("track-2"))
_, exists := result[2]
Expect(exists).To(BeFalse())
})
It("preserves original indices when some songs don't match", func() {
songs := []agents.Song{
{Name: "Unknown Song", Artist: "Unknown Artist"},
{ID: "track-1", Name: "Known Song", Artist: "Known Artist"},
}
mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"}
expectIDPhase(model.MediaFiles{mf1})
allowOtherPhases()
result, err := m.MatchSongsIndexed(ctx, songs)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
_, exists := result[0]
Expect(exists).To(BeFalse())
Expect(result[1].ID).To(Equal("track-1"))
})
It("returns empty map for empty input", func() {
result, err := m.MatchSongsIndexed(ctx, nil)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Describe("specificity level matching", func() {
BeforeEach(func() {
conf.Server.SimilarSongsMatchThreshold = 100
conf.Server.Matcher.FuzzyThreshold = 100
})
It("matches by title + artist MBID + album MBID (highest priority)", func() {
@ -283,7 +330,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -303,7 +350,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -323,7 +370,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -337,7 +384,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
@ -356,7 +403,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(3))
@ -384,7 +431,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
@ -396,7 +443,7 @@ var _ = Describe("Matcher", func() {
Describe("fuzzy matching thresholds", func() {
Context("with default threshold (85%)", func() {
It("matches songs with remastered suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
conf.Server.Matcher.FuzzyThreshold = 85
songs := []agents.Song{
{Name: "Paranoid Android", Artist: "Radiohead"},
@ -407,7 +454,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -415,7 +462,7 @@ var _ = Describe("Matcher", func() {
})
It("matches songs with live suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
conf.Server.Matcher.FuzzyThreshold = 85
songs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen"},
@ -426,7 +473,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -436,7 +483,7 @@ var _ = Describe("Matcher", func() {
Context("with threshold set to 100 (exact match only)", func() {
It("only matches exact titles", func() {
conf.Server.SimilarSongsMatchThreshold = 100
conf.Server.Matcher.FuzzyThreshold = 100
songs := []agents.Song{
{Name: "Paranoid Android", Artist: "Radiohead"},
@ -447,7 +494,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
@ -456,7 +503,7 @@ var _ = Describe("Matcher", func() {
Context("with lower threshold (75%)", func() {
It("matches more aggressively", func() {
conf.Server.SimilarSongsMatchThreshold = 75
conf.Server.Matcher.FuzzyThreshold = 75
songs := []agents.Song{
{Name: "Song", Artist: "Artist"},
@ -467,7 +514,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -478,7 +525,8 @@ var _ = Describe("Matcher", func() {
Describe("fuzzy album matching", func() {
BeforeEach(func() {
conf.Server.SimilarSongsMatchThreshold = 85
conf.Server.Matcher.FuzzyThreshold = 85
conf.Server.Matcher.PreferStarred = false
})
It("matches album with (Remaster) suffix", func() {
@ -494,7 +542,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -514,7 +562,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -534,17 +582,59 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("exact"))
})
It("prefers starred songs over better album match when enabled", func() {
conf.Server.Matcher.PreferStarred = true
songs := []agents.Song{
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
}
albumMatch := model.MediaFile{
ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
}
starredTrack := model.MediaFile{
ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Starred: true},
}
setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack})
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("starred"))
})
It("prefers 4-star songs over better album match when enabled", func() {
conf.Server.Matcher.PreferStarred = true
songs := []agents.Song{
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
}
albumMatch := model.MediaFile{
ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
}
ratedTrack := model.MediaFile{
ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Rating: 4},
}
setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack})
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("rated"))
})
})
Describe("duration matching", func() {
BeforeEach(func() {
conf.Server.SimilarSongsMatchThreshold = 100
conf.Server.Matcher.FuzzyThreshold = 100
})
It("prefers tracks with matching duration", func() {
@ -560,7 +650,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -577,7 +667,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{closeDuration})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -597,7 +687,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -614,7 +704,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{differentDuration})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -634,7 +724,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -651,7 +741,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{anyTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -668,7 +758,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{shortTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -678,7 +768,7 @@ var _ = Describe("Matcher", func() {
Describe("deduplication edge cases", func() {
BeforeEach(func() {
conf.Server.SimilarSongsMatchThreshold = 85
conf.Server.Matcher.FuzzyThreshold = 85
})
It("handles mixed scenario with both identical and different input songs", func() {
@ -694,7 +784,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
@ -714,7 +804,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(3))
@ -735,7 +825,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB})
result, err := m.MatchSongsToLibrary(ctx, songs, 2)
result, err := m.MatchSongs(ctx, songs, 2)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))

View File

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

View File

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

View File

@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -199,6 +200,7 @@ var _ = Describe("MPV", func() {
})
It("executes MPV command and captures arguments correctly", func() {
tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -226,6 +228,7 @@ var _ = Describe("MPV", func() {
})
It("handles file paths with spaces", func() {
tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -253,6 +256,7 @@ var _ = Describe("MPV", func() {
})
It("passes all snapcast arguments correctly", func() {
tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

View File

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

View File

@ -3,6 +3,7 @@ package playlists
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@ -17,14 +18,89 @@ import (
"golang.org/x/text/unicode/norm"
)
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error) {
absPath, err := filepath.Abs(absolutePath)
if err != nil {
return nil, fmt.Errorf("resolving absolute path: %w", err)
}
dir := filepath.Dir(absPath)
filename := filepath.Base(absPath)
folder, err := s.resolveFolder(ctx, dir)
if err != nil && !errors.Is(err, errNotInLibrary) {
return nil, err
}
if err == nil {
pls, err := s.importFromFolder(ctx, folder, filename, sync)
if err != nil {
return nil, err
}
if pls.ID != "" && pls.Sync != sync {
pls.Sync = sync
if putErr := s.ds.Playlist(ctx).Put(pls); putErr != nil {
return nil, putErr
}
}
return pls, nil
}
log.Debug(ctx, "Playlist file is outside all libraries, using path-based import", "path", absPath)
pls, err := s.newSyncedPlaylist(dir, filename)
if err != nil {
return nil, fmt.Errorf("reading playlist file: %w", err)
}
pls.Sync = sync
file, err := os.Open(absPath)
if err != nil {
return nil, fmt.Errorf("opening playlist file: %w", err)
}
defer file.Close()
reader := ioutils.UTF8Reader(file)
if err := s.parseM3U(ctx, pls, nil, reader); err != nil {
return nil, err
}
if err := s.updatePlaylist(ctx, pls, sync); err != nil {
return nil, err
}
return pls, nil
}
var errNotInLibrary = fmt.Errorf("path not in any library")
func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folder, error) {
libs, err := s.ds.Library(ctx).GetAll()
if err != nil {
return nil, err
}
matcher := newLibraryMatcher(libs)
lib, ok := matcher.findLibrary(dir)
if !ok {
return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir)
}
folder, err := s.ds.Folder(ctx).GetByPath(lib, dir)
if err != nil {
return nil, fmt.Errorf("resolving folder for path %s: %w", dir, err)
}
folder.LibraryPath = lib.Path
return folder, nil
}
func (s *playlists) ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
return s.importFromFolder(ctx, folder, filename, false)
}
func (s *playlists) importFromFolder(ctx context.Context, folder *model.Folder, filename string, forceSync bool) (*model.Playlist, error) {
pls, err := s.parsePlaylist(ctx, filename, folder)
if err != nil {
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
return nil, err
}
log.Debug(ctx, "Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
err = s.updatePlaylist(ctx, pls)
err = s.updatePlaylist(ctx, pls, forceSync)
if err != nil {
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
}
@ -74,27 +150,31 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
return pls, err
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
owner, _ := request.UserFrom(ctx)
// Try to find existing playlist by path. Since filesystem normalization differs across
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
// playlists that may have been imported on a different platform.
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
// findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode
// normalization forms to handle cross-platform filesystem differences.
func (s *playlists) findByPathNormalized(ctx context.Context, path string) (*model.Playlist, error) {
pls, err := s.ds.Playlist(ctx).FindByPath(path)
if errors.Is(err, model.ErrNotFound) {
// Try alternate normalization form
altPath := norm.NFD.String(newPls.Path)
if altPath == newPls.Path {
altPath = norm.NFC.String(newPls.Path)
altPath := norm.NFD.String(path)
if altPath == path {
altPath = norm.NFC.String(path)
}
if altPath != newPls.Path {
if altPath != path {
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
}
}
return pls, err
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, forceSync bool) error {
owner, _ := request.UserFrom(ctx)
pls, err := s.findByPathNormalized(ctx, newPls.Path)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return err
}
if err == nil && !pls.Sync {
alreadyImportedAndNotSynced := err == nil && !pls.Sync && !forceSync
if alreadyImportedAndNotSynced {
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
return nil
}

View File

@ -39,7 +39,7 @@ var _ = Describe("Playlists - Import", func() {
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
Describe("ImportFile", func() {
Describe("ImportFromFolder", func() {
var folder *model.Folder
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
@ -59,7 +59,7 @@ var _ = Describe("Playlists - Import", func() {
Describe("M3U", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, folder, "pls1.m3u")
pls, err := ps.ImportFromFolder(ctx, folder, "pls1.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Tracks).To(HaveLen(2))
@ -69,19 +69,19 @@ var _ = Describe("Playlists - Import", func() {
})
It("parses playlists using LF ending", func() {
pls, err := ps.ImportFile(ctx, folder, "lf-ended.m3u")
pls, err := ps.ImportFromFolder(ctx, folder, "lf-ended.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
It("parses playlists using CR ending (old Mac format)", func() {
pls, err := ps.ImportFile(ctx, folder, "cr-ended.m3u")
pls, err := ps.ImportFromFolder(ctx, folder, "cr-ended.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
It("parses playlists with UTF-8 BOM marker", func() {
pls, err := ps.ImportFile(ctx, folder, "bom-test.m3u")
pls, err := ps.ImportFromFolder(ctx, folder, "bom-test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("Test Playlist"))
@ -90,7 +90,7 @@ var _ = Describe("Playlists - Import", func() {
})
It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() {
pls, err := ps.ImportFile(ctx, folder, "bom-test-utf16.m3u")
pls, err := ps.ImportFromFolder(ctx, folder, "bom-test-utf16.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("UTF-16 Test Playlist"))
@ -101,7 +101,7 @@ var _ = Describe("Playlists - Import", func() {
It("parses #EXTALBUMARTURL with HTTP URL", func() {
conf.Server.EnableM3UExternalAlbumArt = true
pls, err := ps.ImportFile(ctx, folder, "pls-with-art-url.m3u")
pls, err := ps.ImportFromFolder(ctx, folder, "pls-with-art-url.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg"))
Expect(pls.Tracks).To(HaveLen(2))
@ -121,7 +121,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(imgPath))
})
@ -139,7 +139,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(filepath.Join(tmpDir, "cover.jpg")))
})
@ -158,7 +158,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(imgPath))
})
@ -177,12 +177,13 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(imgPath))
})
It("rejects #EXTALBUMARTURL with absolute path outside library boundaries", func() {
tests.SkipOnWindows("relies on Unix /etc filesystem")
tmpDir := GinkgoT().TempDir()
m3u := "#EXTALBUMARTURL:/etc/passwd\ntest.mp3\n"
@ -194,7 +195,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
@ -211,7 +212,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
@ -228,7 +229,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
@ -246,7 +247,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
@ -274,12 +275,38 @@ var _ = Describe("Playlists - Import", func() {
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.UploadedImage).To(Equal("existing-id.jpg"))
Expect(pls.ExternalImageURL).To(Equal("https://example.com/new-cover.jpg"))
})
It("skips non-synced playlist on re-import (respects user's choice)", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
existingPls := &model.Playlist{
ID: "existing-id",
Name: "Existing Playlist",
Path: plsFile,
Sync: false,
OwnerID: "123",
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
// updatePlaylist skips the non-synced playlist, so the returned
// playlist has no ID (was never persisted/updated).
Expect(pls.ID).To(BeEmpty())
})
It("clears ExternalImageURL on re-scan when directive is removed", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
@ -300,7 +327,7 @@ var _ = Describe("Playlists - Import", func() {
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
@ -308,7 +335,7 @@ var _ = Describe("Playlists - Import", func() {
Describe("NSP", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last).To(Equal(pls))
Expect(pls.OwnerID).To(Equal("123"))
@ -320,17 +347,18 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
})
It("returns an error if the playlist is not well-formed", func() {
_, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
tests.SkipOnWindows("line-ending differences affect JSON error offset")
_, err := ps.ImportFromFolder(ctx, folder, "invalid_json.nsp")
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
})
It("parses NSP with public: true and creates public playlist", func() {
pls, err := ps.ImportFile(ctx, folder, "public_playlist.nsp")
pls, err := ps.ImportFromFolder(ctx, folder, "public_playlist.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Public Playlist"))
Expect(pls.Public).To(BeTrue())
})
It("parses NSP with public: false and creates private playlist", func() {
pls, err := ps.ImportFile(ctx, folder, "private_playlist.nsp")
pls, err := ps.ImportFromFolder(ctx, folder, "private_playlist.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Private Playlist"))
Expect(pls.Public).To(BeFalse())
@ -338,7 +366,7 @@ var _ = Describe("Playlists - Import", func() {
It("uses server default when public field is absent", func() {
conf.Server.DefaultPlaylistPublicVisibility = true
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Recently Played"))
Expect(pls.Public).To(BeTrue()) // Should be true since server default is true
@ -347,6 +375,7 @@ var _ = Describe("Playlists - Import", func() {
DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)",
func(storedForm, filesystemForm string) {
tests.SkipOnWindows("/tmp hardcoded in test")
// Use Polish characters that decompose: ó (U+00F3) -> o + combining acute (U+006F + U+0301)
plsNameNFC := "Piosenki_Polskie_zółć" // NFC form (composed)
plsNameNFD := norm.NFD.String(plsNameNFC)
@ -383,7 +412,7 @@ var _ = Describe("Playlists - Import", func() {
Path: "",
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, filesystemName+".m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, filesystemName+".m3u")
Expect(err).ToNot(HaveOccurred())
// Should update existing playlist, not create new one
@ -438,7 +467,7 @@ var _ = Describe("Playlists - Import", func() {
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
@ -459,7 +488,7 @@ var _ = Describe("Playlists - Import", func() {
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
// Should only find abc.mp3, not outside.mp3
Expect(pls.Tracks).To(HaveLen(1))
@ -496,7 +525,7 @@ var _ = Describe("Playlists - Import", func() {
Name: "subfolder", // The folder name
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
@ -539,7 +568,7 @@ var _ = Describe("Playlists - Import", func() {
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library
@ -590,7 +619,7 @@ var _ = Describe("Playlists - Import", func() {
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
// Should have BOTH tracks, not just one
@ -613,6 +642,126 @@ var _ = Describe("Playlists - Import", func() {
})
})
Describe("ImportFile", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}}
})
It("resolves file inside a library and imports it", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
mockFolderRepo := &mockFolderRepoForImport{
folder: &model.Folder{
ID: "1",
LibraryID: 1,
LibraryPath: tmpDir,
Path: "",
Name: "",
},
}
ds.MockedFolder = mockFolderRepo
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsContent := "#PLAYLIST:My Playlist\ntest.mp3\ntest.ogg\n"
plsFile := filepath.Join(tmpDir, "my-playlist.m3u")
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
pls, err := ps.ImportFile(ctx, plsFile, true)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("My Playlist"))
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Path).To(Equal(plsFile))
Expect(pls.Sync).To(BeTrue())
})
It("records path for files outside all libraries", func() {
tmpDir := GinkgoT().TempDir()
libDir := filepath.Join(tmpDir, "music")
Expect(os.Mkdir(libDir, 0755)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: libDir}})
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsContent := "#PLAYLIST:External Playlist\n" + libDir + "/test.mp3\n"
plsFile := filepath.Join(tmpDir, "external.m3u")
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
pls, err := ps.ImportFile(ctx, plsFile, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("External Playlist"))
Expect(pls.Path).To(Equal(plsFile))
Expect(pls.Sync).To(BeFalse())
})
It("imports with Sync=false", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
mockFolderRepo := &mockFolderRepoForImport{
folder: &model.Folder{
ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
},
}
ds.MockedFolder = mockFolderRepo
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
pls, err := ps.ImportFile(ctx, plsFile, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Sync).To(BeFalse())
})
It("imports with Sync=true", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
mockFolderRepo := &mockFolderRepoForImport{
folder: &model.Folder{
ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
},
}
ds.MockedFolder = mockFolderRepo
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
pls, err := ps.ImportFile(ctx, plsFile, true)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Sync).To(BeTrue())
})
It("upgrades non-synced playlist to synced on re-import with sync=true", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
mockFolderRepo := &mockFolderRepoForImport{
folder: &model.Folder{
ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
},
}
ds.MockedFolder = mockFolderRepo
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
existingPls := &model.Playlist{
ID: "existing-id", Name: "Existing", Path: plsFile,
Sync: false, OwnerID: "123",
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
pls, err := ps.ImportFile(ctx, plsFile, true)
Expect(err).ToNot(HaveOccurred())
Expect(pls.ID).To(Equal("existing-id"))
Expect(pls.Sync).To(BeTrue())
})
})
Describe("ImportM3U", func() {
var repo *mockedMediaFileFromListRepo
BeforeEach(func() {
@ -821,6 +970,7 @@ var _ = Describe("Playlists - Import", func() {
})
It("returns true if folder is in PlaylistsPath", func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
conf.Server.PlaylistsPath = "other/**:playlists/**"
Expect(playlists.InPath(folder)).To(BeTrue())
})
@ -921,3 +1071,15 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
}
return mfs, nil
}
type mockFolderRepoForImport struct {
model.FolderRepository
folder *model.Folder
}
func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.Folder, error) {
if m.folder != nil {
return m.folder, nil
}
return nil, model.ErrNotFound
}

View File

@ -163,17 +163,26 @@ type libraryMatcher struct {
// findLibraryForPath finds which library contains the given absolute path.
// Returns library ID and path, or 0 and empty string if not found.
func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) {
lib, ok := lm.findLibrary(absolutePath)
if !ok {
return 0, ""
}
return lib.ID, filepath.Clean(lib.Path)
}
// findLibrary checks if the absolute path is under any of the library paths.
func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) {
// Check sorted libraries (longest path first) to find the best match
for i, cleanLibPath := range lm.cleanedPaths {
// Check if absolutePath is under this library path
if strings.HasPrefix(absolutePath, cleanLibPath) {
// Ensure it's a proper path boundary (not just a prefix)
if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator {
return lm.libraries[i].ID, cleanLibPath
return lm.libraries[i], true
}
}
}
return 0, ""
return model.Library{}, false
}
// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first).

View File

@ -15,6 +15,7 @@ var _ = Describe("libraryMatcher", func() {
ctx := context.Background()
BeforeEach(func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
mockLibRepo = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{
MockedLibrary: mockLibRepo,
@ -196,6 +197,7 @@ var _ = Describe("pathResolver", func() {
ctx := context.Background()
BeforeEach(func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
mockLibRepo = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{
MockedLibrary: mockLibRepo,

View File

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

View File

@ -42,10 +42,11 @@ type Playlists interface {
RemoveImage(ctx context.Context, playlistID string) error
// Import
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error)
ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
// REST adapters (follows Share/Library pattern)
// REST adapters
NewRepository(ctx context.Context) rest.Repository
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
}

View File

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

View File

@ -3,10 +3,14 @@ package playlists
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) ---
@ -77,6 +81,14 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri
// updatePlaylistEntity updates playlist metadata with permission checks.
// Used by the REST API wrapper.
//
// 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 {
@ -89,15 +101,100 @@ 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
}
// Apply ownership change (admin only)
if entity.OwnerID != "" {
nameChanged := sent("name") && entity.Name != current.Name
commentChanged := sent("comment") && entity.Comment != current.Comment
rulesChanged := sent("rules") && !rulesEqual(current.Rules, entity.Rules)
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
}
// Apply smart playlist rules update
current.Rules = entity.Rules
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
if rulesChanged {
current.Rules = entity.Rules
}
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
current.Sync = entity.Sync
}
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
updateCols = append(updateCols, "public")
}
if len(updateCols) == 0 {
return nil
}
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 {
if a == b {
return true
}
if a == nil || b == nil {
return false
}
return reflect.DeepEqual(a, b)
}

View File

@ -63,7 +63,6 @@ var _ = Describe("REST Adapter", func() {
It("clears server-managed fields to prevent injection via REST API", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
now := time.Now()
pls := &model.Playlist{
Name: "Legit Playlist",
Comment: "A comment",
@ -73,7 +72,7 @@ var _ = Describe("REST Adapter", func() {
Sync: true,
UploadedImage: "injected-image-path",
ExternalImageURL: "http://evil.example.com/ssrf",
EvaluatedAt: &now,
EvaluatedAt: new(time.Now()),
}
_, err := repo.Save(pls)
Expect(err).ToNot(HaveOccurred())
@ -126,6 +125,25 @@ var _ = Describe("REST Adapter", func() {
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
DescribeTable("denies regular user from changing ownership under any case-variant JSON key",
func(colName string) {
// rest.Put's field-name extraction is case-sensitive, but Go's
// json decoder is case-insensitive on struct fields, so any
// {"OwnerId":"x"} / {"OWNERID":"x"} / {"ownerid":"x"} populates
// entity.OwnerID. sentFields normalizes both sides so the
// permission gate fires regardless of casing.
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{OwnerID: "other-user"}
err := repo.Update("pls-1", pls, colName)
Expect(err).To(Equal(rest.ErrPermissionDenied))
},
Entry("canonical camelCase", "ownerId"),
Entry("PascalCase", "OwnerId"),
Entry("all upper", "OWNERID"),
Entry("all lower", "ownerid"),
)
It("updates smart playlist rules", func() {
mockPlsRepo.Data["smart-1"] = &model.Playlist{
ID: "smart-1",
@ -142,6 +160,76 @@ var _ = Describe("REST Adapter", func() {
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
})
It("allows toggling sync for file-backed playlists", func() {
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
mockPlsRepo.Data["file-pls"] = &model.Playlist{
ID: "file-pls",
Name: "File Playlist",
OwnerID: "user-1",
Path: "/music/playlist.m3u",
Sync: true,
UpdatedAt: originalTime,
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "File Playlist", Sync: false}
err := repo.Update("file-pls", pls)
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime))
})
It("does not allow setting sync on non-file-backed playlists", func() {
mockPlsRepo.Data["manual-pls"] = &model.Playlist{
ID: "manual-pls",
Name: "Manual Playlist",
OwnerID: "user-1",
Path: "",
Sync: false,
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Manual Playlist", Sync: true}
err := repo.Update("manual-pls", pls)
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last).To(BeNil())
})
It("does not bump updatedAt when only public changes", func() {
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
mockPlsRepo.Data["pls-pub"] = &model.Playlist{
ID: "pls-pub",
Name: "My Playlist",
OwnerID: "user-1",
Public: false,
UpdatedAt: originalTime,
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "My Playlist", Public: true}
err := repo.Update("pls-pub", pls)
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Public).To(BeTrue())
Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime))
})
It("bumps updatedAt when name changes along with sync", func() {
mockPlsRepo.Data["file-pls2"] = &model.Playlist{
ID: "file-pls2",
Name: "Old Name",
OwnerID: "user-1",
Path: "/music/playlist.m3u",
Sync: true,
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "New Name", Sync: false}
err := repo.Update("file-pls2", pls)
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("New Name"))
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
})
It("returns rest.ErrNotFound when playlist doesn't exist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
@ -149,6 +237,156 @@ var _ = Describe("REST Adapter", func() {
err := repo.Update("nonexistent", pls)
Expect(err).To(Equal(rest.ErrNotFound))
})
// Regression tests for #5541: partial REST updates (e.g. bulk "Make Public")
// must only touch the fields the client actually sent. The cols list from
// rest.Put names those fields; fields outside it must be left alone, even
// when the deserialized entity has zero values for them.
Context("with partial updates (cols)", func() {
BeforeEach(func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
mockPlsRepo.Data["partial"] = &model.Playlist{
ID: "partial",
Name: "Original Name",
Comment: "Original comment",
OwnerID: "user-1",
Public: false,
}
})
It("preserves name and comment when only public is sent (bulk Make Public)", func() {
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Public: true}, "public")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Original Name"))
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
Expect(mockPlsRepo.Last.Public).To(BeTrue())
})
It("preserves name when only sync is sent for a file-backed playlist", func() {
mockPlsRepo.Data["file-partial"] = &model.Playlist{
ID: "file-partial",
Name: "Keep Me",
OwnerID: "user-1",
Path: "/music/p.m3u",
Sync: true,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("file-partial", &model.Playlist{Sync: false}, "sync")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Keep Me"))
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
})
It("renames the playlist when only name is sent", func() {
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "name")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Renamed"))
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
Expect(mockPlsRepo.Last.Public).To(BeFalse())
})
It("clears the comment when an empty comment is sent explicitly", func() {
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Comment: ""}, "comment")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Comment).To(BeEmpty())
Expect(mockPlsRepo.Last.Name).To(Equal("Original Name"))
})
It("updates rules-only on a smart playlist (Feishin-style edit)", func() {
mockPlsRepo.Data["smart-partial"] = &model.Playlist{
ID: "smart-partial",
Name: "Smart Original",
Comment: "smart comment",
OwnerID: "user-1",
Public: true,
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
}
repo = ps.NewRepository(ctx).(rest.Persistable)
newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"}
err := repo.Update("smart-partial", &model.Playlist{Rules: newRules}, "rules")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Original"))
Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment"))
Expect(mockPlsRepo.Last.Public).To(BeTrue())
})
It("updates name and rules together (smart-playlist Edit form)", func() {
mockPlsRepo.Data["smart-edit"] = &model.Playlist{
ID: "smart-edit",
Name: "Smart Original",
Comment: "smart comment",
OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
}
repo = ps.NewRepository(ctx).(rest.Persistable)
newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"}
err := repo.Update("smart-edit",
&model.Playlist{Name: "Smart Renamed", Rules: newRules},
"name", "rules")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Renamed"))
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment"))
})
It("does not bump the saved rules on an idempotent rules-only PUT", func() {
rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
mockPlsRepo.Data["smart-idempotent"] = &model.Playlist{
ID: "smart-idempotent",
Name: "Smart Idempotent",
OwnerID: "user-1",
Rules: rules,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
// Same rules sent back — rulesEqual should report no change and
// the request should no-op (no Put call).
sameRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
err := repo.Update("smart-idempotent", &model.Playlist{Rules: sameRules}, "rules")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last).To(BeNil()) // no Put happened
})
It("preserves rules when only public is sent (smart playlist + bulk Make Public)", func() {
rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
mockPlsRepo.Data["smart-public"] = &model.Playlist{
ID: "smart-public",
Name: "Smart Public",
OwnerID: "user-1",
Public: false,
Rules: rules,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("smart-public", &model.Playlist{Public: true}, "public")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Public).To(BeTrue())
Expect(mockPlsRepo.Last.Rules).To(Equal(rules))
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Public"))
})
It("does not treat a missing ownerId as an ownership transfer attempt", func() {
// A non-admin user sending only {public:true} should not be blocked
// just because OwnerID is the zero value in the deserialized entity.
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Public: true}, "public")
Expect(err).ToNot(HaveOccurred())
})
It("matches cols case-insensitively (mirrors json decoder behavior)", func() {
// Go's json decoder populates struct fields from case-variant keys
// like {"Name":"x"}, but rest.Put's field-name extraction is
// case-sensitive. sentFields normalizes both sides so a request
// with {"Name":"Renamed"} is honored, not silently ignored.
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "Name")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Renamed"))
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
})
})
})
Describe("Delete", func() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

130
core/sonic/sonic.go Normal file
View File

@ -0,0 +1,130 @@
package sonic
import (
"context"
"fmt"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
const capabilitySonicSimilarity = "SonicSimilarity"
type SimilarResult struct {
Song agents.Song
Similarity float64
}
type SimilarMatch struct {
MediaFile model.MediaFile
Similarity float64
}
type Provider interface {
GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]SimilarResult, error)
FindSonicPath(ctx context.Context, startMF, endMF *model.MediaFile, count int) ([]SimilarResult, error)
}
type PluginLoader interface {
PluginNames(capability string) []string
LoadSonicSimilarity(name string) (Provider, bool)
}
type Sonic struct {
ds model.DataStore
pluginLoader PluginLoader
matcher *matcher.Matcher
}
func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher) *Sonic {
return &Sonic{
ds: ds,
pluginLoader: pluginLoader,
matcher: matcher,
}
}
func (s *Sonic) HasProvider() bool {
return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0
}
func (s *Sonic) loadProvider() (Provider, error) {
names := s.pluginLoader.PluginNames(capabilitySonicSimilarity)
if len(names) == 0 {
return nil, model.ErrNotFound
}
provider, ok := s.pluginLoader.LoadSonicSimilarity(names[0])
if !ok {
return nil, model.ErrNotFound
}
return provider, nil
}
func (s *Sonic) resolveMatches(ctx context.Context, results []SimilarResult) ([]SimilarMatch, error) {
songs := make([]agents.Song, len(results))
for i, r := range results {
songs[i] = r.Song
}
matchMap, err := s.matcher.MatchSongsIndexed(ctx, songs)
if err != nil {
return nil, fmt.Errorf("matching songs to library: %w", err)
}
var matches []SimilarMatch
for i, r := range results {
if mf, ok := matchMap[i]; ok {
matches = append(matches, SimilarMatch{
MediaFile: mf,
Similarity: r.Similarity,
})
}
}
return matches, nil
}
func (s *Sonic) GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) {
provider, err := s.loadProvider()
if err != nil {
return nil, err
}
mf, err := s.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, fmt.Errorf("getting media file %s: %w", id, err)
}
results, err := provider.GetSonicSimilarTracks(ctx, mf, count)
if err != nil {
log.Error(ctx, "Plugin GetSonicSimilarTracks failed", "id", id, err)
return nil, err
}
return s.resolveMatches(ctx, results)
}
func (s *Sonic) FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) {
provider, err := s.loadProvider()
if err != nil {
return nil, err
}
startMF, err := s.ds.MediaFile(ctx).Get(startID)
if err != nil {
return nil, fmt.Errorf("getting start media file %s: %w", startID, err)
}
endMF, err := s.ds.MediaFile(ctx).Get(endID)
if err != nil {
return nil, fmt.Errorf("getting end media file %s: %w", endID, err)
}
results, err := provider.FindSonicPath(ctx, startMF, endMF, count)
if err != nil {
log.Error(ctx, "Plugin FindSonicPath failed", "startId", startID, "endId", endID, err)
return nil, err
}
return s.resolveMatches(ctx, results)
}

View File

@ -0,0 +1,17 @@
package sonic_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestSonic(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Sonic Suite")
}

146
core/sonic/sonic_test.go Normal file
View File

@ -0,0 +1,146 @@
package sonic_test
import (
"context"
"errors"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type mockPluginLoader struct {
names []string
provider sonic.Provider
loadOk bool
}
func (m *mockPluginLoader) PluginNames(capability string) []string {
if capability == "SonicSimilarity" {
return m.names
}
return nil
}
func (m *mockPluginLoader) LoadSonicSimilarity(name string) (sonic.Provider, bool) {
return m.provider, m.loadOk
}
type mockProvider struct {
similarResults []sonic.SimilarResult
similarErr error
pathResults []sonic.SimilarResult
pathErr error
}
func (m *mockProvider) GetSonicSimilarTracks(_ context.Context, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) {
return m.similarResults, m.similarErr
}
func (m *mockProvider) FindSonicPath(_ context.Context, _, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) {
return m.pathResults, m.pathErr
}
var _ = Describe("Sonic", func() {
var (
ctx context.Context
ds *tests.MockDataStore
loader *mockPluginLoader
service *sonic.Sonic
)
BeforeEach(func() {
ctx = GinkgoT().Context()
ds = &tests.MockDataStore{}
loader = &mockPluginLoader{}
})
Describe("HasProvider", func() {
It("returns false when no plugins available", func() {
loader.names = nil
service = sonic.New(ds, loader, nil)
Expect(service.HasProvider()).To(BeFalse())
})
It("returns true when a plugin is available", func() {
loader.names = []string{"test-plugin"}
service = sonic.New(ds, loader, nil)
Expect(service.HasProvider()).To(BeTrue())
})
})
Describe("GetSonicSimilarTracks", func() {
It("returns error when no plugin available", func() {
loader.names = nil
service = sonic.New(ds, loader, nil)
_, err := service.GetSonicSimilarTracks(ctx, "song-1", 10)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("returns error when media file not found", func() {
loader.names = []string{"test-plugin"}
loader.provider = &mockProvider{}
loader.loadOk = true
ds.MockedMediaFile = &tests.MockMediaFileRepo{}
service = sonic.New(ds, loader, matcher.New(ds))
_, err := service.GetSonicSimilarTracks(ctx, "nonexistent", 10)
Expect(err).To(HaveOccurred())
})
It("returns matched results from plugin", func() {
mf1 := model.MediaFile{ID: "song-1", Title: "Test Song", Artist: "Test Artist"}
mf2 := model.MediaFile{ID: "song-2", Title: "Similar Song", Artist: "Test Artist"}
mockRepo := tests.CreateMockMediaFileRepo()
mockRepo.SetData(model.MediaFiles{mf1, mf2})
ds.MockedMediaFile = mockRepo
provider := &mockProvider{
similarResults: []sonic.SimilarResult{
{Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85},
},
}
loader.names = []string{"test-plugin"}
loader.provider = provider
loader.loadOk = true
service = sonic.New(ds, loader, matcher.New(ds))
matches, err := service.GetSonicSimilarTracks(ctx, "song-1", 10)
Expect(err).ToNot(HaveOccurred())
Expect(matches).To(HaveLen(1))
Expect(matches[0].MediaFile.ID).To(Equal("song-2"))
Expect(matches[0].Similarity).To(Equal(0.85))
})
})
Describe("FindSonicPath", func() {
It("returns error when no plugin available", func() {
loader.names = nil
service = sonic.New(ds, loader, nil)
_, err := service.FindSonicPath(ctx, "song-1", "song-2", 25)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("returns error when plugin call fails", func() {
mf1 := model.MediaFile{ID: "song-1", Title: "Start", Artist: "Artist"}
mf2 := model.MediaFile{ID: "song-2", Title: "End", Artist: "Artist"}
mockRepo := tests.CreateMockMediaFileRepo()
mockRepo.SetData(model.MediaFiles{mf1, mf2})
ds.MockedMediaFile = mockRepo
provider := &mockProvider{pathErr: errors.New("plugin error")}
loader.names = []string{"test-plugin"}
loader.provider = provider
loader.loadOk = true
service = sonic.New(ds, loader, matcher.New(ds))
_, err := service.FindSonicPath(ctx, "song-1", "song-2", 25)
Expect(err).To(HaveOccurred())
})
})
})

View File

@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -44,6 +45,10 @@ var _ = Describe("LocalStorage", func() {
})
Describe("newLocalStorage", func() {
BeforeEach(func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
})
Context("with valid path", func() {
It("should create a localStorage instance with correct path", func() {
u, err := url.Parse("file://" + tempDir)
@ -166,6 +171,10 @@ var _ = Describe("LocalStorage", func() {
})
Describe("localStorage.FS", func() {
BeforeEach(func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
})
Context("with existing directory", func() {
It("should return a localFS instance", func() {
u, err := url.Parse("file://" + tempDir)
@ -199,6 +208,7 @@ var _ = Describe("LocalStorage", func() {
var testFile string
BeforeEach(func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
// Create a test file
testFile = filepath.Join(tempDir, "test.mp3")
err := os.WriteFile(testFile, []byte("test data"), 0600)
@ -380,6 +390,7 @@ var _ = Describe("LocalStorage", func() {
Describe("Storage registration", func() {
It("should register localStorage for file scheme", func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
// This tests the init() function indirectly
storage, err := storage.For("file://" + tempDir)
Expect(err).ToNot(HaveOccurred())

View File

@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -54,6 +55,7 @@ var _ = Describe("Storage", func() {
Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp"))
})
It("should return a file implementation for a relative folder", func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage)")
s, err := For("tmp")
Expect(err).ToNot(HaveOccurred())
cwd, _ := os.Getwd()

View File

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

View File

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

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