Merge remote-tracking branch 'origin/master' into error-handling-and-response-bugs/5896

# Conflicts:
#	server/auth.go
#	server/auth_test.go
This commit is contained in:
zapisanchez 2026-09-23 15:51:05 +02:00
commit ecbffde9a5
470 changed files with 15520 additions and 3567 deletions

View File

@ -4,7 +4,7 @@
"dockerfile": "Dockerfile",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
"VARIANT": "1.26",
"VARIANT": "1.27",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v24"

60
.github/workflows/coverage-on-pr.yml vendored Normal file
View File

@ -0,0 +1,60 @@
name: Report coverage on PR
on:
workflow_run:
workflows: ['Pipeline: Test, Lint, Build']
types: [completed]
jobs:
comment:
name: Comment coverage report
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
pull-requests: write
env:
COVERAGE_COMMENT: 'true'
steps:
# Only the config, from the base branch: this job holds a write token, so
# it must never check out the fork.
- name: Check out the octocov config
uses: actions/checkout@v7
with:
sparse-checkout: .octocov.yml
sparse-checkout-cone-mode: false
persist-credentials: false
# Into a subdirectory. A pull_request run executes the fork's own copy of
# pipeline.yml, so every file in here is attacker-controlled.
- uses: actions/download-artifact@v8
with:
name: octocov-pr
path: untrusted
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Verify the artifact and take the coverage profile
id: pr
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
number=$(head -c 20 untrusted/pr_number | tr -d '[:space:]')
case "$number" in ''|*[!0-9]*)
echo "::error::artifact pr_number is not a number"; exit 1;;
esac
sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .head.sha)
if [ "$sha" != "$HEAD_SHA" ]; then
echo "::error::artifact claims PR #$number, but its head $sha is not $HEAD_SHA"; exit 1
fi
cp untrusted/coverage.out coverage.out
echo "number=$number" >> "$GITHUB_OUTPUT"
- uses: k1LoW/octocov-action@v1
env:
# A workflow_run job looks like a push to the default branch. Point
# octocov back at the pull request and at the run that produced it.
GITHUB_PULL_REQUEST_NUMBER: ${{ steps.pr.outputs.number }}
OCTOCOV_GITHUB_REF: refs/pull/${{ steps.pr.outputs.number }}/merge
OCTOCOV_GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
OCTOCOV_GITHUB_RUN_ID: ${{ github.event.workflow_run.id }}

View File

@ -34,16 +34,19 @@ jobs:
}
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
if (!artifacts.length) {
const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-'));
if (!downloadable.length) {
return core.error(`No artifacts found`);
}
let body = `Download the artifacts for this pull request:\n`;
for (const art of artifacts) {
const header = `Download the artifacts for this pull request:`;
let body = `${header}\n`;
for (const art of downloadable) {
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number});
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
// Match on the body too: octocov also comments as github-actions[bot].
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header));
if (existing_comment) {
core.info(`Updating comment ${existing_comment.id}`);
await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});

View File

@ -137,8 +137,10 @@ jobs:
- name: Download dependencies
run: go mod download
- name: Test
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
# Name must stay unique across the workflow: octocov matches step names
# by name across every job, and waits for each match to finish.
- name: Test with coverage
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$')
- name: Test ndpgen
run: |
@ -147,6 +149,84 @@ jobs:
go build -o ndpgen .
./ndpgen --help
- name: Upload coverage profile
uses: actions/upload-artifact@v7
with:
name: octocov-go
path: coverage.out
if-no-files-found: error
go-plugins:
name: Test Go plugins
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v7
- uses: actions/setup-go@v6
id: setup-go
with:
go-version-file: go.mod
# Without this, the suite recompiles every test plugin WASM module,
# which dominates its runtime under -race.
- name: Cache the WASM compilation cache
uses: actions/cache@v6
with:
path: plugins/testdata/.wazero-cache
key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }}
restore-keys: wazero-${{ runner.os }}-
- name: Test plugins
run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 --cover --covermode=atomic --coverprofile=coverage.out --output-dir=. ./plugins/
- name: Upload coverage profile
uses: actions/upload-artifact@v7
with:
name: octocov-plugins
path: coverage.out
if-no-files-found: error
coverage:
name: Report coverage
runs-on: ubuntu-latest
needs: [go, go-plugins]
permissions:
contents: read
actions: write
env:
COVERAGE_COMMENT: 'false'
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
pattern: octocov-*
# Merge here rather than letting octocov do it: octocov reports statement
# coverage for a single profile, but switches to line counting for several.
- name: Merge coverage profiles
run: |
echo "mode: atomic" > coverage.out
awk 'FNR==1 && /^mode:/ {next} {k=$1" "$2; c[k]+=$3} END {for (k in c) print k, c[k]}' \
octocov-*/coverage.out | sort >> coverage.out
- uses: k1LoW/octocov-action@v1
- name: Save the PR number for the comment workflow
if: github.event_name == 'pull_request'
run: echo "${{ github.event.pull_request.number }}" > pr_number
- name: Upload the merged profile for the comment workflow
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v7
with:
name: octocov-pr
path: |
coverage.out
pr_number
if-no-files-found: error
go-windows:
name: Test Go code (Windows)
runs-on: windows-2022
@ -213,12 +293,12 @@ jobs:
run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
- name: Test ndpgen
shell: pwsh
shell: bash
run: |
cd plugins\cmd\ndpgen
cd plugins/cmd/ndpgen
go test -shuffle=on -v
go build -o ndpgen.exe .
.\ndpgen.exe --help
./ndpgen.exe --help
js:
name: Test JS code
@ -284,7 +364,7 @@ jobs:
build:
name: Build
needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
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 ]
@ -469,7 +549,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
done

6
.gitignore vendored
View File

@ -43,4 +43,8 @@ go.work*
.playwright-mcp/
# Temp benchmark files
zz_*_test.go
zz_*_test.go
# wazero compilation cache for the plugins test suite
/plugins/testdata/.wazero-cache/
/plugins/testdata/*.stage/

44
.octocov.yml Normal file
View File

@ -0,0 +1,44 @@
# Code coverage reporting for pull requests. See https://github.com/k1LoW/octocov
# The 30s default is not enough: scanning this repo's artifacts for the baseline
# eats most of it, leaving none for the report upload.
timeout: 5m
coverage:
# A single pre-merged profile: octocov reports statements for one path, but
# switches to line counting when it merges several itself.
paths:
- coverage.out
# Not code under test: tests/ holds the mocks and helpers, *_gen.go is generated.
# Both patterns need the '**/' prefix: the comment workflow has no source tree,
# so octocov cannot shorten the profile's import paths to repo-relative ones.
exclude:
- '**/tests/**'
- '**/*_gen.go'
codeToTestRatio:
# Needs the pull request's own source, which the comment workflow must not
# check out: it holds a write token.
if: env.COVERAGE_COMMENT != 'true'
code:
- '**/*.go'
- '!**/*_test.go'
- '!**/*_gen.go'
test:
- '**/*_test.go'
testExecutionTime:
if: true
steps:
- Test with coverage
- Test plugins
diff:
datastores:
- artifact://${GITHUB_REPOSITORY}
comment:
# Only the 'Report coverage on PR' workflow sets this: a pull_request run from
# a fork gets a read-only token, so commenting from here 403s.
if: env.COVERAGE_COMMENT == 'true'
updatePrevious: true
summary:
if: true
report:
if: is_default_branch
datastores:
- artifact://${GITHUB_REPOSITORY}

View File

@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
########################################################################################################################
### Build xx (original image: tonistiigi/xx)
FROM --platform=$BUILDPLATFORM alpine:3.20 AS xx-build
FROM --platform=$BUILDPLATFORM alpine:3.22 AS xx-build
# v1.9.0
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
@ -43,7 +43,7 @@ COPY --from=ui /build /build
########################################################################################################################
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build-alpine
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build-alpine
COPY --from=xx / /
ARG TARGETPLATFORM
@ -85,7 +85,7 @@ EOT
########################################################################################################################
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
FROM --platform=$BUILDPLATFORM golang:1.26-trixie AS base
FROM --platform=$BUILDPLATFORM golang:1.27-trixie AS base
RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / /
WORKDIR /workspace
@ -152,19 +152,52 @@ RUN xx-verify --static /out/navidrome*
FROM scratch AS binary
COPY --from=build /out /
########################################################################################################################
### Build no-op stubs for mpv's video-output libraries
# mpv links libEGL/libgbm for video output only; Navidrome drives it headless, for audio.
# Real mesa pulls in LLVM + gallium (+218MB uncompressed), so ship stubs it never calls.
FROM --platform=$BUILDPLATFORM alpine:3.22 AS mpv-stubs
COPY --from=xx / /
RUN apk add --no-cache clang lld binutils mesa-egl mesa-gbm
ARG TARGETPLATFORM
RUN xx-apk add --no-cache musl-dev
RUN <<EOT
set -e
mkdir -p /out
for so in libEGL.so.1 libgbm.so.1; do
readelf -sW /usr/lib/$so \
| awk '$5 == "GLOBAL" && $7 != "UND" { print $8 }' \
| sed 's/@.*//' \
| grep -vE '^(_init|_fini|_edata|_end|__bss_start|_GLOBAL_OFFSET_TABLE_)$' \
| sort -u \
| awk '{ print "void " $1 "(void) {}" }' > /tmp/stub.c
test -s /tmp/stub.c
xx-clang -shared -nostdlib -fPIC -Wl,-soname,$so -o /out/$so /tmp/stub.c
xx-verify /out/$so
done
EOT
########################################################################################################################
### Build Final Image
FROM alpine:3.20 AS final
FROM alpine:3.22 AS final
LABEL maintainer="deluan@navidrome.org"
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
# Install runtime dependencies
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer,
# otherwise the deleted bytes still ship in the image.
RUN apk add -U --no-cache curl ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
for lib in libwebp libwebpdemux libwebpmux; do \
target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \
[ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \
done
done && \
rm -rf /usr/lib/gallium-pipe /usr/lib/dri \
/usr/lib/libEGL.so* /usr/lib/libgbm.so* /usr/lib/libgallium*.so /usr/lib/libLLVM.so* \
/usr/lib/libGL.so* /usr/lib/libGLESv2.so* /usr/lib/libglapi.so*
COPY --from=mpv-stubs /out/ /usr/lib/
RUN mpv --no-video --ao=null --version > /dev/null
# Copy navidrome binary (musl build for Docker, enables native libwebp)
COPY --from=build-alpine /out/navidrome /app/

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.12.0
GOLANGCI_LINT_VERSION ?= v2.13.2
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")

View File

@ -13,15 +13,26 @@ import (
"strings"
"github.com/microcosm-cc/bluemonday"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
)
const apiBaseURL = "https://api.deezer.com"
const authBaseURL = "https://auth.deezer.com"
var (
ErrNotFound = errors.New("deezer: not found")
)
// errCodeQuota is Deezer's "Quota limit exceeded"; it arrives in the body, with HTTP 200
// and no rate-limit headers, so the body code is the only signal.
const errCodeQuota = 4
type deezerError struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
}
func (e *deezerError) Error() string {
return fmt.Sprintf("deezer error(%d): %s", e.Code, e.Message)
}
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
@ -56,7 +67,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A
}
if len(results.Data) == 0 {
return nil, ErrNotFound
return nil, agents.ErrNotFound
}
return results.Data, nil
}
@ -74,20 +85,31 @@ func (c *client) makeRequest(req *http.Request, response any) error {
return err
}
// Checked before the status: a throttled request still answers 200, and decoding its body
// into a result type yields an empty one, which reads as "nothing found".
if err := parseBodyError(data); err != nil {
return err
}
if resp.StatusCode != 200 {
return c.parseError(data)
return fmt.Errorf("deezer http status: (%d)", resp.StatusCode)
}
return json.Unmarshal(data, response)
}
func (c *client) parseError(data []byte) error {
var deezerError Error
err := json.Unmarshal(data, &deezerError)
if err != nil {
return err
// parseBodyError returns the error Deezer reported in the body, or nil when it reported none.
func parseBodyError(data []byte) error {
var body errorResponse
// Discarded: a payload that is not an error object leaves Error nil, which is the "none" answer.
_ = json.Unmarshal(data, &body)
switch {
case body.Error == nil:
return nil
case body.Error.Code == errCodeQuota:
return errors.Join(body.Error, agents.ErrRetryLater)
default:
return body.Error
}
return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message)
}
func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {

View File

@ -2,12 +2,14 @@ package deezer
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/navidrome/navidrome/core/agents"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -41,7 +43,37 @@ var _ = Describe("client", func() {
})
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(MatchError(ErrNotFound))
Expect(err).To(MatchError(agents.ErrNotFound))
})
// Deezer answers 200 with no rate-limit headers when throttling, so this body is the only signal.
It("reports an exhausted quota as a retryable error, not as a missing artist", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(
`{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)),
})
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(HaveOccurred())
Expect(err).ToNot(MatchError(agents.ErrNotFound),
"a throttled lookup would otherwise settle the artist as having no image")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
Expect(err.Error()).To(ContainSubstring("Quota limit exceeded"))
})
It("reports a non-quota body error as a plain error", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(
`{"error":{"type":"Exception","message":"Invalid query","code":100}}`)),
})
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(HaveOccurred())
Expect(err).ToNot(MatchError(agents.ErrNotFound))
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse(),
"only a throttle asks the caller to come back later")
})
})

View File

@ -5,7 +5,6 @@ import (
"context"
"errors"
"fmt"
"net/http"
"slices"
"strings"
@ -15,6 +14,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/slice"
)
@ -36,9 +36,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface {
dataStore: dataStore,
languages: conf.Server.Deezer.Languages,
}
httpClient := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
httpClient := httpclient.New(consts.DefaultHttpClientTimeOut)
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
agent.client = newClient(cachedHttpClient)
return agent
@ -93,9 +91,6 @@ func isPlaceholderPicture(url string) bool {
func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit)
if errors.Is(err, ErrNotFound) || len(artists) == 0 {
return nil, agents.ErrNotFound
}
if err != nil {
return nil, err
}

View File

@ -3,6 +3,7 @@ package deezer
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
@ -80,6 +81,22 @@ var _ = Describe("deezerAgent", func() {
Expect(artist.ID).To(Equal(2))
})
// The artwork worker settles an artist as "no image" on agents.ErrNotFound, so a throttled
// lookup reaching that here would record a permanent absence.
It("surfaces an exhausted quota instead of reporting the artist as not found", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(
`{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)),
})
_, err := agent.searchArtist(ctx, "Queen")
Expect(err).To(HaveOccurred())
Expect(err).ToNot(MatchError(agents.ErrNotFound))
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
})
It("returns ErrNotFound when no result matches the name exactly", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,

View File

@ -22,12 +22,8 @@ type Artist struct {
Type string `json:"type"`
}
type Error struct {
Error struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
type errorResponse struct {
Error *deezerError `json:"error"`
}
type RelatedArtists struct {

View File

@ -26,7 +26,7 @@ var _ = Describe("Responses", func() {
Describe("Error", func() {
It("parses the error response correctly", func() {
var errorResp Error
var errorResp errorResponse
body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`)
err := json.Unmarshal(body, &errorResp)
Expect(err).To(BeNil())

View File

@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/httpclient"
"golang.org/x/net/html"
)
@ -59,9 +60,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
secret: conf.Server.LastFM.Secret,
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.httpClient = chc
l.client = newClient(l.apiKey, l.secret, chc)
@ -242,6 +241,10 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti
var (
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
// Not a RetryLaterError on purpose: parking the agent would also stall its API-backed
// methods, which the page block does not affect.
errNoArtistPage = errors.New("no artist image in Last.fm page")
)
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
@ -268,7 +271,9 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
var res []agents.ExternalImage
n := cascadia.Query(node, artistOpenGraphQuery)
if n == nil {
return res, nil
// A real artist page always has og:image; its absence means a bot challenge or a redesign.
log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL)
return nil, errNoArtistPage
}
for _, attr := range n.Attr {
if attr.Key != "content" {
@ -406,7 +411,8 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err)
return errors.Join(err, scrobbler.ErrRetryLater)
}
if lfErr.Code == 11 || lfErr.Code == 16 {
// 11: service offline; 16: temporarily unavailable. Rate limiting is mapped by the client.
if lfErr.Code == 11 || lfErr.Code == 16 || errors.Is(err, scrobbler.ErrRetryLater) {
return errors.Join(err, scrobbler.ErrRetryLater)
}
return errors.Join(err, scrobbler.ErrUnrecoverable)

View File

@ -100,6 +100,15 @@ var _ = Describe("lastfmAgent", func() {
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)),
StatusCode: 200,
}
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
})
})
Describe("Language Fallback", func() {
@ -497,6 +506,16 @@ var _ = Describe("lastfmAgent", func() {
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)),
StatusCode: 200,
}
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue())
})
It("returns ErrRetryLater on http errors", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`internal server error`)),
@ -629,18 +648,41 @@ var _ = Describe("lastfmAgent", func() {
Expect(images).To(BeEmpty())
})
It("returns empty list if page has no meta tags", func() {
It("errors when the page has no meta tags", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).To(MatchError(errNoArtistPage))
})
It("errors when Last.fm serves a bot challenge page", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(err).To(MatchError(errNoArtistPage))
Expect(images).To(BeEmpty())
})
It("does not park the agent: the failure is not a retry-later", func() {
// A RetryLaterError would cool down the agent's API-backed methods too.
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse())
})
It("returns error if API call fails", func() {
apiClient.Err = errors.New("api error")
_, err := agent.GetArtistImages(ctx, "123", "U2", "")

View File

@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/req"
)
@ -41,9 +42,7 @@ func NewRouter(ds model.DataStore) *Router {
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
r.Handler = r.routes()
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
r.client = newClient(r.apiKey, r.secret, hc)
return r
}
@ -133,7 +132,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
sessionKey, err := s.client.getSession(ctx, token)
if err != nil {
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token,
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid,
"requestId", middleware.GetReqID(ctx), err)
return err
}

View File

@ -5,6 +5,7 @@ import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@ -14,11 +15,15 @@ import (
"strings"
"time"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
)
const (
apiBaseUrl = "https://ws.audioscrobbler.com/2.0/"
// errCodeRateLimit is Last.fm's "rate limit exceeded"; it arrives in the body, with HTTP 200
// and no rate-limit headers, so the body code is the only signal.
errCodeRateLimit = 29
)
type lastFMError struct {
@ -44,6 +49,12 @@ type client struct {
hc httpDoer
}
// escapePlus works around Last.fm decoding artist.* and track.* params twice, turning "+" into a space.
// album.getInfo decodes only once, so it must not use this.
func escapePlus(s string) string {
return strings.ReplaceAll(s, "+", "%2B")
}
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
params := url.Values{}
params.Add("method", "album.getInfo")
@ -61,7 +72,7 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
params := url.Values{}
params.Add("method", "artist.getInfo")
params.Add("artist", name)
params.Add("artist", escapePlus(name))
params.Add("lang", lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
@ -73,7 +84,7 @@ func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*
func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) {
params := url.Values{}
params.Add("method", "artist.getSimilar")
params.Add("artist", name)
params.Add("artist", escapePlus(name))
params.Add("limit", strconv.Itoa(limit))
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
@ -85,7 +96,7 @@ func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (
func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) {
params := url.Values{}
params.Add("method", "artist.getTopTracks")
params.Add("artist", name)
params.Add("artist", escapePlus(name))
params.Add("limit", strconv.Itoa(limit))
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
@ -97,8 +108,8 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int)
func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
params := url.Values{}
params.Add("method", "track.getSimilar")
params.Add("track", name)
params.Add("artist", artist)
params.Add("track", escapePlus(name))
params.Add("artist", escapePlus(artist))
params.Add("limit", strconv.Itoa(limit))
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
@ -225,7 +236,11 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
return nil, jsonErr
}
if response.Error != 0 {
return &response, &lastFMError{Code: response.Error, Message: response.Message}
var err error = &lastFMError{Code: response.Error, Message: response.Message}
if response.Error == errCodeRateLimit {
err = errors.Join(err, &agents.RetryLaterError{})
}
return &response, err
}
return &response, nil

View File

@ -35,6 +35,15 @@ var _ = Describe("client", func() {
Expect(album.Name).To(Equal("Believe"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
})
It("does not double-encode plus signs", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.albumGetInfo(context.Background(), "Lungs", "Florence + the Machine", "", "en")
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence + the Machine"))
})
})
Describe("artistGetInfo", func() {
@ -48,6 +57,15 @@ var _ = Describe("client", func() {
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
})
It("double-encodes plus signs in the artist name", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.artistGetInfo(context.Background(), "Florence + the Machine", "en")
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence %2B the Machine"))
})
It("fails if Last.fm returns an http status != 200", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
@ -107,6 +125,15 @@ var _ = Describe("client", func() {
Expect(len(similar.Artists)).To(Equal(2))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar"))
})
It("double-encodes plus signs in the artist name", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.artistGetSimilar(context.Background(), "+44", 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("%2B44"))
})
})
Describe("artistGetTopTracks", func() {
@ -119,6 +146,15 @@ var _ = Describe("client", func() {
Expect(len(top.Track)).To(Equal(2))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks"))
})
It("double-encodes plus signs in the artist name", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.artistGetTopTracks(context.Background(), "C+C Music Factory", 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("C%2BC Music Factory"))
})
})
Describe("trackGetSimilar", func() {
@ -135,6 +171,17 @@ var _ = Describe("client", func() {
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
})
It("double-encodes plus signs in the track and artist names", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.trackGetSimilar(context.Background(), "1+1", "Queen + Paul Rodgers", 5)
Expect(err).ToNot(HaveOccurred())
query := httpClient.SavedRequest.URL.Query()
Expect(query.Get("track")).To(Equal("1%2B1"))
Expect(query.Get("artist")).To(Equal("Queen %2B Paul Rodgers"))
})
It("returns empty list when no similar tracks found", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}

View File

@ -3,7 +3,6 @@ package listenbrainz
import (
"context"
"errors"
"net/http"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
@ -12,6 +11,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/slice"
)
@ -33,9 +33,7 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
baseURL: conf.Server.ListenBrainz.BaseURL,
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = newClient(l.baseURL, chc)
return l

View File

@ -164,6 +164,19 @@ var _ = Describe("listenBrainzAgent", func() {
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
})
It("keeps a 429 scrobble for retry and carries the delay", func() {
httpClient.Res = http.Response{
StatusCode: 429,
Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}},
Body: io.NopCloser(bytes.NewBufferString(`{"code":429,"error":"rate limited"}`)),
}
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue())
retry, ok := errors.AsType[*agents.RetryLaterError](err)
Expect(ok).To(BeTrue())
Expect(retry.RetryIn).To(Equal(7 * time.Second))
})
})
Describe("GetArtistUrl", func() {

View File

@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/utils/httpclient"
)
type sessionKeysRepo interface {
@ -37,9 +38,7 @@ func NewRouter(ds model.DataStore) *Router {
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
r.Handler = r.routes()
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc)
return r
}

View File

@ -13,6 +13,7 @@ import (
"slices"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
)
@ -21,6 +22,12 @@ const (
labsBase = "https://labs.api.listenbrainz.org/"
)
// retryLaterErr reads the wait ListenBrainz asked for. It sends X-RateLimit-Reset-In
// (delta-seconds) on every response, including the 429, and never Retry-After.
func retryLaterErr(h http.Header) *agents.RetryLaterError {
return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(h.Get("X-RateLimit-Reset-In"))}
}
var (
ErrorNotFound = errors.New("listenbrainz: not found")
)
@ -174,6 +181,9 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return nil, retryLaterErr(resp.Header)
}
decoder := json.NewDecoder(resp.Body)
var response listenBrainzResponse
@ -185,6 +195,10 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en
return nil, jsonErr
}
if response.Code != 0 && response.Code != 200 {
// LB also reports rate limiting as a body code, not only as an HTTP status.
if response.Code == http.StatusTooManyRequests {
return &response, retryLaterErr(resp.Header)
}
return &response, &listenBrainzError{Code: response.Code, Message: response.Error}
}
@ -211,6 +225,9 @@ func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint
// On a 200 code, there is no code. Decode using using error message if it exists
if resp.StatusCode != 200 {
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return nil, retryLaterErr(resp.Header)
}
decoder := json.NewDecoder(resp.Body)
var lbzError lbzHttpError

View File

@ -4,13 +4,17 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -461,4 +465,73 @@ var _ = Describe("client", func() {
}))
})
})
Describe("rate limiting", func() {
It("returns RetryLaterError with the header delay on 429", func() {
httpClient.Res = http.Response{
StatusCode: 429,
Header: http.Header{"X-Ratelimit-Reset-In": []string{"3"}},
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
}
_, err := client.validateToken(context.Background(), "token")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
retry, ok := errors.AsType[*agents.RetryLaterError](err)
Expect(ok).To(BeTrue())
Expect(retry.RetryIn).To(Equal(3 * time.Second))
})
It("returns RetryLaterError with zero delay when no header is present", func() {
httpClient.Res = http.Response{
StatusCode: 429,
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
}
_, err := client.validateToken(context.Background(), "token")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
retry, _ := errors.AsType[*agents.RetryLaterError](err)
Expect(retry.RetryIn).To(BeZero())
})
DescribeTable("caps absurd header values at one hour",
func(header string) {
httpClient.Res = http.Response{
StatusCode: 429,
Header: http.Header{"X-Ratelimit-Reset-In": []string{header}},
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
}
_, err := client.validateToken(context.Background(), "token")
retry, _ := errors.AsType[*agents.RetryLaterError](err)
Expect(retry.RetryIn).To(Equal(time.Hour))
},
Entry("a large value", "999999"),
Entry("a huge value", "99999999999"),
// Scaling this to nanoseconds before capping wraps past 2^64, landing on ~0.29s.
Entry("a value that overflows int64 nanoseconds", "18446744074"),
)
It("maps a body-level 429 sent with a non-429 status", func() {
httpClient.Res = http.Response{
StatusCode: 200,
Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}},
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
}
_, err := client.validateToken(context.Background(), "token")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
retry, ok := errors.AsType[*agents.RetryLaterError](err)
Expect(ok).To(BeTrue())
Expect(retry.RetryIn).To(Equal(7 * time.Second))
})
It("returns RetryLaterError on a 429 from makeGenericRequest", func() {
httpClient.Res = http.Response{
StatusCode: 429,
Header: http.Header{"X-Ratelimit-Reset-In": []string{"5"}},
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
}
_, err := client.getArtistUrl(context.Background(), "1")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
retry, ok := errors.AsType[*agents.RetryLaterError](err)
Expect(ok).To(BeTrue())
Expect(retry.RetryIn).To(Equal(5 * time.Second))
})
})
})

View File

@ -42,9 +42,10 @@ func init() {
"stored trace of the last resolution; also initializes plugin agents, which may open "+
"external connections")
artworkReprocessCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil,
"kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable")
"kinds to reprocess ("+kindPrefixes(artwork.ReprocessKinds)+"); repeatable")
artworkReprocessCmd.Flags().StringSliceVar(&artworkSources, "source", nil,
"only items currently resolved from these sources (e.g. folder, external:deezer, absent)")
"only items currently resolved from these sources (e.g. folder, external:deezer, absent, "+
"or failed for the absent ones that gave up)")
artworkReprocessCmd.Flags().BoolVar(&artworkAll, "all", false, "reprocess every kind")
artworkReprocessCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false,
"report what would be queued and exit without queueing")
@ -113,7 +114,7 @@ var artworkCancelCmd = &cobra.Command{
"Work already picked up is not interrupted, and an item with no artwork yet can be\n" +
"queued again by the hourly re-check. The selection is applied again when you confirm,\n" +
"so anything queued after the preview is cancelled too. Use it to call off a bulk\n" +
"backfill, not to stop the worker.",
"reprocess, not to stop the worker.",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
runCancel(cmd.Context())
@ -122,7 +123,7 @@ var artworkCancelCmd = &cobra.Command{
var artworkStatusCmd = &cobra.Command{
Use: "status",
Short: "Report the artwork queue, where artwork resolves from, and the backfill state",
Short: "Report the artwork queue, where artwork resolves from, and the config state",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
runStatus(cmd.Context())
@ -146,9 +147,11 @@ type sourceCount struct {
count int64
}
// absentCount partitions a kind's absent states: noImage was answered, failed gave up.
type absentCount struct {
kind model.Kind
model.ArtworkAbsentStat
kind model.Kind
noImage int64
failed int64
}
type statusReport struct {
@ -170,16 +173,6 @@ func queueTotal(stats []model.ArtworkQueueStat) int64 {
return n
}
func (r statusReport) backfillQueued() int64 {
var n int64
for _, s := range r.queue {
if s.Priority == model.ArtworkPriorityBackfill {
n += s.Count
}
}
return n
}
func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) {
q := ds.ArtworkQueue(ctx)
var rep statusReport
@ -188,8 +181,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
}
cutoff := time.Now().Add(-artwork.StaleAbsentAge)
for _, k := range artwork.RecheckKinds {
for _, k := range artwork.ReprocessKinds {
sources, err := q.SourcesInUse(k)
if err != nil {
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
@ -201,12 +193,15 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error
return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err)
}
rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n})
// An absent state is exactly a row with no source, so it needs no second query.
if s == "" {
failed, err := q.CountBySource(k, []string{model.ArtworkSourceFailed})
if err != nil {
return rep, fmt.Errorf("counting failed %s artwork: %w", k, err)
}
rep.absent = append(rep.absent, absentCount{kind: k, noImage: n - failed, failed: failed})
}
}
stat, err := q.CountAbsent(k, cutoff)
if err != nil {
return rep, fmt.Errorf("counting absent %s artwork: %w", k, err)
}
rep.absent = append(rep.absent, absentCount{kind: k, ArtworkAbsentStat: stat})
}
rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs()
@ -234,19 +229,20 @@ func formatStatus(rep statusReport) string {
}
fmt.Fprintln(w, "\nAbsent (resolved, no image found)")
fmt.Fprintln(w, " KIND\tABSENT\tDUE FOR RECHECK")
fmt.Fprintln(w, " KIND\tNO IMAGE\tFAILED")
for _, a := range rep.absent {
fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.Total, a.Stale)
fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.noImage, a.failed)
}
fmt.Fprintf(w, " (eligible once the last attempt is older than %gh; re-queued %d per kind per hour, oldest first)\n",
artwork.StaleAbsentAge.Hours(), artwork.StaleAbsentRecheckBatch)
fmt.Fprintln(w, " (nothing retries these; 'artwork reprocess --source absent' retries both columns)")
fmt.Fprintln(w, " (failed = gave up rather than being answered, so the ones most likely to resolve;\n"+
" 'artwork reprocess --source failed' retries just those)")
fmt.Fprintln(w, "\nBackfill")
fmt.Fprintf(w, " State:\t%s\n", backfillState(rep))
fmt.Fprintln(w, "\nConfig")
fmt.Fprintf(w, " State:\t%s\n", configState(rep))
fmt.Fprintf(w, " Stored fingerprint:\t%s\n", cmp.Or(rep.stored, "(none)"))
fmt.Fprintf(w, " Current fingerprint:\t%s\n", rep.current)
if len(rep.inputs) > 0 {
fmt.Fprintln(w, " Fingerprint inputs (changing any of these re-resolves the whole library):")
fmt.Fprintln(w, " Fingerprint inputs (changing any of these makes the stored artwork stale):")
for _, in := range rep.inputs {
fmt.Fprintf(w, " %s:\t%s\n", in.Name, in.Value)
}
@ -256,18 +252,10 @@ func formatStatus(rep statusReport) string {
return sb.String()
}
// backfillState leads with the queued backlog: by the time anyone runs this, backfill has usually
// already stored the new fingerprint, and "up to date" would bury the flood it is still working through.
func backfillState(rep statusReport) string {
pending := "fingerprint changed — every artist, album, playlist and radio will be re-enqueued on the next startup"
if n := rep.backfillQueued(); n > 0 {
if rep.stored != rep.current {
return fmt.Sprintf("backfill running: %d items queued, and %s", n, pending)
}
return fmt.Sprintf("backfill running: %d items queued (fingerprint up to date)", n)
}
func configState(rep statusReport) string {
if rep.stored != rep.current {
return pending
return "fingerprint changed — stored artwork keeps the old resolution; " +
"run 'artwork reprocess --all' to apply it"
}
return "up to date"
}
@ -297,8 +285,8 @@ type artworkPriority struct {
var knownPriorities = []artworkPriority{
{"bump", model.ArtworkPriorityBump},
{"scan", model.ArtworkPriorityScan},
{"backfill", model.ArtworkPriorityBackfill},
{"recheck", model.ArtworkPriorityRecheck},
{"backfill", model.ArtworkPriorityBackfill},
}
// priorityName falls back to the number: a row written by a newer version still has to print.
@ -351,29 +339,41 @@ func runReprocess(ctx context.Context) {
func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) {
// A source filter on its own is already a complete selection, so it does not also need a kind.
if all || (len(kinds) == 0 && len(sources) > 0) {
return artwork.RecheckKinds, nil
return artwork.ReprocessKinds, nil
}
if len(kinds) == 0 {
return nil, fmt.Errorf("no selector given: pass --kind, --source or --all")
}
return parseAll(kinds, func(s string) (model.Kind, error) {
return parseArtworkKind(s, artwork.RecheckKinds)
return parseArtworkKind(s, artwork.ReprocessKinds)
})
}
// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI.
const absentSource = "absent"
// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI, and
// failedSource the subset of it that gave up rather than being answered.
const (
absentSource = "absent"
failedSource = "failed"
)
func repositorySources(sources []string) []string {
return slice.Map(sources, func(s string) string {
if s == absentSource {
switch s {
case absentSource:
return ""
case failedSource:
return model.ArtworkSourceFailed
}
return s
})
}
func displaySource(s string) string { return cmp.Or(s, absentSource) }
func displaySource(s string) string {
if s == model.ArtworkSourceFailed {
return failedSource
}
return cmp.Or(s, absentSource)
}
type confirmFunc func(out io.Writer, total, external int64) bool
@ -447,7 +447,7 @@ func validateSources(q model.ArtworkQueueRepository, sources []string) error {
return nil
}
var inUse []string
for _, k := range artwork.RecheckKinds {
for _, k := range artwork.ReprocessKinds {
found, err := q.SourcesInUse(k)
if err != nil {
return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
@ -456,14 +456,16 @@ func validateSources(q model.ArtworkQueueRepository, sources []string) error {
}
var unknown []string
for _, s := range sources {
if s != "" && !slices.Contains(inUse, s) { // the reserved absent source is valid even when nothing is absent
// The reserved absent and failed sources are valid even when nothing currently matches them.
if s != "" && s != model.ArtworkSourceFailed && !slices.Contains(inUse, s) {
unknown = append(unknown, displaySource(s))
}
}
if len(unknown) == 0 {
return nil
}
valid := slice.Map(inUse, displaySource)
// failed is accepted but never stored, so listing only what is in use would hide it.
valid := append(slice.Map(inUse, displaySource), failedSource)
slices.Sort(valid)
return fmt.Errorf("no artwork resolves from %s; sources in use: %s",
strings.Join(unknown, ", "), cmp.Or(strings.Join(valid, ", "), "(none)"))
@ -478,6 +480,18 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
return err
}
// Derived from what actually drives the queries, so a filter added to this signature cannot
// silently keep stamping the fingerprint for a partial run.
markApplied := func() error {
if len(sources) > 0 || len(kinds) < len(artwork.ReprocessKinds) {
return nil
}
if err := artwork.MarkConfigApplied(ctx, ds); err != nil {
return fmt.Errorf("recording the applied artwork config: %w", err)
}
return nil
}
matched := make([]int64, len(kinds))
var total, external int64
for i, k := range kinds {
@ -496,8 +510,9 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
fmt.Fprintln(out, "\nDry run: nothing was queued.")
return nil
case total == 0:
// An empty match set still leaves nothing resolved under the old config.
fmt.Fprintln(out, "Nothing was queued.")
return nil
return markApplied()
case !confirm(out, total, external):
fmt.Fprintln(out, "Aborted: nothing was queued.")
return nil
@ -519,7 +534,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
if skipped := total - queued; skipped > 0 {
fmt.Fprintf(out, "Already queued, left unchanged: %d (priority and retry backoff untouched).\n", skipped)
}
return nil
return markApplied()
}
func runCancel(ctx context.Context) {
@ -546,7 +561,7 @@ func cancelSelection(kinds, priorities []string, all bool) ([]model.Kind, []int,
if len(kinds) == 0 && len(priorities) == 0 {
return nil, nil, fmt.Errorf("no selector given: pass --kind, --priority or --all")
}
// RefreshableKinds, not RecheckKinds: media files are queued, so --kind must reach them.
// RefreshableKinds, not ReprocessKinds: media files are queued, so --kind must reach them.
outKinds, err := parseAll(kinds, func(s string) (model.Kind, error) {
return parseArtworkKind(s, artwork.RefreshableKinds)
})
@ -648,7 +663,7 @@ func refreshItems(ctx context.Context, ds model.DataStore, targets []model.Artwo
for _, t := range targets {
kind, id := t.Kind, t.ID
// artwork.Refresh would happily queue an id that does not exist, orphaning a queue row.
if _, err := artworkItemName(ctx, ds, kind, id); err != nil {
if _, err := artwork.ItemName(ctx, ds, kind, id); err != nil {
log.Error(ctx, "Item not found", "kind", kind, "id", id, err)
failed++
continue
@ -963,7 +978,7 @@ func runExplain(ctx context.Context, args []string) {
}
kind, id := targets[0].Kind, targets[0].ID
name, err := artworkItemName(ctx, ds, kind, id)
name, err := artwork.ItemName(ctx, ds, kind, id)
if err != nil {
log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err)
}
@ -1005,60 +1020,3 @@ func runExplain(ctx context.Context, args []string) {
log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.resolveErr)
}
}
// artworkItemName looks the entity up under its own kind, so a mismatched kind/id pair is
// reported as not found instead of silently explaining another entity's artwork.
func artworkItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) {
switch kind {
case model.KindArtistArtwork:
ar, err := ds.Artist(ctx).Get(id)
if err != nil {
return "", err
}
return ar.Name, nil
case model.KindAlbumArtwork:
al, err := ds.Album(ctx).Get(id)
if err != nil {
return "", err
}
return al.Name, nil
case model.KindPlaylistArtwork:
pls, err := ds.Playlist(ctx).Get(id)
if err != nil {
return "", err
}
return pls.Name, nil
case model.KindRadioArtwork:
rd, err := ds.Radio(ctx).Get(id)
if err != nil {
return "", err
}
return rd.Name, nil
case model.KindMediaFileArtwork:
mf, err := ds.MediaFile(ctx).Get(id)
if err != nil {
return "", err
}
return mf.Title, nil
case model.KindDiscArtwork:
return discArtworkName(ctx, ds, id)
}
return "", fmt.Errorf("unsupported kind %q", kind.Prefix())
}
func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string, error) {
albumID, discNumber, err := model.ParseDiscArtworkID(id)
if err != nil {
return "", err
}
al, err := ds.Album(ctx).Get(albumID)
if err != nil {
return "", err
}
name := fmt.Sprintf("%s (disc %d)", al.Name, discNumber)
// The subtitle is itself a DiscArtPriority candidate, so name it where the chain can be read against it.
if subtitle := strings.TrimSpace(al.Discs[discNumber]); subtitle != "" {
name += ": " + subtitle
}
return name, nil
}

View File

@ -3,7 +3,6 @@ package cmd
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
@ -20,20 +19,20 @@ import (
var _ = Describe("parseArtworkKind", func() {
It("accepts a supported kind", func() {
k, err := parseArtworkKind("ar", artwork.RecheckKinds)
k, err := parseArtworkKind("ar", artwork.ReprocessKinds)
Expect(err).ToNot(HaveOccurred())
Expect(k).To(Equal(model.KindArtistArtwork))
})
It("rejects an unknown kind and lists the valid ones", func() {
_, err := parseArtworkKind("zz", artwork.RecheckKinds)
_, err := parseArtworkKind("zz", artwork.ReprocessKinds)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ar"))
Expect(err.Error()).To(ContainSubstring("al"))
})
It("rejects a known kind the command does not accept", func() {
_, err := parseArtworkKind("mf", artwork.RecheckKinds)
_, err := parseArtworkKind("mf", artwork.ReprocessKinds)
Expect(err).To(HaveOccurred())
})
@ -424,33 +423,6 @@ var _ = Describe("explainConfig", func() {
)
})
var _ = Describe("discArtworkName", func() {
var ds *tests.MockDataStore
BeforeEach(func() {
albumRepo := tests.CreateMockAlbumRepo()
albumRepo.SetData(model.Albums{{ID: "al-1", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}}})
ds = &tests.MockDataStore{MockedAlbum: albumRepo}
})
It("names the album, the disc and its subtitle", func() {
name, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1:2")
Expect(err).ToNot(HaveOccurred())
Expect(name).To(Equal("Sandinista! (disc 2): Side Three"))
})
It("omits the subtitle when the disc has none", func() {
name, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1:1")
Expect(err).ToNot(HaveOccurred())
Expect(name).To(Equal("Sandinista! (disc 1)"))
})
It("rejects an id that is not <albumID>:<disc>", func() {
_, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1")
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("artwork refresh command", func() {
It("requires at least one argument", func() {
Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{})).To(HaveOccurred())
@ -469,13 +441,13 @@ var _ = Describe("artwork reprocess selection", func() {
It("returns every kind for --all", func() {
ks, err := selectedKinds(nil, nil, true)
Expect(err).ToNot(HaveOccurred())
Expect(ks).To(ConsistOf(artwork.RecheckKinds))
Expect(ks).To(ConsistOf(artwork.ReprocessKinds))
})
It("returns every kind for a source filter given without a kind", func() {
ks, err := selectedKinds(nil, []string{"folder"}, false)
Expect(err).ToNot(HaveOccurred())
Expect(ks).To(ConsistOf(artwork.RecheckKinds), "--source alone is already a complete selection")
Expect(ks).To(ConsistOf(artwork.ReprocessKinds), "--source alone is already a complete selection")
})
It("returns only the named kinds", func() {
@ -539,6 +511,11 @@ var _ = Describe("repositorySources", func() {
Expect(repositorySources([]string{"absent", "folder"})).To(Equal([]string{"", "folder"}))
})
It("maps the failed name onto the pseudo-source, and back for display", func() {
Expect(repositorySources([]string{failedSource})).To(Equal([]string{model.ArtworkSourceFailed}))
Expect(displaySource(model.ArtworkSourceFailed)).To(Equal(failedSource))
})
It("keeps an empty selection empty, meaning every source", func() {
Expect(repositorySources(nil)).To(BeEmpty())
})
@ -634,6 +611,25 @@ var _ = Describe("reprocessArtwork", func() {
Expect(queue.Count()).To(BeZero())
})
DescribeTable("records the applied config only for a run that leaves nothing on the old one",
func(selected []model.Kind, sources []string, dryRun, applied bool) {
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed())
want := "stale-fingerprint"
if applied {
want = artwork.ConfigFingerprint()
}
Expect(ds.Property(ctx).Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(want))
},
Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true),
Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true),
Entry("filtered by source", artwork.ReprocessKinds, []string{"external:deezer"}, false, false),
Entry("a subset of kinds", []model.Kind{model.KindAlbumArtwork}, nil, false, false),
Entry("a dry run applies nothing", artwork.ReprocessKinds, nil, true, false),
)
It("queues the matching items at recheck priority, leaving their artwork state alone", func() {
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
@ -787,6 +783,14 @@ var _ = Describe("reprocessArtwork", func() {
imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected")
})
It("names failed among the valid sources when rejecting a typo", func() {
err := reprocessArtwork(ctx, ds, kinds, []string{"faild"}, imageAgents, true, accept, &out)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed"),
"failed is accepted but never stored, so it has to be named explicitly")
})
It("accepts a source another kind uses, letting the empty selection report itself", func() {
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, []string{"folder"},
imageAgents, false, decline, &out)).To(Succeed())
@ -819,14 +823,16 @@ var _ = Describe("collectStatus", func() {
ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed())
}
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-artwork.StaleAbsentAge-time.Hour))
put(model.KindArtistArtwork, "ar-3", "", "", time.Now())
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour))
// ar-3 is absent because it gave up, so the two absent artists split across the columns.
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3",
ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed())
put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now())
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed())
})
It("reports the queue, the source distribution and the absent ages", func() {
It("reports the queue, the source distribution and the absent totals", func() {
rep, err := collectStatus(ctx, ds)
Expect(err).ToNot(HaveOccurred())
@ -837,8 +843,8 @@ var _ = Describe("collectStatus", func() {
sourceCount{kind: model.KindArtistArtwork, source: "", count: 2},
sourceCount{kind: model.KindAlbumArtwork, source: "folder", count: 1},
))
Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork,
ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}}))
Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork, noImage: 1, failed: 1}),
"two absent artists, one answered and one that gave up")
})
It("compares the stored fingerprint against the current one", func() {
@ -872,7 +878,7 @@ var _ = Describe("formatStatus", func() {
{kind: model.KindArtistArtwork, source: "", count: 2},
},
absent: []absentCount{
{kind: model.KindArtistArtwork, ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}},
{kind: model.KindArtistArtwork, noImage: 1, failed: 1},
},
inputs: []artwork.FingerprintInput{{Name: "Agents", Value: "deezer,lastfm"}},
stored: "abc123",
@ -905,50 +911,37 @@ var _ = Describe("formatStatus", func() {
Expect(sources).To(MatchRegexp(`artist\s+absent\s+2`))
})
It("prints the absent total and how many are due for recheck", func() {
absent := block(formatStatus(rep), "Absent (resolved, no image found)")
Expect(absent).To(MatchRegexp(`artist\s+2\s+1`))
It("partitions the absent states into answered and gave up", func() {
out := block(formatStatus(rep), "Absent (resolved, no image found)")
Expect(out).To(ContainSubstring("NO IMAGE"))
Expect(out).To(MatchRegexp(`artist\s+1\s+1`), "1 answered plus 1 failed, summing to 2 absent")
Expect(formatStatus(rep)).To(ContainSubstring("artwork reprocess --source failed"))
})
It("states the recheck window and the drip rate the absent counts are bucketed against", func() {
Expect(formatStatus(rep)).To(ContainSubstring(fmt.Sprintf("%gh", artwork.StaleAbsentAge.Hours())))
Expect(formatStatus(rep)).To(ContainSubstring("100 per kind per hour"))
It("says absent states are never retried on their own, and names both commands that do", func() {
out := formatStatus(rep)
Expect(out).To(ContainSubstring("nothing retries these"))
Expect(out).To(ContainSubstring("artwork reprocess --source absent"))
Expect(out).To(ContainSubstring("artwork reprocess --source failed"))
})
It("leads with the queued backlog, which is the finding, not with the fingerprint verdict", func() {
out := block(formatStatus(rep), "Backfill")
Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`),
"an operator scanning for trouble must not read 'up to date' while 2 items churn")
Expect(out).To(ContainSubstring("fingerprint up to date"))
})
It("keeps the re-enqueue warning while a backfill is already running", func() {
rep.stored = "older"
out := block(formatStatus(rep), "Backfill")
Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`))
Expect(out).To(ContainSubstring("re-enqueued"),
"the stored fingerprint is still stale, so a second full re-enqueue is pending on top of this one")
})
It("reports up to date only once the backfill has drained", func() {
rep.queue = []model.ArtworkQueueStat{{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}}
Expect(block(formatStatus(rep), "Backfill")).To(MatchRegexp(`State:\s+up to date`))
It("reports a matching fingerprint as up to date, whatever else is queued", func() {
Expect(block(formatStatus(rep), "Config")).To(MatchRegexp(`State:\s+up to date`))
})
It("echoes the config inputs a fingerprint change would have come from", func() {
out := block(formatStatus(rep), "Backfill")
out := block(formatStatus(rep), "Config")
Expect(out).To(MatchRegexp(`Agents:\s+deezer,lastfm`))
Expect(out).To(ContainSubstring("abc123"), "the fingerprint values themselves must be printed")
})
It("reports a changed fingerprint as a pending re-resolve of everything", func() {
It("reports a changed fingerprint as stale artwork, and names the command that applies it", func() {
rep.stored = "older"
rep.queue = nil
out := formatStatus(rep)
Expect(out).To(ContainSubstring("fingerprint changed"))
Expect(out).To(ContainSubstring("artwork reprocess --all"))
Expect(out).ToNot(ContainSubstring("up to date"))
})
@ -1042,7 +1035,7 @@ var _ = Describe("needsImageAgents", func() {
It("is false once the chains no longer reach an agent", func() {
conf.Server.CoverArtPriority = "cover.*"
conf.Server.ArtistArtPriority = "artist.*"
Expect(needsImageAgents(artwork.RecheckKinds)).To(BeFalse())
Expect(needsImageAgents(artwork.ReprocessKinds)).To(BeFalse())
})
})

View File

@ -2,9 +2,7 @@ package cmd
import (
"context"
"fmt"
"os"
"strings"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
@ -31,7 +29,7 @@ func init() {
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
backupRoot.AddCommand(pruneCmd)
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
_ = restoreCommand.MarkFlagRequired("backup-file")
backupRoot.AddCommand(restoreCommand)
@ -78,24 +76,12 @@ func runBackup(ctx context.Context) {
conf.Server.Backup.Path = conf.NewDir(backupDir)
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
requireExistingDB()
start := time.Now()
path, err := db.Backup(ctx)
if err != nil {
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err)
}
elapsed := time.Since(start)
@ -111,36 +97,17 @@ func runPrune(ctx context.Context) {
conf.Server.Backup.Count = backupCount
}
if conf.Server.Backup.Count == 0 && !force {
fmt.Println("Warning: pruning ALL backups")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Prune cancelled")
return
}
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") {
log.Warn("Prune cancelled")
return
}
requireExistingDB()
start := time.Now()
count, err := db.Prune(ctx)
if err != nil {
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err)
}
elapsed := time.Since(start)
@ -149,36 +116,29 @@ func runPrune(ctx context.Context) {
}
func runRestore(ctx context.Context) {
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
requireExistingDB()
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
if !force {
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Restore cancelled")
// A relative --backup-file is resolved against Backup.Path, the same folder
// `backup create` writes to. Without this, the value was treated as relative
// to the working directory, where the file does not exist.
if !filepath.IsAbs(restorePath) {
backupPath, err := conf.Server.Backup.Path.Path()
if err != nil {
log.Fatal("Backup directory not available", "backupPath", conf.Server.Backup.Path, err)
return
}
restorePath = filepath.Join(backupPath, restorePath)
}
if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") {
log.Warn("Restore cancelled")
return
}
start := time.Now()
err := db.Restore(ctx, restorePath)
if err != nil {
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
log.Fatal("Error restoring database", "backupFile", restorePath, err)
}
elapsed := time.Since(start)

99
cmd/doctor.go Normal file
View File

@ -0,0 +1,99 @@
package cmd
import (
"context"
"database/sql"
"fmt"
"io"
"os"
"github.com/navidrome/navidrome/db"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(doctorCmd)
}
var doctorCmd = &cobra.Command{
Use: "doctor",
Short: "Check your Navidrome installation for problems",
Long: "Run read-only health checks and report what was found. Checks the database for " +
"corruption and foreign key violations, and reports whether 'navidrome search rebuild' " +
"can fix what it finds. This command never alters your data",
Run: func(cmd *cobra.Command, _ []string) {
runDoctor(cmd.Context())
},
}
func runDoctor(ctx context.Context) {
requireExistingDB()
healthy := doctor(ctx, db.Db(), os.Stdout)
db.Close(ctx)
if !healthy {
os.Exit(1)
}
}
const recoveryAdvice = "Restore a backup (navidrome backup restore), or try SQLite's '.recover' command."
func printFindings(out io.Writer, check, noun string, items []string) {
fmt.Fprintf(out, "%s reported %d %s:\n", check, len(items), noun)
for _, item := range items {
fmt.Fprintln(out, " "+item)
}
}
func doctor(ctx context.Context, database *sql.DB, out io.Writer) bool {
healthy := true
fmt.Fprintln(out, "Checking database integrity...")
issues, truncated, err := db.IntegrityCheck(ctx, database)
switch {
case err != nil:
fmt.Fprintln(out, "The integrity check could not complete: "+err.Error())
fmt.Fprintln(out, recoveryAdvice)
return false
case len(issues) == 0:
fmt.Fprintln(out, "Integrity check passed.")
default:
healthy = false
printFindings(out, "Integrity check", "issue(s)", issues)
switch {
case truncated:
fmt.Fprintln(out, "The integrity check stopped at its limit, so the damage may reach further than listed.")
fmt.Fprintln(out, recoveryAdvice)
case db.IsFTSCorruptionOnly(issues):
fmt.Fprintln(out, "Corruption is limited to the search index. Run 'navidrome search rebuild' to fix it.")
default:
fmt.Fprintln(out, "Corruption is not limited to the search index, and cannot be repaired automatically.")
fmt.Fprintln(out, recoveryAdvice)
}
}
fmt.Fprintln(out, "Checking foreign keys...")
violations, err := db.ForeignKeyCheck(ctx, database)
switch {
case err != nil:
healthy = false
fmt.Fprintln(out, "The foreign key check could not complete: "+err.Error())
case len(violations) == 0:
fmt.Fprintln(out, "Foreign key check passed.")
default:
healthy = false
lines := make([]string, 0, len(violations))
for _, v := range violations {
lines = append(lines,
fmt.Sprintf("%s: %d row(s) reference missing rows in %s", v.Table, v.Count, v.Parent))
}
printFindings(out, "Foreign key check", "violation(s)", lines)
fmt.Fprintln(out, "These are orphaned rows, not corruption. 'navidrome scan -f' clears some of them "+
"in library data; the rest have to be removed by hand.")
}
if healthy {
fmt.Fprintln(out, "Database is healthy.")
}
return healthy
}

124
cmd/doctor_test.go Normal file
View File

@ -0,0 +1,124 @@
package cmd
import (
"context"
"database/sql"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/db"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("doctor", func() {
var (
ctx context.Context
dbPath string
database *sql.DB
out *strings.Builder
reopen func()
)
// A file-backed DB so specs can corrupt raw pages; a table named like a real FTS
// search table so IsFTSCorruptionOnly matches, plus a parent/child pair for FK checks.
BeforeEach(func() {
ctx = context.Background()
dbPath = filepath.Join(GinkgoT().TempDir(), "doctor.db")
reopen = func() {
var err error
database, err = sql.Open(db.Dialect, dbPath)
Expect(err).ToNot(HaveOccurred())
database.SetMaxOpenConns(1)
}
reopen()
DeferCleanup(func() { _ = database.Close() })
for _, stmt := range []string{
`create virtual table media_file_fts using fts5(title, content='', content_rowid='rowid')`,
`insert into media_file_fts(rowid, title) values (1, 'teenage lobotomy'), (2, 'rockaway beach')`,
`create table library(id integer primary key)`,
`create table media_file(id integer primary key, library_id integer references library(id))`,
} {
_, err := database.ExecContext(ctx, stmt)
Expect(err).ToNot(HaveOccurred())
}
out = &strings.Builder{}
})
It("reports a healthy database", func() {
Expect(doctor(ctx, database, out)).To(BeTrue())
Expect(out.String()).To(ContainSubstring("Database is healthy."))
})
It("points to 'search rebuild' when corruption is limited to the search index", func() {
_, err := database.ExecContext(ctx,
`update media_file_fts_data set block = x'deadbeefdeadbeef' where id > 1`)
Expect(err).ToNot(HaveOccurred())
Expect(doctor(ctx, database, out)).To(BeFalse())
Expect(out.String()).To(ContainSubstring("navidrome search rebuild"))
})
It("points to a backup restore when corruption is not limited to the search index", func() {
_, err := database.ExecContext(ctx,
`insert into library(id)
with recursive s(x) as (select 1 union all select x+1 from s where x < 200)
select x from s`)
Expect(err).ToNot(HaveOccurred())
var rootPage, pageSize int64
Expect(database.QueryRowContext(ctx,
`select rootpage from sqlite_master where name = 'library'`).Scan(&rootPage)).To(Succeed())
Expect(database.QueryRowContext(ctx, `pragma page_size`).Scan(&pageSize)).To(Succeed())
Expect(database.Close()).To(Succeed())
f, err := os.OpenFile(dbPath, os.O_WRONLY, 0600)
Expect(err).ToNot(HaveOccurred())
_, err = f.WriteAt([]byte{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef}, (rootPage-1)*pageSize+40)
Expect(err).ToNot(HaveOccurred())
Expect(f.Close()).To(Succeed())
reopen()
Expect(doctor(ctx, database, out)).To(BeFalse())
Expect(out.String()).To(ContainSubstring("backup restore"))
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
})
It("reports foreign key violations", func() {
_, err := database.ExecContext(ctx, `pragma foreign_keys = off`)
Expect(err).ToNot(HaveOccurred())
_, err = database.ExecContext(ctx, `insert into media_file(id, library_id) values (1, 999)`)
Expect(err).ToNot(HaveOccurred())
Expect(doctor(ctx, database, out)).To(BeFalse())
Expect(out.String()).To(ContainSubstring("Foreign key check reported"))
Expect(out.String()).To(ContainSubstring("media_file"))
Expect(out.String()).To(ContainSubstring("navidrome scan -f"))
// GC never touches player, share or playqueue, so don't promise a full cleanup.
Expect(out.String()).To(ContainSubstring("removed by hand"))
})
// Every issue names an FTS-like index, so IsFTSCorruptionOnly alone would send the
// user to 'search rebuild', but the pragma stopped at its limit without saying so.
It("does not blame the search index when the issue list is truncated", func() {
for _, stmt := range []string{
`create table t(a, b)`,
`with recursive s(x) as (select 1 union all select x+1 from s where x < 300)
insert into t select x, x + 10000 from s`,
`create index media_file_fts_probe on t(a)`,
`pragma writable_schema=on`,
`update sqlite_master set sql = 'CREATE INDEX media_file_fts_probe ON t(b)'
where name = 'media_file_fts_probe'`,
} {
_, err := database.ExecContext(ctx, stmt)
Expect(err).ToNot(HaveOccurred())
}
Expect(database.Close()).To(Succeed())
reopen()
Expect(doctor(ctx, database, out)).To(BeFalse())
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
Expect(out.String()).To(ContainSubstring("backup restore"))
})
})

171
cmd/missing.go Normal file
View File

@ -0,0 +1,171 @@
package cmd
import (
"bufio"
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/spf13/cobra"
)
var missingListFormat string
func init() {
missingListCmd.Flags().StringVarP(&missingListFormat, "format", "f", "csv", "output format [supported values: csv, json]")
missingCmd.AddCommand(missingListCmd)
missingCmd.AddCommand(missingFixCmd)
rootCmd.AddCommand(missingCmd)
}
var (
missingCmd = &cobra.Command{
Use: "missing",
Short: "Manage missing files",
Long: "List files marked as missing and remap them onto existing files",
}
missingListCmd = &cobra.Command{
Use: "list",
Short: "List missing files",
Run: func(cmd *cobra.Command, _ []string) {
runMissingList(cmd.Context())
},
}
missingFixCmd = &cobra.Command{
Use: "fix <missing path|id> <target path|id>",
Short: "Remap a missing file onto an existing file",
Long: "Remap a file marked as missing onto an existing (non-missing) file, the same way\n" +
"the scanner reconciles moved or renamed files. Each argument may be a media file ID,\n" +
"a library-relative path, or a libraryID:path pair.",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
runMissingFix(cmd.Context(), args[0], args[1])
},
}
)
type displayMissingFile struct {
ID string `json:"id"`
LibraryID int `json:"libraryId"`
Title string `json:"title"`
Album string `json:"album"`
Artist string `json:"artist"`
Path string `json:"path"`
}
func runMissingList(ctx context.Context) {
if missingListFormat != "csv" && missingListFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", missingListFormat)
}
ds, ctx := getAdminContext(ctx)
mfs, err := ds.MediaFile(ctx).GetCursor(model.QueryOptions{
Filters: squirrel.Eq{"missing": true},
Sort: "path",
})
if err == nil {
err = writeMissingList(os.Stdout, missingListFormat, mfs)
}
if err != nil {
log.Fatal(ctx, "Failed to retrieve missing files", err)
}
}
// writeMissingList streams the cursor so a library with many missing files doesn't get loaded into memory
func writeMissingList(w io.Writer, format string, mfs model.MediaFileCursor) error {
if format == "json" {
bw := bufio.NewWriter(w)
_, _ = io.WriteString(bw, "[")
sep := ""
for mf, err := range mfs {
if err != nil {
return err
}
j, _ := json.Marshal(displayMissingFile{ID: mf.ID, LibraryID: mf.LibraryID, Title: mf.Title, Album: mf.Album, Artist: mf.Artist, Path: mf.Path})
_, _ = fmt.Fprintf(bw, "%s%s", sep, j)
sep = ","
}
_, _ = io.WriteString(bw, "]\n")
return bw.Flush()
}
cw := csv.NewWriter(w)
_ = cw.Write([]string{"id", "library id", "title", "album", "artist", "path"})
for mf, err := range mfs {
if err != nil {
return err
}
_ = cw.Write([]string{mf.ID, strconv.Itoa(mf.LibraryID), mf.Title, mf.Album, mf.Artist, mf.Path})
}
cw.Flush()
return cw.Error()
}
func runMissingFix(ctx context.Context, missingRef, targetRef string) {
ds, ctx := getAdminContext(ctx)
missing := resolveMediaFile(ctx, ds, missingRef)
target := resolveMediaFile(ctx, ds, targetRef)
if err := core.NewMaintenance(ds).RemapMissingFile(ctx, missing.ID, target.ID); err != nil {
log.Fatal(ctx, "Failed to remap missing file", "missing", missing.Path, "target", target.Path, err)
}
fmt.Printf("Remapped %q onto %q\n", missing.Path, target.Path)
}
// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path).
func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile {
mf, err := ds.MediaFile(ctx).Get(ref)
if err == nil {
return mf
}
if !errors.Is(err, model.ErrNotFound) {
log.Fatal(ctx, "Error looking up media file", "ref", ref, err)
}
mfs, err := ds.MediaFile(ctx).FindByPaths([]string{ref})
if err != nil {
log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err)
}
if len(mfs) == 0 {
log.Fatal(ctx, "No media file found", "ref", ref)
}
mfs = preferQualified(ref, mfs)
if len(mfs) > 1 {
log.Fatal(ctx, "Path matches multiple files; disambiguate with an ID or libraryID:path", "ref", ref, "matches", len(mfs))
}
return &mfs[0]
}
// preferQualified resolves the ambiguity FindByPaths creates by searching a "libraryID:path"
// reference both ways: an explicit library wins over a file literally named like one.
func preferQualified(ref string, mfs model.MediaFiles) model.MediaFiles {
id, path, ok := strings.Cut(ref, ":")
if !ok {
return mfs
}
libraryID, err := strconv.Atoi(id)
if err != nil {
return mfs
}
qualified := slice.Filter(mfs, func(mf model.MediaFile) bool {
return mf.LibraryID == libraryID && strings.EqualFold(mf.Path, path)
})
if len(qualified) == 0 {
return mfs
}
return qualified
}

81
cmd/missing_test.go Normal file
View File

@ -0,0 +1,81 @@
package cmd
import (
"errors"
"strings"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("writeMissingList", func() {
cursor := func(err error, mfs ...model.MediaFile) model.MediaFileCursor {
return func(yield func(model.MediaFile, error) bool) {
for _, mf := range mfs {
if !yield(mf, nil) {
return
}
}
if err != nil {
yield(model.MediaFile{}, err)
}
}
}
song := model.MediaFile{ID: "1", LibraryID: 1, Path: "Bach: Goldberg/01.mp3", Title: "Aria", Album: "Goldberg", Artist: "Bach"}
It("writes csv with a header, quoting as needed", func() {
var out strings.Builder
Expect(writeMissingList(&out, "csv", cursor(nil, song))).To(Succeed())
Expect(out.String()).To(Equal("id,library id,title,album,artist,path\n1,1,Aria,Goldberg,Bach,Bach: Goldberg/01.mp3\n"))
})
It("writes a json array", func() {
var out strings.Builder
Expect(writeMissingList(&out, "json", cursor(nil, song, song))).To(Succeed())
Expect(out.String()).To(MatchJSON(`[
{"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"},
{"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"}
]`))
})
It("writes an empty json array when nothing is missing", func() {
var out strings.Builder
Expect(writeMissingList(&out, "json", cursor(nil))).To(Succeed())
Expect(out.String()).To(MatchJSON(`[]`))
})
It("returns the cursor's error", func() {
var out strings.Builder
Expect(writeMissingList(&out, "csv", cursor(errors.New("boom"), song))).To(MatchError("boom"))
})
})
var _ = Describe("preferQualified", func() {
target := model.MediaFile{ID: "want", LibraryID: 1, Path: "foo.mp3"}
decoy := model.MediaFile{ID: "decoy", LibraryID: 1, Path: "1:foo.mp3"}
It("picks the library-qualified match over a literal path that looks like one", func() {
Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, decoy})).To(Equal(model.MediaFiles{target}))
})
It("picks the named library when the same path exists in two", func() {
other := model.MediaFile{ID: "other", LibraryID: 2, Path: "foo.mp3"}
Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, other})).To(Equal(model.MediaFiles{target}))
})
It("leaves an unqualified reference ambiguous", func() {
both := model.MediaFiles{target, {ID: "other", LibraryID: 2, Path: "foo.mp3"}}
Expect(preferQualified("foo.mp3", both)).To(Equal(both))
})
It("leaves it alone when the prefix is not a library id", func() {
both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}}
Expect(preferQualified("x:foo.mp3", both)).To(Equal(both))
})
It("leaves it alone when no candidate matches the qualified form", func() {
both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}}
Expect(preferQualified("9:nope.mp3", both)).To(Equal(both))
})
})

View File

@ -2,6 +2,7 @@ package cmd
import (
"context"
"net/http"
"os"
"os/signal"
"strings"
@ -85,6 +86,7 @@ func runNavidrome(ctx context.Context) {
g.Go(startSignaller(ctx))
g.Go(startScheduler(ctx))
g.Go(startPlaybackServer(ctx))
g.Go(startJellyfinDiscovery(ctx))
g.Go(schedulePeriodicBackup(ctx))
g.Go(startInsightsCollector(ctx))
g.Go(scheduleDBAnalyzer(ctx))
@ -138,7 +140,7 @@ func startServer(ctx context.Context) func() error {
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler())
}
if conf.Server.DevEnableProfiler {
a.MountRouter("Profiling", "/debug", middleware.Profiler())
a.MountRouter("Profiling", "/debug", profilerHandler())
}
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
@ -147,6 +149,14 @@ func startServer(ctx context.Context) func() error {
}
}
// profilerHandler returns the pprof handler. net/http/pprof resolves the profile
// name from the raw request path, so the BasePath has to come off first.
func profilerHandler() http.Handler {
// A trailing or root slash would make StripPrefix drop the leading slash chi needs.
basePath := strings.TrimRight(conf.Server.BasePath, "/")
return http.StripPrefix(basePath, middleware.Profiler())
}
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
func schedulePeriodicScan(ctx context.Context) func() error {
return func() error {
@ -334,6 +344,18 @@ func startInsightsCollector(ctx context.Context) func() error {
}
}
// startJellyfinDiscovery never returns an error: a discovery failure must not stop the server.
func startJellyfinDiscovery(ctx context.Context) func() error {
return func() error {
if !conf.Server.Jellyfin.Enabled || !conf.Server.Jellyfin.AutoDiscovery {
log.Debug("Jellyfin auto-discovery is DISABLED")
return nil
}
CreateJellyfinDiscovery().Serve(ctx)
return nil
}
}
// startPlaybackServer starts the Navidrome playback server, if configured.
// It is responsible for the Jukebox functionality
func startPlaybackServer(ctx context.Context) func() error {
@ -357,21 +379,18 @@ func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() erro
}
}
// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the
// recurring stale-absent recheck and prune jobs.
// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and
// reports an artwork config change without acting on it.
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
return func() error {
schedulerInstance := scheduler.GetInstance()
if _, err := schedulerInstance.Add(consts.ArtworkStaleAbsentRecheckSchedule, func() {
if err := worker.EnqueueStaleAbsentAll(ctx); err != nil {
log.Error(ctx, "Error enqueueing stale artwork rechecks", err)
}
if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() {
if err := worker.EnqueueMissingAll(ctx); err != nil {
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
}
}); err != nil {
log.Error(ctx, "Error scheduling artwork stale-absent recheck", err)
log.Error(ctx, "Error scheduling artwork missing-state recheck", err)
}
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
@ -388,23 +407,8 @@ func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) fu
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
}
backfilled, err := worker.Backfill(ctx)
if err != nil {
log.Error(ctx, "Error running artwork backfill", err)
return nil
}
if !backfilled {
return nil
}
log.Info(ctx, "Artwork backfill enqueued, scheduling a follow-up prune")
timer := time.NewTimer(consts.ArtworkPostBackfillPruneDelay)
defer timer.Stop()
select {
case <-timer.C:
if err := worker.RunPrune(ctx); err != nil {
log.Error(ctx, "Error running post-backfill artwork prune", err)
}
case <-ctx.Done():
if err := worker.ReconcileConfig(ctx); err != nil {
log.Error(ctx, "Error checking the artwork config fingerprint", err)
}
return nil
}

46
cmd/root_test.go Normal file
View File

@ -0,0 +1,46 @@
package cmd
import (
"net/http"
"net/http/httptest"
"path"
"runtime/pprof"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = pprof.NewProfile("nd-profiler-test")
var _ = Describe("profilerHandler", func() {
// Mirrors how server.MountRouter mounts the handler.
mount := func() http.Handler {
router := chi.NewRouter()
router.Mount(path.Join(conf.Server.BasePath, "/debug"), profilerHandler())
return router
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
DescribeTable("serves a named profile",
func(basePath string) {
conf.Server.BasePath = basePath
w := httptest.NewRecorder()
target := path.Join(basePath, "/debug/pprof/nd-profiler-test") + "?debug=1"
mount().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(HavePrefix("nd-profiler-test profile: total 0"))
},
Entry("without a BasePath", ""),
Entry("with a BasePath", "/music"),
Entry("with a root BasePath", "/"),
Entry("with a trailing-slash BasePath", "/music/"),
)
})

55
cmd/search.go Normal file
View File

@ -0,0 +1,55 @@
package cmd
import (
"context"
"fmt"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/spf13/cobra"
)
var searchRebuildForce bool
func init() {
rootCmd.AddCommand(searchRoot)
searchRebuildCmd.Flags().BoolVarP(&searchRebuildForce, "force", "f", false, "bypass rebuild confirmation")
searchRoot.AddCommand(searchRebuildCmd)
}
var (
searchRoot = &cobra.Command{
Use: "search",
Short: "Search index maintenance",
}
searchRebuildCmd = &cobra.Command{
Use: "rebuild",
Short: "Rebuild the full-text search index",
Long: "Drop and rebuild the full-text search index from the library data. Fixes a corrupted " +
"or desynced search index without any data loss. Note that 'navidrome doctor' detects a " +
"corrupted index, but cannot tell when the index has merely drifted out of sync with the " +
"library. This must be done offline",
Run: func(cmd *cobra.Command, _ []string) {
runSearchRebuild(cmd.Context())
},
}
)
func runSearchRebuild(ctx context.Context) {
requireExistingDB()
if !searchRebuildForce && !confirmYES("This will rebuild the search index. Make sure Navidrome is not running.") {
log.Warn("Rebuild cancelled")
return
}
fmt.Println("Rebuilding the search index...")
err := db.RebuildFTS(ctx, db.Db())
db.Close(ctx)
if err != nil {
log.Fatal("Error rebuilding the search index", err)
}
fmt.Println("Search index rebuilt successfully.")
}

View File

@ -5,8 +5,11 @@ import (
"errors"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
@ -15,6 +18,23 @@ import (
"github.com/navidrome/navidrome/persistence"
)
// requireExistingDB aborts the command when the database file (DbPath minus DSN
// params) does not exist.
func requireExistingDB() {
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
}
}
func confirmYES(warning string) bool {
fmt.Println(warning)
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
return input == "YES" && err == nil
}
// newTabWriter keeps every CLI table on the same column settings.
func newTabWriter(out io.Writer) *tabwriter.Writer {
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)

View File

@ -21,6 +21,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
@ -70,13 +71,17 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
insights := metrics.GetInstance(dataStore)
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
user := core.NewUser(dataStore, manager)
maintenance := core.NewMaintenance(dataStore)
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader)
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
quickConnect := quickconnect.GetInstance()
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect)
return router
}
@ -97,10 +102,10 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
@ -129,10 +134,11 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
quickConnect := quickconnect.GetInstance()
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect)
return router
}
@ -165,6 +171,13 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
return router
}
func CreateJellyfinDiscovery() *jellyfin.Discovery {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
discovery := jellyfin.NewDiscovery(dataStore)
return discovery
}
func CreateInsights() metrics.Insights {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
@ -186,7 +199,7 @@ func CreateScanner(ctx context.Context) model.Scanner {
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
return modelScanner
}
@ -197,7 +210,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
}
@ -246,7 +259,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.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(sonic.Engine), new(*sonic.Sonic)), 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)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, 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(sonic.Engine), new(*sonic.Sonic)), 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)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View File

@ -36,13 +36,14 @@ var allProviders = wire.NewSet(
server.New,
subsonic.New,
jellyfin.New,
jellyfin.NewDiscovery,
nativeapi.New,
public.New,
persistence.New,
lastfm.NewRouter,
listenbrainz.NewRouter,
events.GetBroker,
scanner.New,
scanner.GetInstance,
scanner.GetWatcher,
metrics.GetPrometheusInstance,
db.Db,
@ -108,6 +109,12 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
))
}
func CreateJellyfinDiscovery() *jellyfin.Discovery {
panic(wire.Build(
allProviders,
))
}
func CreateInsights() metrics.Insights {
panic(wire.Build(
allProviders,

View File

@ -235,6 +235,8 @@ type jellyfinOptions struct {
// ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated
// GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users.
ExposedPublicUsers string
AutoDiscovery bool
QuickConnect bool
// MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB
// cursor — and its pooled connection — for the whole client-paced response, so without a bound
// enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI.
@ -315,6 +317,12 @@ var currentGOOS = func() string {
return runtime.GOOS
}
// TLSEnabled reports whether the server serves HTTPS. Both halves are required,
// so callers cannot infer it from the certificate alone.
func (c *configOptions) TLSEnabled() bool {
return c.TLSCert != "" && c.TLSKey != ""
}
var (
Server = &configOptions{}
hooks []func()
@ -345,6 +353,13 @@ func LoadFromFile(confFile string) {
Load(true)
}
func durationNonNegativeOrDefault(val *time.Duration, original time.Duration) {
if val.Nanoseconds() < 0 {
log.Warn("Duration is a negative value. Using default value", "value", *val, "default", original)
*val = original
}
}
func Load(noConfigDump bool) {
parseIniFileConfiguration()
remapEnvVarKeysFromConfig()
@ -394,7 +409,7 @@ func Load(noConfigDump bool) {
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)
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
}
@ -412,6 +427,20 @@ func Load(noConfigDump bool) {
log.SetLogSourceLine(Server.DevLogSourceLine)
log.SetRedacting(Server.EnableLogRedacting)
durationNonNegativeOrDefault(&Server.SessionTimeout, consts.DefaultSessionTimeout)
durationNonNegativeOrDefault(&Server.SmartPlaylistRefreshDelay, consts.DefaultSmartRefresh)
durationNonNegativeOrDefault(&Server.DefaultShareExpiration, consts.DefaultShareExpiration)
durationNonNegativeOrDefault(&Server.UIPlaybackReportInterval, consts.DefaultUIPlaybackReportInterval)
durationNonNegativeOrDefault(&Server.AuthWindowLength, consts.DefaultAuthWindowLength)
durationNonNegativeOrDefault(&Server.Scanner.WatcherWait, consts.DefaultWatcherWait)
durationNonNegativeOrDefault(&Server.DevActivityPanelUpdateRate, consts.DefaultActivityPanelUpdateRate)
durationNonNegativeOrDefault(&Server.DevArtworkThrottleBacklogTimeout, consts.RequestThrottleBacklogTimeout)
durationNonNegativeOrDefault(&Server.DevArtistInfoTimeToLive, consts.ArtistInfoTimeToLive)
durationNonNegativeOrDefault(&Server.DevAlbumInfoTimeToLive, consts.AlbumInfoTimeToLive)
durationNonNegativeOrDefault(&Server.DevInsightsInitialDelay, consts.InsightsInitialDelay)
durationNonNegativeOrDefault(&Server.DevPluginCompilationTimeout, consts.DefaultPluginCompilationTimeout)
// Log deprecated, removed and unknown options
for _, o := range deprecatedOptions {
logDeprecatedOptions(o.name, o.replacement)
@ -961,7 +990,7 @@ func setViperDefaults() {
viper.SetDefault("autoimportplaylists", true)
viper.SetDefault("defaultplaylistpublicvisibility", false)
viper.SetDefault("playlistspath", "")
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
viper.SetDefault("smartPlaylistRefreshDelay", consts.DefaultSmartRefresh)
viper.SetDefault("enabledownloads", true)
viper.SetDefault("enableexternalservices", true)
viper.SetDefault("enablem3uexternalalbumart", false)
@ -1005,14 +1034,14 @@ func setViperDefaults() {
viper.SetDefault("maximagesize", consts.DefaultMaxImageSize)
viper.SetDefault("enablesharing", true)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
viper.SetDefault("defaultshareexpiration", consts.DefaultShareExpiration)
viper.SetDefault("defaultdownloadableshare", false)
viper.SetDefault("gatrackingid", "")
viper.SetDefault("enableinsightscollector", true)
viper.SetDefault("enablescheduleddbanalyze", true)
viper.SetDefault("enablelogredacting", true)
viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second)
viper.SetDefault("authwindowlength", consts.DefaultAuthWindowLength)
viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("extauth.userheader", "Remote-User")
viper.SetDefault("extauth.trustedsources", "")
@ -1060,6 +1089,8 @@ func setViperDefaults() {
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
viper.SetDefault("jellyfin.enabled", false)
viper.SetDefault("jellyfin.servername", "")
viper.SetDefault("jellyfin.autodiscovery", false)
viper.SetDefault("jellyfin.quickconnect", true)
viper.SetDefault("enablescrobblehistory", true)
viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("backup.path", "")

View File

@ -5,9 +5,13 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -326,6 +330,21 @@ var _ = Describe("Configuration", func() {
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
})
It("creates the log file readable only by the owner", func() {
if runtime.GOOS == "windows" {
Skip("file modes are not enforced on Windows")
}
logFile := filepath.Join(GinkgoT().TempDir(), "navidrome.log")
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("logfile", logFile)
DeferCleanup(log.SetOutput, os.Stderr)
conf.Load(true)
info, err := os.Stat(logFile)
Expect(err).ToNot(HaveOccurred())
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600)))
})
It("is called when BaseURL is invalid", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("baseurl", "://invalid")
@ -452,4 +471,73 @@ var _ = Describe("Configuration", func() {
Entry("INI format", "ini"),
Entry("JSON format", "json"),
)
It("should use default values for negative duration fields", func() {
filename := filepath.Join("testdata", "invalid_duration.toml")
conf.InitConfig(filename, false)
conf.Load(true)
server := conf.Server
Expect(server.SessionTimeout).To(Equal(consts.DefaultSessionTimeout))
Expect(server.SmartPlaylistRefreshDelay).To(Equal(consts.DefaultSmartRefresh))
Expect(server.DefaultShareExpiration).To(Equal(consts.DefaultShareExpiration))
Expect(server.UIPlaybackReportInterval).To(Equal(consts.DefaultUIPlaybackReportInterval))
Expect(server.AuthWindowLength).To(Equal(consts.DefaultAuthWindowLength))
Expect(server.Scanner.WatcherWait).To(Equal(consts.DefaultWatcherWait))
Expect(server.DevActivityPanelUpdateRate).To(Equal(consts.DefaultActivityPanelUpdateRate))
Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(consts.RequestThrottleBacklogTimeout))
Expect(server.DevArtistInfoTimeToLive).To(Equal(consts.ArtistInfoTimeToLive))
Expect(server.DevAlbumInfoTimeToLive).To(Equal(consts.AlbumInfoTimeToLive))
Expect(server.DevInsightsInitialDelay).To(Equal(consts.InsightsInitialDelay))
Expect(server.DevPluginCompilationTimeout).To(Equal(consts.DefaultPluginCompilationTimeout))
})
It("should use parsed values for duration fields", func() {
conf.InitConfig(filepath.Join("testdata", "valid_duration.toml"), false)
conf.Load(true)
configured := 1 * time.Second
server := conf.Server
Expect(server.SessionTimeout).To(Equal(configured))
Expect(server.SmartPlaylistRefreshDelay).To(Equal(configured))
Expect(server.DefaultShareExpiration).To(Equal(configured))
Expect(server.UIPlaybackReportInterval).To(Equal(configured))
Expect(server.AuthWindowLength).To(Equal(configured))
Expect(server.Scanner.WatcherWait).To(Equal(configured))
Expect(server.DevActivityPanelUpdateRate).To(Equal(configured))
Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(configured))
Expect(server.DevArtistInfoTimeToLive).To(Equal(configured))
Expect(server.DevAlbumInfoTimeToLive).To(Equal(configured))
Expect(server.DevInsightsInitialDelay).To(Equal(configured))
Expect(server.DevPluginCompilationTimeout).To(Equal(configured))
})
})
var _ = Describe("TLSEnabled", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("is false when neither the certificate nor the key is set", func() {
Expect(conf.Server.TLSEnabled()).To(BeFalse())
})
It("is true when both the certificate and the key are set", func() {
conf.Server.TLSCert = "cert.pem"
conf.Server.TLSKey = "key.pem"
Expect(conf.Server.TLSEnabled()).To(BeTrue())
})
It("is false when only the certificate is set", func() {
conf.Server.TLSCert = "cert.pem"
Expect(conf.Server.TLSEnabled()).To(BeFalse())
})
It("is false when only the key is set", func() {
conf.Server.TLSKey = "key.pem"
Expect(conf.Server.TLSEnabled()).To(BeFalse())
})
})

12
conf/testdata/invalid_duration.toml vendored Normal file
View File

@ -0,0 +1,12 @@
SessionTimeout = "-10s"
SmartPlaylistRefreshDelay = "-10s"
UIPlaybackReportInterval = "-10s"
AuthWindowLength = "-10s"
DefaultShareExpiration = "-10s"
Scanner.WatcherWait = "-10s"
DevActivityPanelUpdateRate = "-10s"
DevArtworkThrottleBacklogTimeout = "-10s"
DevArtistInfoTimeToLive = "-10s"
DevAlbumInfoTimeToLive = "-10s"
DevInsightsInitialDelay = "-10s"
DevPluginCompilationTimeout = "-10s"

12
conf/testdata/valid_duration.toml vendored Normal file
View File

@ -0,0 +1,12 @@
SessionTimeout = "1s"
SmartPlaylistRefreshDelay = "1s"
UIPlaybackReportInterval = "1s"
AuthWindowLength = "1s"
DefaultShareExpiration = "1s"
Scanner.WatcherWait = "1s"
DevActivityPanelUpdateRate = "1s"
DevArtworkThrottleBacklogTimeout = "1s"
DevArtistInfoTimeToLive = "1s"
DevAlbumInfoTimeToLive = "1s"
DevInsightsInitialDelay = "1s"
DevPluginCompilationTimeout = "1s"

View File

@ -24,8 +24,8 @@ const (
LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt"
DBAnalyzePendingKey = "DBAnalyzePending"
DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount"
// ArtConfFingerprintPropertyKey is the model.PropertyRepository key Backfill compares against
// to detect artwork-affecting config changes across restarts.
// ArtConfFingerprintPropertyKey is the model.PropertyRepository key the artwork config check
// compares against to detect artwork-affecting config changes across restarts.
ArtConfFingerprintPropertyKey = "ArtConfFingerprint"
UIAuthorizationHeader = "X-ND-Authorization"
@ -34,14 +34,15 @@ const (
JWTPublicSecretKey = "JWTPublicSecret"
JWTIssuer = "ND"
DefaultSessionTimeout = 48 * time.Hour
DefaultSmartRefresh = 5 * time.Second
DefaultShareExpiration = 8760 * time.Hour
CookieExpiry = 365 * 24 * 3600 // One year
DBAnalyzeCheckSchedule = "@every 30m"
DBAnalyzeMaxAge = 24 * time.Hour
ArtworkStaleAbsentRecheckSchedule = "@every 1h"
ArtworkPruneSchedule = "@daily"
ArtworkPostBackfillPruneDelay = 10 * time.Minute
ArtworkEnqueueMissingSchedule = "@every 1h"
ArtworkPruneSchedule = "@daily"
// DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option
// Never ever change this! Or it will break all Navidrome installations that don't set the config option
@ -72,6 +73,7 @@ const (
DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline
DefaultMaxSidebarPlaylists = 100
DefaultAuthWindowLength = 20 * time.Second
RequestThrottleBacklogLimit = 100
RequestThrottleBacklogTimeout = time.Minute
@ -107,6 +109,9 @@ const (
DefaultScannerExtractor = "taglib"
DefaultWatcherWait = 5 * time.Second
Zwsp = string('\u200b')
DefaultActivityPanelUpdateRate = 300 * time.Millisecond
DefaultPluginCompilationTimeout = time.Minute
)
const (
@ -201,7 +206,7 @@ var (
}
)
var HTTPUserAgent = "Navidrome" + "/" + Version
var HTTPUserAgent = "Navidrome/" + Version + " - https://github.com/navidrome"
var (
VariousArtists = "Various Artists"

View File

@ -36,7 +36,7 @@ RestrictNamespaces=yes
RestrictRealtime=yes
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
SystemCallFilter=setrlimit
SystemCallFilter=setrlimit mbind
SystemCallArchitectures=native
UMask=0066

View File

@ -1,9 +1,13 @@
package agents
import (
"cmp"
"context"
"errors"
"maps"
"slices"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
@ -22,11 +26,43 @@ type PluginLoader interface {
LoadMediaAgent(name string) (Interface, bool)
}
// agentCooldown is the default cooldown duration for an agent that returns a RetryLaterError without a specific
// RetryIn duration.
const agentCooldown = time.Minute
// errUnsupported marks an agent that does not implement the requested method: it never ran,
// so it neither answered nor throttled.
var errUnsupported = errors.New("agent does not support this method")
// Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order
// until one returns valid data.
type Agents struct {
ds model.DataStore
pluginLoader PluginLoader
cooldowns cooldowns
}
// cooldowns remembers, across dispatches, which agents asked to be left alone and until when.
type cooldowns struct {
mu sync.RWMutex
until map[string]time.Time
}
func (c *cooldowns) active(name string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return time.Now().Before(c.until[name])
}
// park keeps whichever deadline is later, so a call still in flight when a longer cooldown
// starts cannot cut it short when it finally answers.
func (c *cooldowns) park(name string, d time.Duration) {
until := time.Now().Add(d)
c.mu.Lock()
defer c.mu.Unlock()
if until.After(c.until[name]) {
c.until[name] = until
}
}
// GetAgents returns the singleton instance of Agents
@ -41,6 +77,7 @@ func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
return &Agents{
ds: ds,
pluginLoader: pluginLoader,
cooldowns: cooldowns{until: map[string]time.Time{}},
}
}
@ -90,12 +127,19 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
} else if isPlugin {
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
} else {
log.Debug("Unknown agent ignored", "name", name)
log.Debug("Unknown agent ignored", "name", name, "available", availableAgentNames(availablePlugins))
}
}
return validAgents
}
// availableAgentNames returns every name accepted by the Agents config option.
func availableAgentNames(plugins []string) []string {
names := append(slices.Collect(maps.Keys(Map)), plugins...)
slices.Sort(names)
return names
}
func (a *Agents) getAgent(ea enabledAgent) Interface {
if ea.isPlugin {
// Try to load WASM plugin agent (if plugin loader is available)
@ -171,7 +215,7 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) {
retriever, ok := ag.(ArtistMBIDRetriever)
if !ok {
return "", ErrNotFound
return "", errUnsupported
}
return retriever.GetArtistMBID(ctx, id, name)
})
@ -188,7 +232,7 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) {
retriever, ok := ag.(ArtistURLRetriever)
if !ok {
return "", ErrNotFound
return "", errUnsupported
}
return retriever.GetArtistURL(ctx, id, name, mbid)
})
@ -205,7 +249,7 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) {
retriever, ok := ag.(ArtistBiographyRetriever)
if !ok {
return "", ErrNotFound
return "", errUnsupported
}
return retriever.GetArtistBiography(ctx, id, name, mbid)
})
@ -224,7 +268,11 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier)
start := time.Now()
attempts := newAttempts(&a.cooldowns)
for _, enabledAgent := range a.getEnabledAgentNames() {
if attempts.skip(enabledAgent.name) {
continue
}
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
@ -237,6 +285,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
continue
}
similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit)
attempts.record(enabledAgent.name, err)
if len(similar) > 0 && err == nil {
if log.IsGreaterOrEqualTo(log.LevelTrace) {
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
@ -246,7 +295,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
return similar, err
}
}
return nil, ErrNotFound
return nil, attempts.noResultErr()
}
func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) {
@ -260,7 +309,7 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) {
retriever, ok := ag.(ArtistImageRetriever)
if !ok {
return nil, ErrNotFound
return nil, errUnsupported
}
return retriever.GetArtistImages(ctx, id, name, mbid)
})
@ -281,7 +330,7 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
retriever, ok := ag.(ArtistTopSongsRetriever)
if !ok {
return nil, ErrNotFound
return nil, errUnsupported
}
return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
})
@ -295,7 +344,7 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) {
retriever, ok := ag.(AlbumInfoRetriever)
if !ok {
return nil, ErrNotFound
return nil, errUnsupported
}
return retriever.GetAlbumInfo(ctx, name, artist, mbid)
})
@ -309,7 +358,7 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string)
return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) {
retriever, ok := ag.(AlbumImageRetriever)
if !ok {
return nil, ErrNotFound
return nil, errUnsupported
}
return retriever.GetAlbumImages(ctx, name, artist, mbid)
})
@ -320,7 +369,7 @@ func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, m
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) {
retriever, ok := ag.(SimilarSongsByTrackRetriever)
if !ok {
return nil, ErrNotFound
return nil, errUnsupported
}
return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count)
})
@ -331,7 +380,7 @@ func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, m
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) {
retriever, ok := ag.(SimilarSongsByAlbumRetriever)
if !ok {
return nil, ErrNotFound
return nil, errUnsupported
}
return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count)
})
@ -349,16 +398,61 @@ func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid str
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) {
retriever, ok := ag.(SimilarSongsByArtistRetriever)
if !ok {
return nil, ErrNotFound
return nil, errUnsupported
}
return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count)
})
}
func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) {
// agentAttempts tallies what the enabled agents did in one dispatch.
type agentAttempts struct {
cooldowns *cooldowns
throttled bool
answered bool
}
func newAttempts(c *cooldowns) agentAttempts {
return agentAttempts{cooldowns: c}
}
// skip reports whether name is still cooling down, counting it as throttled for this dispatch.
func (t *agentAttempts) skip(name string) bool {
if !t.cooldowns.active(name) {
return false
}
t.throttled = true
return true
}
// record files one agent's outcome, parking it when it asked to be retried later.
func (t *agentAttempts) record(name string, err error) {
switch retry, isRetryLater := errors.AsType[*RetryLaterError](err); {
case errors.Is(err, errUnsupported):
case isRetryLater:
t.cooldowns.park(name, cmp.Or(retry.RetryIn, agentCooldown))
t.throttled = true
default:
t.answered = true
}
}
// noResultErr tells a retryable empty dispatch (nobody answered) from a definitive miss.
func (t *agentAttempts) noResultErr() error {
if t.throttled && !t.answered {
return ErrRetryLater
}
return ErrNotFound
}
// callAgent tries each enabled agent in order until found reports a usable result.
func callAgent[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error), found func(T) bool) (T, error) {
var zero T
start := time.Now()
attempts := newAttempts(&agents.cooldowns)
for _, enabledAgent := range agents.getEnabledAgentNames() {
if attempts.skip(enabledAgent.name) {
continue
}
ag := agents.getAgent(enabledAgent)
if ag == nil {
continue
@ -367,41 +461,29 @@ func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodNa
break
}
result, err := fn(ag)
attempts.record(enabledAgent.name, err)
if err != nil {
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
continue
}
if result != zero {
if found(result) {
log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start))
return result, nil
}
}
return zero, ErrNotFound
return zero, attempts.noResultErr()
}
func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) {
return callAgent(ctx, agents, methodName, fn, func(result T) bool {
var zero T
return result != zero
})
}
func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) {
start := time.Now()
for _, enabledAgent := range agents.getEnabledAgentNames() {
ag := agents.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
results, err := fn(ag)
if err != nil {
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
continue
}
if len(results) > 0 {
log.Debug(ctx, "Got results", "method", methodName, "agent", ag.AgentName(), "count", len(results), "elapsed", time.Since(start))
return results, nil
}
}
return nil, ErrNotFound
return callAgent(ctx, agents, methodName, fn, func(results []T) bool { return len(results) > 0 })
}
var _ Interface = (*Agents)(nil)

View File

@ -3,6 +3,8 @@ package agents
import (
"context"
"errors"
"slices"
"time"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
@ -14,6 +16,29 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("cooldowns", func() {
// Calls to one agent overlap, so a short cooldown can land after a long one started.
It("keeps the longer deadline when a shorter park lands after it", func() {
c := cooldowns{until: map[string]time.Time{}}
c.park("fake", time.Hour)
c.park("fake", time.Millisecond)
time.Sleep(10 * time.Millisecond)
Expect(c.active("fake")).To(BeTrue())
})
It("extends the deadline when the later park is longer", func() {
c := cooldowns{until: map[string]time.Time{}}
c.park("fake", time.Millisecond)
c.park("fake", time.Hour)
time.Sleep(10 * time.Millisecond)
Expect(c.active("fake")).To(BeTrue())
})
})
var _ = Describe("Agents", func() {
var ctx context.Context
var cancel context.CancelFunc
@ -67,6 +92,22 @@ var _ = Describe("Agents", func() {
Expect(ags).ToNot(ContainElement("disabled"))
})
Describe("availableAgentNames", func() {
It("combines built-in agents with the given plugins", func() {
names := availableAgentNames([]string{"apple-music"})
Expect(names).To(ContainElements("apple-music", LocalAgentName, "fake", "empty"))
})
It("returns the names sorted", func() {
names := availableAgentNames([]string{"zz-plugin", "aa-plugin"})
Expect(slices.IsSorted(names)).To(BeTrue())
})
It("works when there are no plugins", func() {
Expect(availableAgentNames(nil)).To(ContainElement(LocalAgentName))
})
})
Describe("GetArtistMBID", func() {
It("returns on first match", func() {
Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid"))
@ -160,6 +201,102 @@ var _ = Describe("Agents", func() {
})
})
Describe("cooldown", func() {
It("skips an agent that returned RetryLaterError until the deadline", func() {
mock.Err = &RetryLaterError{RetryIn: time.Hour}
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
// Immediately after: agent is skipped, not called
mock.Err = nil
calls := mock.Calls
_, err = ag.GetArtistBiography(ctx, "id", "name", "mbid")
Expect(mock.Calls).To(Equal(calls))
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
})
// Providers that throttle without saying for how long (Last.fm sends no delay at all)
// must still be parked, or the aggregate keeps calling them on every request.
It("parks an agent that asked to be retried without a delay", func() {
mock.Err = ErrRetryLater
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
mock.Err = nil
calls := mock.Calls
_, err = ag.GetArtistBiography(ctx, "id", "name", "mbid")
Expect(mock.Calls).To(Equal(calls), "the default cooldown must outlast the request")
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
})
It("calls the agent again once the cooldown expires", func() {
mock.Err = &RetryLaterError{RetryIn: 10 * time.Millisecond}
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
mock.Err = nil
Eventually(func() (string, error) {
return ag.GetArtistBiography(ctx, "id", "name", "mbid")
}, 5*time.Second, 10*time.Millisecond).Should(Equal("bio"))
})
It("returns ErrNotFound, not ErrRetryLater, when agents failed for other reasons", func() {
mock.Err = errors.New("boom")
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
})
// ErrRetryLater tells the caller "nobody answered, do not cache this". A definitive
// answer from any other agent is an answer, throttled peer or not.
It("returns ErrNotFound when another agent answered with a definitive miss", func() {
other := &mockAgent{Err: ErrNotFound}
Register("fake2", func(model.DataStore) Interface { return other })
conf.Server.Agents = "fake,fake2"
ag = createAgents(ds, nil)
mock.Err = &RetryLaterError{RetryIn: time.Hour}
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
// The cooldown was still recorded for the throttled agent
calls := mock.Calls
_, _ = ag.GetArtistBiography(ctx, "id", "name", "mbid")
Expect(mock.Calls).To(Equal(calls))
})
It("returns ErrNotFound when another agent answered with an empty slice", func() {
empty := &testImageAgent{Name: "emptyImages"}
Register("emptyImages", func(model.DataStore) Interface { return empty })
conf.Server.Agents = "fake,emptyImages"
ag = createAgents(ds, nil)
mock.Err = &RetryLaterError{RetryIn: time.Hour}
_, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
})
It("returns ErrRetryLater from GetSimilarArtists when only cooling agents remain", func() {
mock.Err = &RetryLaterError{RetryIn: time.Hour}
_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2)
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
})
It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() {
other := &mockAgent{Err: ErrNotFound}
Register("fake2", func(model.DataStore) Interface { return other })
conf.Server.Agents = "fake,fake2"
ag = createAgents(ds, nil)
mock.Err = &RetryLaterError{RetryIn: time.Hour}
_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2)
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
})
})
Describe("GetArtistImages", func() {
It("returns on first match", func() {
Expect(ag.GetArtistImages(ctx, "123", "test", "mb123")).To(Equal([]ExternalImage{{
@ -423,8 +560,9 @@ var _ = Describe("Agents", func() {
})
type mockAgent struct {
Args []any
Err error
Args []any
Err error
Calls int
}
func (a *mockAgent) AgentName() string {
@ -449,6 +587,7 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri
func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) {
a.Args = []any{id, name, mbid}
a.Calls++
if a.Err != nil {
return "", a.Err
}

View File

@ -3,6 +3,9 @@ package agents
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/gohugoio/hashstructure"
"github.com/navidrome/navidrome/model"
@ -52,11 +55,49 @@ func (s Song) Equals(other Song) bool {
return h1 == h2
}
var (
// ErrNotFound means the provider answered and had nothing. Return the underlying error
// for a fault instead, or callers that back off on faults will treat it as definitive.
ErrNotFound = errors.New("not found")
)
// ErrNotFound means the provider answered and had nothing. Return the underlying error
// for a fault instead, or callers that back off on faults will treat it as definitive.
var ErrNotFound = errors.New("not found")
// ErrRetryLater is the zero-delay RetryLaterError: the provider is temporarily unavailable
// or throttling us, but did not say for how long. Both errors.Is(err, ErrRetryLater) and
// errors.AsType[*RetryLaterError] match it and every delay-carrying variant.
// Treat it as immutable; build a new RetryLaterError to name a delay.
var ErrRetryLater = &RetryLaterError{}
// RetryLaterError asks callers to back off, optionally for the delay the provider requested.
type RetryLaterError struct {
RetryIn time.Duration
}
func (e *RetryLaterError) Error() string {
if e.RetryIn > 0 {
return fmt.Sprintf("retry later (in %s)", e.RetryIn)
}
return "retry later"
}
func (e *RetryLaterError) Is(target error) bool {
_, ok := target.(*RetryLaterError)
return ok
}
// MaxRetryIn caps a delay parsed from a provider, so a bogus value cannot park it indefinitely.
const MaxRetryIn = time.Hour
const maxRetryInSeconds = int(MaxRetryIn / time.Second)
// ParseRetryIn reads a provider's delay given in seconds, from a header or a plugin token.
// Anything unparseable or non-positive means unspecified.
func ParseRetryIn(seconds string) time.Duration {
// Clamp in seconds: scaling first would wrap a huge value past int64 nanoseconds,
// turning "wait an age" into a fraction of a second. Parse at a fixed width so the
// cap holds on the 32-bit targets we ship, where a plain Atoi would overflow first.
secs, err := strconv.ParseInt(seconds, 10, 64)
if err != nil || secs <= 0 {
return 0
}
return time.Duration(min(secs, int64(maxRetryInSeconds))) * time.Second
}
// AlbumInfoRetriever provides album info (no images)
type AlbumInfoRetriever interface {

View File

@ -1,27 +1,42 @@
package agents
package agents_test
import (
"errors"
"fmt"
"time"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Song.Equals", func() {
base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}}
It("true for identical songs incl Artists", func() {
Expect(base.Equals(base)).To(BeTrue())
var _ = Describe("RetryLaterError", func() {
It("matches the ErrRetryLater sentinel via errors.Is", func() {
err := &agents.RetryLaterError{RetryIn: 30 * time.Second}
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
})
It("false when Artists differ", func() {
other := base
other.Artists = []Artist{{ID: "y", Name: "B"}}
Expect(base.Equals(other)).To(BeFalse())
It("matches through errors.Join and wrapping", func() {
err := fmt.Errorf("calling LB: %w", errors.Join(errors.New("http 429"), &agents.RetryLaterError{}))
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
})
It("false when a scalar differs", func() {
other := base
other.Name = "T"
Expect(base.Equals(other)).To(BeFalse())
It("exposes the delay through the wrapped error", func() {
err := errors.Join(errors.New("http 429"), &agents.RetryLaterError{RetryIn: 42 * time.Second})
retry, ok := errors.AsType[*agents.RetryLaterError](err)
Expect(ok).To(BeTrue())
Expect(retry.RetryIn).To(Equal(42 * time.Second))
})
It("true when both have empty Artists and equal scalars", func() {
a := Song{ID: "1", Name: "S"}
Expect(a.Equals(a)).To(BeTrue())
It("matches the sentinel too, reporting no delay", func() {
retry, ok := errors.AsType[*agents.RetryLaterError](agents.ErrRetryLater)
Expect(ok).To(BeTrue())
Expect(retry.RetryIn).To(BeZero())
})
It("is the same sentinel as scrobbler.ErrRetryLater", func() {
Expect(errors.Is(scrobbler.ErrRetryLater, agents.ErrRetryLater)).To(BeTrue())
Expect(errors.Is(&agents.RetryLaterError{}, scrobbler.ErrRetryLater)).To(BeTrue())
})
})

27
core/agents/song_test.go Normal file
View File

@ -0,0 +1,27 @@
package agents
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Song.Equals", func() {
base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}}
It("true for identical songs incl Artists", func() {
Expect(base.Equals(base)).To(BeTrue())
})
It("false when Artists differ", func() {
other := base
other.Artists = []Artist{{ID: "y", Name: "B"}}
Expect(base.Equals(other)).To(BeFalse())
})
It("false when a scalar differs", func() {
other := base
other.Name = "T"
Expect(base.Equals(other)).To(BeFalse())
})
It("true when both have empty Artists and equal scalars", func() {
a := Song{ID: "1", Name: "S"}
Expect(a.Equals(a)).To(BeTrue())
})
})

View File

@ -2,6 +2,7 @@ package artwork
import (
"context"
"errors"
"io"
"net/url"
@ -41,22 +42,36 @@ func bestImageURL(imgs []agents.ExternalImage) *url.URL {
return best
}
// fetchArtistImage tries each enabled artist-image agent in order. extErr is true only when no
// longerRetry keeps whichever external failure asks for the longer wait, so one provider's
// short delay cannot shorten another's.
func longerRetry(a, b error) error {
if a == nil {
return b
}
var ra, rb *agents.RetryLaterError
if errors.As(b, &rb) && (!errors.As(a, &ra) || rb.RetryIn > ra.RetryIn) {
return b
}
return a
}
// fetchArtistImage tries each enabled artist-image agent in order. The error is non-nil only when no
// agent succeeded and at least one failed transiently.
func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (r io.ReadCloser, agentName string, extErr bool) {
func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (io.ReadCloser, string, error) {
// Synthetic artists would otherwise get an unrelated agent result assigned to them.
switch ar.ID {
case consts.UnknownArtistID, consts.VariousArtistsID:
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "synthetic artist"})
return nil, "", false
return nil, "", nil
}
name := externalName(ar.Name)
imageAgents := ag.ArtistImageAgents()
if len(imageAgents) == 0 {
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped,
Detail: "no enabled agent provides artist images"})
return nil, "", false
return nil, "", nil
}
var extErr error
for _, a := range imageAgents {
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID)
@ -71,10 +86,10 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
})
recordAgent(ctx, a.Name, reader, path, err)
if reader != nil {
return reader, a.Name, false
return reader, a.Name, nil
}
if isTransientExternal(err) {
extErr = true
extErr = longerRetry(extErr, err)
log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err)
}
}
@ -82,14 +97,15 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
}
// fetchAlbumImage is the album counterpart of fetchArtistImage.
func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) {
func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (io.ReadCloser, string, error) {
name, artist := externalName(al.Name), externalName(al.AlbumArtist)
imageAgents := ag.AlbumImageAgents()
if len(imageAgents) == 0 {
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped,
Detail: "no enabled agent provides album images"})
return nil, "", false
return nil, "", nil
}
var extErr error
for _, a := range imageAgents {
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID)
@ -104,10 +120,10 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m
})
recordAgent(ctx, a.Name, reader, path, err)
if reader != nil {
return reader, a.Name, false
return reader, a.Name, nil
}
if isTransientExternal(err) {
extErr = true
extErr = longerRetry(extErr, err)
log.Debug(ctx, "Artwork: External album-image lookup failed", "agent", a.Name, "album", al.Name, err)
}
}

View File

@ -2,11 +2,13 @@ package artwork
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@ -153,11 +155,11 @@ var _ = Describe("agent images", func() {
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
ag := imageAgents(a)
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"})
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"})
Expect(r).ToNot(BeNil())
defer r.Close()
Expect(name).To(Equal("agentA"))
Expect(extErr).To(BeFalse())
Expect(err).ToNot(HaveOccurred())
})
It("skips the external lookup for synthetic artists", func() {
@ -165,10 +167,10 @@ var _ = Describe("agent images", func() {
ag := imageAgents(a)
for _, id := range []string{consts.UnknownArtistID, consts.VariousArtistsID} {
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"})
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"})
Expect(r).To(BeNil())
Expect(name).To(BeEmpty())
Expect(extErr).To(BeFalse())
Expect(err).ToNot(HaveOccurred())
}
Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents")
})
@ -177,9 +179,9 @@ var _ = Describe("agent images", func() {
ag := imageAgents()
t := &ChainTrace{}
r, _, extErr := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"})
r, _, err := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"})
Expect(r).To(BeNil())
Expect(extErr).To(BeFalse())
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped,
Detail: "no enabled agent provides artist images"}}),
"a configured external token must never be silently absent from the chain")
@ -211,11 +213,11 @@ var _ = Describe("agent images", func() {
b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}}
ag := imageAgents(a, b)
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
Expect(r).ToNot(BeNil())
defer r.Close()
Expect(name).To(Equal("agentB"))
Expect(extErr).To(BeFalse(), "a later hit clears an earlier agent's error")
Expect(err).ToNot(HaveOccurred(), "a later hit clears an earlier agent's error")
Expect(a.artistCalls).To(Equal(1))
Expect(b.artistCalls).To(Equal(1))
})
@ -225,20 +227,43 @@ var _ = Describe("agent images", func() {
b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound}
ag := imageAgents(a, b)
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
Expect(r).To(BeNil())
Expect(name).To(BeEmpty())
Expect(extErr).To(BeFalse(), "not-found is definitive, never a transient failure")
Expect(err).ToNot(HaveOccurred(), "not-found is definitive, never a transient failure")
})
It("reports extErr when one agent fails transiently and the rest find nothing", func() {
It("reports an error when one agent fails transiently and the rest find nothing", func() {
a := &fakeImageAgent{name: "agentA", err: agents.ErrNotFound}
b := &fakeImageAgent{name: "agentB", err: context.DeadlineExceeded}
ag := imageAgents(a, b)
r, _, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
Expect(r).To(BeNil())
Expect(extErr).To(BeTrue())
Expect(err).To(HaveOccurred())
})
// The worker reschedules on this delay, so it is only honored if the agent loop
// returns it. Two throttled agents: the longest wait is the one that must survive.
It("returns the longest retry delay the providers asked for", func() {
a := &fakeImageAgent{name: "agentA", err: &agents.RetryLaterError{RetryIn: 10 * time.Second}}
b := &fakeImageAgent{name: "agentB", err: &agents.RetryLaterError{RetryIn: 5 * time.Second}}
ag := imageAgents(a, b)
r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
Expect(r).To(BeNil())
retry, ok := errors.AsType[*agents.RetryLaterError](err)
Expect(ok).To(BeTrue())
Expect(retry.RetryIn).To(Equal(10 * time.Second))
})
It("returns no delay when the provider did not ask for one", func() {
ag := imageAgents(&fakeImageAgent{name: "agentA", err: errors.New("boom")})
_, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
Expect(err).To(HaveOccurred())
_, ok := errors.AsType[*agents.RetryLaterError](err)
Expect(ok).To(BeFalse(), "a plain failure must not look like a throttle")
})
})
@ -247,11 +272,11 @@ var _ = Describe("agent images", func() {
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
ag := imageAgents(a)
r, name, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"})
r, name, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"})
Expect(r).ToNot(BeNil())
defer r.Close()
Expect(name).To(Equal("agentA"))
Expect(extErr).To(BeFalse())
Expect(err).ToNot(HaveOccurred())
Expect(a.albumCalls).To(Equal(1))
})
@ -259,21 +284,21 @@ var _ = Describe("agent images", func() {
ag := imageAgents()
t := &ChainTrace{}
r, _, extErr := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"})
r, _, err := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"})
Expect(r).To(BeNil())
Expect(extErr).To(BeFalse())
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped,
Detail: "no enabled agent provides album images"}}),
"a configured external token must never be silently absent from the chain")
})
It("reports extErr when the only agent fails transiently", func() {
It("reports an error when the only agent fails transiently", func() {
a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded}
ag := imageAgents(a)
r, _, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"})
r, _, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"})
Expect(r).To(BeNil())
Expect(extErr).To(BeTrue())
Expect(err).To(HaveOccurred())
})
})

View File

@ -118,10 +118,6 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa
}
}
// requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't
// hammer external services; below StaleAbsentAge to catch younger absences.
const requestRecheckAge = time.Hour
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary)
switch {
@ -130,10 +126,7 @@ func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size i
case err != nil:
return nil, err
case ia.Hash == "":
// Inserts an immediately-eligible recheck for a settled absent row.
if time.Since(ia.AttemptedAt) > requestRecheckAge {
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
}
// Settled absent: only an explicit reprocess or refresh retries it.
return nil, ErrUnavailable
default:
return s.serveHash(ctx, artID, ia, size, square)
@ -175,6 +168,11 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode
if !entityExists(ctx, s.ds, artID) {
return nil, ErrUnavailable
}
// Checked here, not in openOriginal: a resize-cache hit never opens the source.
if isFileBacked(ia.Source) && !model.IsImageFile(ia.SourcePath) {
log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath)
return s.dangling(ctx, artID)
}
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
if err != nil {
if errors.Is(err, model.ErrNotFound) {

View File

@ -2,18 +2,21 @@ package artwork
import (
"io/fs"
"net/netip"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/httpclient"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/goleak"
@ -37,6 +40,14 @@ func TestArtwork(t *testing.T) {
RunSpecs(t, "Artwork Suite")
}
// productionImageClient keeps the guarded client for the specs that assert it refuses loopback.
var productionImageClient = remoteImageClient
// httptest servers listen on loopback, which the production client refuses.
var _ = BeforeSuite(func() {
remoteImageClient = httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128"))
})
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
type osDirFS struct{ fs.FS }

View File

@ -159,6 +159,53 @@ var _ = Describe("Artwork", func() {
Expect(readAll(img)).To(Equal(coverBytes))
})
It("treats a file-backed row pointing at a non-image file as dangling", func() {
dir := GinkgoT().TempDir()
secretPath := filepath.Join(dir, "config.ini")
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "alni")
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd",
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
})).To(Succeed())
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alni"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "alni")].Priority).To(Equal(model.ArtworkPriorityScan))
})
It("refuses a non-image file-backed row even when a resized copy is already cached", func() {
secret := []byte("password=secret")
dir := GinkgoT().TempDir()
secretPath := filepath.Join(dir, "config.ini")
Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "alnic")
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee",
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
})).To(Succeed())
// Older versions cached the raw bytes when the resize failed.
seed := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(secret)), nil }
stream, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, open: seed, ffmpeg: ffm})
Expect(err).ToNot(HaveOccurred())
Expect(io.ReadAll(stream)).To(Equal(secret))
Expect(stream.Close()).To(Succeed())
Eventually(func(g Gomega) {
s, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, ffmpeg: ffm,
open: func() (io.ReadCloser, error) { return nil, os.ErrNotExist }})
g.Expect(err).ToNot(HaveOccurred())
g.Expect(s.Cached).To(BeTrue())
_ = s.Close()
}).Should(Succeed())
_, err = svc.Get(ctx, model.MustParseArtworkID("al-alnic"), 100, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "alnic")].Priority).To(Equal(model.ArtworkPriorityScan))
})
It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() {
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
@ -204,28 +251,15 @@ var _ = Describe("Artwork", func() {
Expect(err).To(MatchError(ErrUnavailable))
})
It("does not re-enqueue a recently-attempted absent state", func() {
It("never re-enqueues an absent state on view, however old", func() {
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now(),
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
})).To(Succeed())
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data).To(BeEmpty())
})
It("promotes a stale absent state at Bump priority on view", func() {
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "al4b", AttemptedAt: time.Now().Add(-2 * requestRecheckAge),
})).To(Succeed())
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al4b"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al4b")].Priority).To(Equal(model.ArtworkPriorityBump))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4b", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(BeEmpty())
})
})
Describe("provisional read-through", func() {

View File

@ -90,6 +90,28 @@ var _ = Describe("Artist artwork resolution", func() {
})
})
When("the artist's only album folder has no images of its own", func() {
// Artist/
// ├── backdrop1.jpg
// ├── folder.jpg ← matched by folder.*
// ├── logo.png
// └── Album/
// ├── 01 - Track.mp3
// └── 02 - Track.mp3
It("resolves the artist folder, not the library root", func() {
conf.Server.ArtistArtPriority = "folder.*, artist.*, album/artist.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}),
"Artist/Album/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}),
"Artist/backdrop1.jpg": smallPNG("backdrop"),
"Artist/folder.jpg": smallPNG("artist-folder"),
"Artist/logo.png": smallPNG("logo"),
})
scan()
expectArtistFolder(soleArtist(), "Artist/folder.jpg")
})
})
When("the artist's only album has its tracks in disc subfolders", func() {
// Artist/
// ├── artist.jpg ← wins (artist.* before album/artist.*)

View File

@ -56,7 +56,8 @@ func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bo
runCtx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- worker.Run(runCtx) }()
Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue())
// Long enough for one retry (3-7s backoff, 5s poll tick).
Eventually(until, 15*time.Second, 10*time.Millisecond).Should(BeTrue())
cancel()
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
}

View File

@ -1,6 +1,7 @@
package artwork
import (
"cmp"
"context"
"errors"
"io"
@ -95,6 +96,9 @@ type breaker struct {
// generation identifies the current open episode, so an answer from a call admitted before
// the breaker opened cannot be mistaken for evidence that it has recovered.
generation int
// probeAfter overrides the probe delay for the current episode when a provider named its own
// back-off; zero falls back to breakerProbeAfter.
probeAfter time.Duration
}
func newBreaker() *breaker { return &breaker{} }
@ -107,7 +111,7 @@ func (b *breaker) allow() (bool, int) {
if b.failures < breakerThreshold {
return true, 0
}
if time.Since(b.openedAt) >= breakerProbeAfter {
if time.Since(b.openedAt) >= cmp.Or(b.probeAfter, breakerProbeAfter) {
b.openedAt = time.Now() // start a fresh probe window so only one caller passes
return true, b.generation
}
@ -121,11 +125,24 @@ func (b *breaker) record(name string, gen int, err error) {
}
b.mu.Lock()
defer b.mu.Unlock()
// An explicit back-off is a definitive "stop for this long", so it opens the breaker at once
// with the provider's own delay instead of waiting for the failure threshold.
if retry, ok := errors.AsType[*agents.RetryLaterError](err); ok && retry.RetryIn > 0 {
b.recoveries = 0
b.failures = breakerThreshold
b.openedAt = time.Now()
b.probeAfter = retry.RetryIn
b.generation++
log.Warn("Artwork: Circuit breaker opened for agent, provider asked to back off", "agent", name,
"probeAfter", retry.RetryIn)
return
}
if isTransientExternal(err) {
b.recoveries = 0
b.failures++
if b.failures == breakerThreshold {
b.openedAt = time.Now()
b.probeAfter = 0
b.generation++
log.Warn("Artwork: Circuit breaker opened for agent", "agent", name,
"consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err)

View File

@ -2,7 +2,9 @@ package artwork
import (
"errors"
"time"
"github.com/navidrome/navidrome/core/agents"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -40,4 +42,15 @@ var _ = Describe("breaker", func() {
Expect(allowed(b)).To(BeFalse(),
"answers from calls admitted before the breaker opened must not close it")
})
It("opens at once when a provider asks to retry later, honoring its delay", func() {
b := newBreaker()
Expect(allowed(b)).To(BeTrue(), "starts closed")
// A single explicit back-off opens the breaker without reaching the failure threshold.
b.record("agentA", 0, &agents.RetryLaterError{RetryIn: 5 * time.Second})
Expect(allowed(b)).To(BeFalse(), "an explicit back-off opens the breaker immediately")
Expect(b.probeAfter).To(Equal(5*time.Second), "the provider's delay drives the probe interval")
})
})

View File

@ -6,26 +6,18 @@ import (
"slices"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/zeebo/xxh3"
)
// StaleAbsentAge is how long an absent state is trusted before a recheck retries it.
const StaleAbsentAge = 30 * 24 * time.Hour
// StaleAbsentRecheckBatch caps how many absent states each hourly tick re-queues per kind,
// oldest first, so external agents see a flat drip instead of a daily burst.
const StaleAbsentRecheckBatch = 100
// RecheckKinds omits media files: they resolve embedded-only, at scan or on view.
var RecheckKinds = []model.Kind{
// ReprocessKinds omits media files: they resolve embedded-only, at scan or on view. Artists lead
// so bulk enqueues give the most external-dependent kind a queue headstart.
var ReprocessKinds = []model.Kind{
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork,
}
@ -34,14 +26,16 @@ var RecheckKinds = []model.Kind{
func KeepsState(kind model.Kind) bool { return kind != model.KindDiscArtwork }
// RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds
// KeepsState admits. Media files are absent from RecheckKinds but belong here: the worker
// resolves them, it just never revisits them on its own.
var RefreshableKinds = append(slices.Clone(RecheckKinds), model.KindMediaFileArtwork)
// KeepsState admits. Media files are absent from ReprocessKinds but belong here: the worker
// resolves them, it just never enumerates them in bulk.
var RefreshableKinds = append(slices.Clone(ReprocessKinds), model.KindMediaFileArtwork)
// hasRecheckPath reports whether a periodic job will revisit this kind, making an absent settle recoverable.
func hasRecheckPath(prefix string) bool {
// settlesAbsentOnGiveUp reports whether an exhausted retry budget records an absent state. Media
// files are excluded because retrying one costs nothing: they resolve embedded-only, from a local
// read, and only a view ever enqueues them.
func settlesAbsentOnGiveUp(prefix string) bool {
kind, ok := model.ParseKind(prefix)
return ok && slices.Contains(RecheckKinds, kind)
return ok && KeepsState(kind) && kind != model.KindMediaFileArtwork
}
// artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change.
@ -72,93 +66,36 @@ func ConfigFingerprint() string {
return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw)))
}
// backfillSummary is what a backfill enqueued. MaxExternalLookups is an upper estimate for one
// attempt per item, not a bound: a local hit ends the walk, and a retry asks the agents again.
type backfillSummary struct {
Ran bool
PerKind map[string]int64
Items int64
MaxExternalLookups int64
}
// backfill enqueues artwork resolution for every entity when the config fingerprint changed.
func backfill(ctx context.Context, ds model.DataStore, agentCount func() ImageAgentCount) (backfillSummary, error) {
start := time.Now()
ctx = auth.WithAdminUser(ctx, ds)
// ReconcileConfigFingerprint warns when the artwork config changed since the library was last
// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess.
func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
current := ConfigFingerprint()
props := ds.Property(ctx)
stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
stored, err := ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
if err != nil {
return backfillSummary{}, err
return err
}
if stored == current {
return backfillSummary{}, nil
}
// Artists first: few entities, most external-dependent, so they get a queue headstart.
kinds := []struct {
kind model.Kind
fetch func() ([]string, error)
}{
{model.KindArtistArtwork, func() ([]string, error) { return ds.Artist(ctx).GetAllIDs() }},
{model.KindAlbumArtwork, func() ([]string, error) { return ds.Album(ctx).GetAllIDs() }},
{model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }},
{model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }},
}
// Counted here, not by the caller: building the agent list constructs every enabled agent, and
// an unchanged fingerprint returns above without ever needing the number.
agents := agentCount()
summary := backfillSummary{Ran: true, PerKind: map[string]int64{}}
for _, k := range kinds {
ids, err := k.fetch()
if err != nil {
return backfillSummary{}, err
}
if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil {
return backfillSummary{}, err
}
n := int64(len(ids))
summary.PerKind[k.kind.Prefix()] = n
summary.Items += n
summary.MaxExternalLookups += n * ExternalLookupsPerItem(k.kind, agents)
}
if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil {
return backfillSummary{}, err
}
log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "items", summary.Items,
"byKind", summary.PerKind, "maxExternalLookups", summary.MaxExternalLookups,
"elapsed", time.Since(start))
return summary, nil
}
func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error {
if len(ids) == 0 {
return nil
}
items := slice.Map(ids, func(id string) model.ArtworkQueueItem {
return model.ArtworkQueueItem{
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill,
}
})
return ds.ArtworkQueue(ctx).Enqueue(items...)
}
func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
cutoff := time.Now().Add(-StaleAbsentAge)
queue := ds.ArtworkQueue(ctx)
for _, kind := range RecheckKinds {
if _, err := queue.EnqueueStaleAbsent(kind, cutoff, StaleAbsentRecheckBatch); err != nil {
return err
}
switch stored {
case current:
case "":
// An unset fingerprint counts as current; the alternative warns every upgrading install once.
return MarkConfigApplied(ctx, ds)
default:
log.Warn(ctx, "Artwork: Config changed since the last full reprocess. Stored artwork keeps "+
"the old resolution; run 'navidrome artwork reprocess --all' to apply the change",
"stored", stored, "current", current, "inputs", FingerprintInputs())
}
return nil
}
// MarkConfigApplied records the current fingerprint as the one the library is resolved under.
func MarkConfigApplied(ctx context.Context, ds model.DataStore) error {
return ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())
}
// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off).
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
queue := ds.ArtworkQueue(ctx)
for _, kind := range RecheckKinds {
for _, kind := range ReprocessKinds {
if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil {
return err
}
@ -166,6 +103,63 @@ func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
return nil
}
// ItemName resolves a kind+id to the entity's display name, and errors when the item
// does not exist. Callers use it to reject ids that would otherwise orphan a queue row.
func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) {
switch kind {
case model.KindArtistArtwork:
ar, err := ds.Artist(ctx).Get(id)
if err != nil {
return "", err
}
return ar.Name, nil
case model.KindAlbumArtwork:
al, err := ds.Album(ctx).Get(id)
if err != nil {
return "", err
}
return al.Name, nil
case model.KindPlaylistArtwork:
pls, err := ds.Playlist(ctx).Get(id)
if err != nil {
return "", err
}
return pls.Name, nil
case model.KindRadioArtwork:
rd, err := ds.Radio(ctx).Get(id)
if err != nil {
return "", err
}
return rd.Name, nil
case model.KindMediaFileArtwork:
mf, err := ds.MediaFile(ctx).Get(id)
if err != nil {
return "", err
}
return mf.Title, nil
case model.KindDiscArtwork:
return discArtworkName(ctx, ds, id)
}
return "", fmt.Errorf("unsupported kind %q", kind.Prefix())
}
func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string, error) {
albumID, discNumber, err := model.ParseDiscArtworkID(id)
if err != nil {
return "", err
}
al, err := ds.Album(ctx).Get(albumID)
if err != nil {
return "", err
}
name := fmt.Sprintf("%s (disc %d)", al.Name, discNumber)
// The subtitle is itself a DiscArtPriority candidate, so name it where the chain can be read against it.
if subtitle := strings.TrimSpace(al.Discs[discNumber]); subtitle != "" {
name += ": " + subtitle
}
return name, nil
}
// Refresh drops an item's resolved artwork state and re-queues it at Bump priority.
func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error {
if err := ds.Artwork(ctx).DeleteForItems(kind, []string{id}); err != nil {

View File

@ -2,59 +2,17 @@ package artwork
import (
"context"
"fmt"
"slices"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"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"
)
// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only
// visible when the ctx carries an admin, so headless work must wrap ctx with one first.
type visibilityPlaylistDS struct {
*tests.MockDataStore
private model.Playlist
tracks model.PlaylistTrackRepository
}
func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository {
repo := tests.CreateMockPlaylistRepo()
repo.TracksRepo = v.tracks
if u, ok := request.UserFrom(ctx); ok && u.IsAdmin {
repo.SetData(model.Playlists{v.private})
}
return repo
}
func adminUserRepo() *tests.MockedUserRepo {
repo := tests.CreateMockUserRepo()
Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
return repo
}
func noAgents() ImageAgentCount { return ImageAgentCount{} }
// orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can
// assert phase ordering (artists-first) that same-priority timestamps can't guarantee.
type orderTrackingQueueRepo struct {
*tests.MockArtworkQueueRepo
callKinds []string
}
func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error {
if len(items) > 0 {
o.callKinds = append(o.callKinds, items[0].ItemKind)
}
return o.MockArtworkQueueRepo.Enqueue(items...)
}
var _ = Describe("RefreshableKinds", func() {
// The two are meant to describe the same fact. Nothing but this test stops them from drifting,
// and a drift would have `artwork explain` report state for a kind that keeps none.
@ -72,7 +30,7 @@ var _ = Describe("Housekeeping", func() {
var (
ctx context.Context
ds *tests.MockDataStore
queueRepo *orderTrackingQueueRepo
queueRepo *tests.MockArtworkQueueRepo
propRepo *tests.MockedPropertyRepo
)
@ -84,52 +42,24 @@ var _ = Describe("Housekeeping", func() {
conf.Server.Agents = "spotify"
conf.Server.EnableExternalServices = true
queueRepo = &orderTrackingQueueRepo{MockArtworkQueueRepo: tests.CreateMockArtworkQueueRepo()}
queueRepo = tests.CreateMockArtworkQueueRepo()
propRepo = &tests.MockedPropertyRepo{}
ds = &tests.MockDataStore{MockedArtworkQueue: queueRepo, MockedProperty: propRepo}
})
seedEntities := func() {
artistRepo := tests.CreateMockArtistRepo()
artistRepo.SetData(model.Artists{{ID: "ar1"}, {ID: "ar2"}})
ds.MockedArtist = artistRepo
albumRepo := tests.CreateMockAlbumRepo()
albumRepo.SetData(model.Albums{{ID: "al1"}})
ds.MockedAlbum = albumRepo
playlistRepo := tests.CreateMockPlaylistRepo()
playlistRepo.SetData(model.Playlists{{ID: "pl1"}})
ds.MockedPlaylist = playlistRepo
radioRepo := tests.CreateMockedRadioRepo()
radioRepo.All = model.Radios{{ID: "ra1"}}
ds.MockedRadio = radioRepo
}
Describe("Fingerprint", func() {
It("changes when a fingerprint-affecting config value changes", func() {
f1 := ConfigFingerprint()
conf.Server.CoverArtPriority = "folder, embedded"
f2 := ConfigFingerprint()
Expect(f1).NotTo(Equal(f2))
})
DescribeTable("changes when a fingerprint-affecting config value changes",
func(change func()) {
before := ConfigFingerprint()
change()
Expect(ConfigFingerprint()).NotTo(Equal(before))
},
Entry("CoverArtPriority", func() { conf.Server.CoverArtPriority = "folder, embedded" }),
Entry("ArtistImageFolder", func() { conf.Server.ArtistImageFolder = "/after" }),
Entry("EnableM3UExternalAlbumArt", func() { conf.Server.EnableM3UExternalAlbumArt = true }),
)
It("changes when ArtistImageFolder changes", func() {
conf.Server.ArtistImageFolder = "/before"
f1 := ConfigFingerprint()
conf.Server.ArtistImageFolder = "/after"
Expect(ConfigFingerprint()).NotTo(Equal(f1))
})
It("changes when EnableM3UExternalAlbumArt is toggled", func() {
conf.Server.EnableM3UExternalAlbumArt = false
f1 := ConfigFingerprint()
conf.Server.EnableM3UExternalAlbumArt = true
Expect(ConfigFingerprint()).NotTo(Equal(f1))
})
// Pinned: a changed formula re-resolves every library on upgrade, flooding external providers.
// Pinned: a changed formula tells every existing install its artwork config went stale.
It("hashes a given config to a stable value", func() {
conf.Server.CoverArtPriority = "cover.*, embedded"
conf.Server.ArtistArtPriority = "artist.*, external"
@ -157,145 +87,23 @@ var _ = Describe("Housekeeping", func() {
f1 := ConfigFingerprint()
consts.Version = original + "-next"
Expect(ConfigFingerprint()).To(Equal(f1),
"the version must not invalidate artwork state: it would re-resolve every entity on every build")
"the version must not invalidate artwork state: every build would report a stale config")
})
})
Describe("Backfill", func() {
It("enqueues nothing and returns false when the stored fingerprint matches", func() {
seedEntities()
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())).To(Succeed())
Describe("ReconcileConfigFingerprint", func() {
It("records the current fingerprint when none was ever stored", func() {
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
counted := false
s, err := backfill(ctx, ds, func() ImageAgentCount {
counted = true
return ImageAgentCount{Artist: 3, Album: 2}
})
Expect(err).ToNot(HaveOccurred())
Expect(s).To(Equal(backfillSummary{}))
Expect(counted).To(BeFalse(), "building the agent list constructs every agent; an unchanged fingerprint must not pay for it")
count, err := queueRepo.Count()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(BeZero())
Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint()))
})
It("runs the backfill when no fingerprint was ever stored", func() {
seedEntities()
s, err := backfill(ctx, ds, noAgents)
Expect(err).ToNot(HaveOccurred())
Expect(s.Ran).To(BeTrue())
count, err := queueRepo.Count()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(5))) // 2 artists + 1 album + 1 playlist + 1 radio
stored, err := propRepo.Get(consts.ArtConfFingerprintPropertyKey)
Expect(err).ToNot(HaveOccurred())
Expect(stored).To(Equal(ConfigFingerprint()))
})
It("enqueues a private playlist by resolving it under an admin context", func() {
ds.MockedUser = adminUserRepo()
vds := &visibilityPlaylistDS{
MockDataStore: ds,
private: model.Playlist{ID: "plPrivate", OwnerID: "admin"},
tracks: &tests.MockPlaylistTrackRepo{},
}
s, err := backfill(ctx, vds, noAgents)
Expect(err).ToNot(HaveOccurred())
Expect(s.Ran).To(BeTrue())
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil())
})
It("enqueues artists before albums/playlists/radios, all at Backfill priority", func() {
seedEntities()
It("leaves a stale fingerprint stored, so the warning survives a restart", func() {
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
s, err := backfill(ctx, ds, noAgents)
Expect(err).ToNot(HaveOccurred())
Expect(s.Ran).To(BeTrue())
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
Expect(queueRepo.callKinds).ToNot(BeEmpty())
firstOther := slices.IndexFunc(queueRepo.callKinds, func(k string) bool { return k != "ar" })
Expect(firstOther).ToNot(Equal(0), "artists must be the first Enqueue call")
if firstOther >= 0 {
Expect(queueRepo.callKinds[firstOther:]).ToNot(ContainElement("ar"),
"no artist Enqueue may follow another kind")
}
for _, it := range queueRepo.Data {
Expect(it.Priority).To(Equal(model.ArtworkPriorityBackfill))
Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra"))
}
})
It("reports what it enqueued, per kind and as an external-lookup ceiling", func() {
conf.Server.ArtistArtPriority = "artist.*, external"
conf.Server.CoverArtPriority = "cover.*, external"
conf.Server.EnableM3UExternalAlbumArt = false
seedEntities()
s, err := backfill(ctx, ds, func() ImageAgentCount { return ImageAgentCount{Artist: 3, Album: 2} })
Expect(err).ToNot(HaveOccurred())
Expect(s.Ran).To(BeTrue())
Expect(s.PerKind).To(Equal(map[string]int64{"ar": 2, "al": 1, "pl": 1, "ra": 1}))
Expect(s.Items).To(Equal(int64(5)))
// 2 artists x 3 agents, 1 album x 2, 1 playlist grid x 2, and radios never fetch.
Expect(s.MaxExternalLookups).To(Equal(int64(6 + 2 + PlaylistGridSamples*2)))
})
})
Describe("EnqueueStaleAbsentAll", func() {
var artRepo *tests.MockArtworkRepo
BeforeEach(func() {
artRepo = tests.CreateMockArtworkRepo()
ds.MockedArtwork = artRepo
queueRepo.ItemArtworkSource = artRepo
})
It("enqueues only absent entries older than the recheck window, across all kinds", func() {
old := time.Now().Add(-StaleAbsentAge - time.Hour)
recent := time.Now().Add(-StaleAbsentAge + time.Hour)
artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
artRepo.ItemData["pl-stale"] = model.ItemArtwork{ItemKind: "pl", ItemID: "pl1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
artRepo.ItemData["ra-stale"] = model.ItemArtwork{ItemKind: "ra", ItemID: "ra1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
artRepo.ItemData["ar-recent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: recent}
artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al2", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: old}
err := enqueueStaleAbsentAll(ctx, ds)
Expect(err).ToNot(HaveOccurred())
Expect(queueRepo.Data).To(HaveLen(4))
for _, it := range queueRepo.Data {
Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck))
}
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).ToNot(BeNil())
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).ToNot(BeNil())
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil())
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil())
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil())
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil())
})
It("caps each tick at the recheck batch, oldest attempts first", func() {
for i := range StaleAbsentRecheckBatch + 1 {
id := fmt.Sprintf("ar%d", i)
artRepo.ItemData[id] = model.ItemArtwork{ItemKind: "ar", ItemID: id, ImageType: model.ImageTypePrimary,
Hash: "", AttemptedAt: time.Now().Add(-StaleAbsentAge - time.Duration(i+1)*time.Minute)}
}
Expect(enqueueStaleAbsentAll(ctx, ds)).To(Succeed())
Expect(queueRepo.Data).To(HaveLen(StaleAbsentRecheckBatch))
// ar0 has the newest attempted_at of the cohort, so it is the one left out.
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar0")).To(BeNil())
Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint"))
})
})
@ -315,8 +123,8 @@ var _ = Describe("Housekeeping", func() {
})
It("enqueues only entities that have no item_artwork row, across all kinds", func() {
artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: time.Now()}
artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()}
artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash"}
artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: ""}
err := enqueueMissingAll(ctx, ds)
Expect(err).ToNot(HaveOccurred())
@ -324,11 +132,64 @@ var _ = Describe("Housekeeping", func() {
for _, it := range queueRepo.Data {
Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck))
}
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).ToNot(BeNil())
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil())
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil())
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).To(BeNil())
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).To(BeNil())
Expect(findQueued(queueRepo, "al", "al2")).ToNot(BeNil())
Expect(findQueued(queueRepo, "pl", "pl1")).ToNot(BeNil())
Expect(findQueued(queueRepo, "ra", "ra1")).ToNot(BeNil())
Expect(findQueued(queueRepo, "al", "al1")).To(BeNil())
Expect(findQueued(queueRepo, "ar", "ar1")).To(BeNil())
})
})
})
var _ = Describe("ItemName", func() {
var ds *tests.MockDataStore
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
albumRepo := tests.CreateMockAlbumRepo()
albumRepo.SetData(model.Albums{
{ID: "al-1", Name: "Kid A"},
{ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}},
})
ds = &tests.MockDataStore{MockedAlbum: albumRepo}
Expect(ds.Artist(ctx).(*tests.MockArtistRepo).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
})
It("returns the album name", func() {
Expect(ItemName(ctx, ds, model.KindAlbumArtwork, "al-1")).To(Equal("Kid A"))
})
It("returns the artist name", func() {
Expect(ItemName(ctx, ds, model.KindArtistArtwork, "ar-1")).To(Equal("Radiohead"))
})
It("errors for an unknown album", func() {
_, err := ItemName(ctx, ds, model.KindAlbumArtwork, "nope")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("errors for an unsupported kind", func() {
// model.Kind is a struct with unexported fields, so the zero value is the only
// unsupported Kind constructible from outside package model.
_, err := ItemName(ctx, ds, model.Kind{}, "al-1")
Expect(err).To(HaveOccurred())
})
Context("disc artwork", func() {
It("names the album, the disc and its subtitle", func() {
Expect(ItemName(ctx, ds, model.KindDiscArtwork, "al-2:2")).
To(Equal("Sandinista! (disc 2): Side Three"))
})
It("omits the subtitle when the disc has none", func() {
Expect(ItemName(ctx, ds, model.KindDiscArtwork, "al-2:1")).
To(Equal("Sandinista! (disc 1)"))
})
It("rejects an id that is not <albumID>:<disc>", func() {
_, err := ItemName(ctx, ds, model.KindDiscArtwork, "al-2")
Expect(err).To(HaveOccurred())
})
})
})

View File

@ -98,6 +98,9 @@ func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error {
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmp.Name(), 0640); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}

View File

@ -48,6 +48,17 @@ var _ = Describe("ImageStore", func() {
Expect(got).To(Equal(data))
})
It("writes group-readable image files", func() {
tests.SkipOnWindows("uses Unix file permission bits")
data := []byte("jpeg-bytes")
h, _ := hashImage(bytes.NewReader(data))
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
info, err := os.Stat(store.path(h, "image/jpeg"))
Expect(err).ToNot(HaveOccurred())
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0640)))
})
It("is idempotent on duplicate writes and preserves the original content", func() {
data := []byte("dup")
h, _ := hashImage(bytes.NewReader(data))

View File

@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/artwork/blurhash"
"github.com/navidrome/navidrome/core/artwork/dominant"
"github.com/navidrome/navidrome/core/artwork/thumbhash"
@ -80,7 +81,7 @@ type processor struct {
// acquire resolves one queue item end to end: find an image, hash/decode/
// blurhash it, place its bytes, and persist the resulting state.
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired) {
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
repo := p.ds.Artwork(ctx)
start := time.Now()
defer func() {
@ -92,10 +93,13 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
if err != nil {
traceStage(ctx, "resolve", err)
log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
return outcomeFailed, nil, 0
}
if retry, ok := errors.AsType[*agents.RetryLaterError](res.extErr); ok {
retryIn = retry.RetryIn
}
if res.reader == nil {
if res.extError || res.localError {
if res.extErr != nil || res.localError {
// A fault is not a definitive "no image": never settle absent, keep serving old state.
// A chainless resolver (playlist/radio) records no step, so leave a fallback or explain is blank.
if t := traceFrom(ctx); len(t.Steps()) == 0 {
@ -106,10 +110,10 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
t.add(TraceStep{Candidate: cmp.Or(res.source, "source"), Outcome: outcome})
}
log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state",
"kind", item.ItemKind, "id", item.ItemID, "extError", res.extError, "localError", res.localError)
return outcomeFailed, nil
"kind", item.ItemKind, "id", item.ItemID, "extErr", res.extErr, "localError", res.localError)
return outcomeFailed, nil, retryIn
}
return writeAbsent(ctx, repo, item), nil
return writeAbsent(ctx, repo, item), nil, 0
}
defer res.reader.Close()
@ -118,7 +122,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
if err != nil {
traceStage(ctx, "read", err)
log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err)
return outcomeFailed, nil
return outcomeFailed, nil, retryIn
}
log.Debug(ctx, "Artwork: Read resolved image", "kind", item.ItemKind, "id", item.ItemID,
"source", res.source, "bytes", len(data), "elapsed", time.Since(readStart))
@ -128,7 +132,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
if err != nil {
traceStage(ctx, "hash", err)
log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
return outcomeFailed, nil, retryIn
}
log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID,
"hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart))
@ -152,14 +156,14 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
if err != nil {
traceStage(ctx, "decode", err)
log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
return outcomeFailed, nil, retryIn
}
log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash,
"width", art.Width, "height", art.Height, "mime", art.Mime, "elapsed", time.Since(decodeStart))
default:
traceStage(ctx, "lookup", err)
log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
return outcomeFailed, nil, retryIn
}
art.SizeBytes = int64(len(data))
@ -167,15 +171,15 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
if err != nil {
traceStage(ctx, "store", err)
log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
return outcomeFailed, nil, retryIn
}
got = &acquired{ia: ia, mime: art.Mime, data: data}
if res.extError {
if res.extErr != nil {
log.Debug(ctx, "Artwork: Serving a lower-priority source after an external failure",
"kind", item.ItemKind, "id", item.ItemID, "source", res.source)
return outcomeFoundStale, got
return outcomeFoundStale, got, retryIn
}
return outcomeFound, got
return outcomeFound, got, retryIn
}
// persist places the bytes and commits the rows referencing them, excluding Prune for that

View File

@ -90,7 +90,7 @@ var _ = Describe("processor.acquire", func() {
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
})
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
Expect(out).To(Equal(outcomeFound))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
@ -127,7 +127,7 @@ var _ = Describe("processor.acquire", func() {
{ID: "alL1", Name: "Album", FolderIDs: []string{"f1"}},
})
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"})
Expect(out).To(Equal(outcomeFound))
Expect(lock.locks).To(BeNumerically(">", 0), "the write window must exclude prune")
Expect(lock.held()).To(BeFalse(), "the window must close before acquire returns")
@ -141,7 +141,7 @@ var _ = Describe("processor.acquire", func() {
{ID: "alL2", Name: "Album", FolderIDs: []string{"f1"}},
})
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"})
Expect(out).To(Equal(outcomeAbsent))
Expect(lock.locks).To(BeZero())
})
@ -153,7 +153,7 @@ var _ = Describe("processor.acquire", func() {
})
folderRepo.result = nil
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
Expect(out).To(Equal(outcomeFound))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
@ -176,7 +176,7 @@ var _ = Describe("processor.acquire", func() {
{ID: "al3", Name: "Album"},
})
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
Expect(out).To(Equal(outcomeAbsent))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
@ -197,7 +197,7 @@ var _ = Describe("processor.acquire", func() {
{ID: "al-io", Name: "Album", FolderIDs: []string{"f1"}},
})
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-io", model.ImageTypePrimary)
@ -222,7 +222,7 @@ var _ = Describe("processor.acquire", func() {
DeferCleanup(func() { _ = os.Chmod(upload, 0o600) })
radioRepo.Data["ra-io"] = &model.Radio{ID: "ra-io", Name: "Station", UploadedImage: "ra-io.jpg"}
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra-io", model.ImageTypePrimary)
@ -249,7 +249,7 @@ var _ = Describe("processor.acquire", func() {
radioRepo.Data["ra-tr"] = &model.Radio{ID: "ra-tr", Name: "Station", UploadedImage: "ra-tr.jpg"}
trace := &ChainTrace{}
out, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"})
out, _, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"})
Expect(out).To(Equal(outcomeFailed))
steps := trace.Steps()
@ -265,13 +265,26 @@ var _ = Describe("processor.acquire", func() {
})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})
out, _, retryIn := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})
Expect(out).To(Equal(outcomeFailed))
Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay")
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("failed-on-extError: reports the delay a throttled provider asked for", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "al4r", Name: "Album"},
})
imageAgents(&fakeImageAgent{name: "throttled", err: &agents.RetryLaterError{RetryIn: 42 * time.Second}})
out, _, retryIn := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4r"})
Expect(out).To(Equal(outcomeFailed))
Expect(retryIn).To(Equal(42 * time.Second))
})
It("found-stale: a fallback hit after a transient external failure persists state and returns outcomeFoundStale", func() {
conf.Server.CoverArtPriority = "external, cover.jpg"
folderRepo.result = []model.Folder{{
@ -283,7 +296,7 @@ var _ = Describe("processor.acquire", func() {
})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})
Expect(out).To(Equal(outcomeFoundStale))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
@ -300,7 +313,7 @@ var _ = Describe("processor.acquire", func() {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alU", Name: "Album", FolderIDs: []string{"f1"}}})
folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}}
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"})
Expect(out).To(Equal(outcomeFound))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary)
@ -320,7 +333,7 @@ var _ = Describe("processor.acquire", func() {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alE", Name: "Album", FolderIDs: []string{"f1"}}})
folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}}
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary)
@ -338,7 +351,7 @@ var _ = Describe("processor.acquire", func() {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alX", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}})
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary)
@ -357,7 +370,7 @@ var _ = Describe("processor.acquire", func() {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alext", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}})
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"})
Expect(out).To(Equal(outcomeFound))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary)
@ -382,7 +395,7 @@ var _ = Describe("processor.acquire", func() {
{ID: "al6", Name: "Album B", FolderIDs: []string{"f1"}},
})
out1, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
Expect(out1).To(Equal(outcomeFound))
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
@ -392,7 +405,7 @@ var _ = Describe("processor.acquire", func() {
poisoned.BlurHash = "SENTINEL"
artRepo.Data[ia1.Hash] = poisoned
out2, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
Expect(out2).To(Equal(outcomeFound))
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
@ -422,7 +435,7 @@ var _ = Describe("processor.acquire", func() {
})
folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}}
outN, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})
outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})
Expect(outN).To(Equal(outcomeFound))
iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
@ -436,7 +449,7 @@ var _ = Describe("processor.acquire", func() {
artRepo.Data[iaA.Hash] = poisoned
folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}}
outN, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})
outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})
Expect(outN).To(Equal(outcomeFound))
iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
@ -467,7 +480,7 @@ var _ = Describe("processor.acquire", func() {
radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}}
ds.MockedRadio = radioRepo
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
@ -488,7 +501,7 @@ var _ = Describe("processor.acquire", func() {
radioRepo.Data = map[string]*model.Radio{"big": {ID: "big", Name: "Radio", UploadedImage: "big_test.jpg"}}
ds.MockedRadio = radioRepo
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"})
Expect(out).To(Equal(outcomeFailed))
_, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary)
@ -554,7 +567,7 @@ var _ = Describe("processor.acquire", func() {
Expect(err).ToNot(HaveOccurred())
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed())
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"})
Expect(out).To(Equal(outcomeFound))
upgraded, err := artRepo.GetImage(hash)
@ -574,7 +587,7 @@ var _ = Describe("processor.acquire", func() {
Expect(os.WriteFile(blockedRoot, []byte("x"), 0600)).To(Succeed())
proc.store = NewImageStore(blockedRoot)
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)

View File

@ -76,7 +76,7 @@ func toFastScaleType(img image.Image) image.Image {
}
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
original, format, err := image.Decode(bytes.NewReader(data))
original, format, err := decodeCapped(data)
if err != nil {
return nil, 0, err
}

View File

@ -0,0 +1,13 @@
package artwork
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("resizeStaticImage", func() {
It("rejects images whose declared dimensions exceed the pixel cap before decoding", func() {
_, _, err := resizeStaticImage(pngHeaderWithDims(9000, 9000), 300, false)
Expect(err).To(MatchError(ContainSubstring("exceed pixel cap")))
})
})

View File

@ -25,9 +25,9 @@ type resolution struct {
source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated"
sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise
refMtime int64 // sourcePath mtime (unix-nanoseconds) at resolution; 0 when no sourcePath
// external source errored/timed out. With no reader it forces failed (never absent);
// on a hit a higher-priority external step failed—serve this, but retry later.
extError bool
// a faulted external source, carrying the provider's requested delay when it named one.
// With no reader it forces failed (never absent); on a hit, serve this but retry later.
extErr error
// a local source that should have been readable wasn't. With no reader it forces failed,
// so a transient I/O fault never records absent.
localError bool
@ -36,14 +36,15 @@ type resolution struct {
// chainState carries what a priority walk has seen so far. A hit takes extErr with it so a
// transient external failure still retries; localErr is dropped, as the scanner re-lists changes.
type chainState struct {
extErr, localErr bool
trace *ChainTrace // nil only where no caller attached one
extErr error
localErr bool
trace *ChainTrace // nil only where no caller attached one
}
// try stamps the accumulated external failure onto a hit, and records the miss otherwise.
func (c *chainState) try(candidate string, res resolution, ok bool) (resolution, bool) {
if ok {
res.extError = c.extErr
res.extErr = c.extErr
c.record(candidate, OutcomeHit, res.sourcePath)
return res, true
}
@ -62,7 +63,7 @@ func (c *chainState) record(candidate string, out Outcome, detail string) {
// exhausted is the outcome when no source in the chain yielded an image.
func (c *chainState) exhausted() resolution {
return resolution{extError: c.extErr, localError: c.localErr}
return resolution{extErr: c.extErr, localError: c.localErr}
}
// externalSource holds the agents to ask and the rate limiter/circuit breaker to ask them through.
@ -181,16 +182,16 @@ func chainFetchesExternal(priority string) bool {
// Album and artist fetches stop here when the resolver is local-only, rather than at each point in
// the chain walk; resolvePlaylist gates the third network path, the m3u image URL, itself.
func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, bool) {
func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, error) {
if r.ext == nil {
return nil, "", false
return nil, "", nil
}
return fetchAlbumImage(ctx, r.ext.agents, r.ext.gate, al)
}
func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, bool) {
func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, error) {
if r.ext == nil {
return nil, "", false
return nil, "", nil
}
return fetchArtistImage(ctx, r.ext.agents, r.ext.gate, ar)
}
@ -223,10 +224,10 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution
return res, nil
}
case pattern == externalCandidate:
if rd, name, isErr := r.fetchExternalAlbum(ctx, *al); rd != nil {
if rd, name, err := r.fetchExternalAlbum(ctx, *al); rd != nil {
return resolution{reader: rd, source: ExternalPrefix + name}, nil
} else if isErr {
chain.extErr = true
} else if err != nil {
chain.extErr = longerRetry(chain.extErr, err)
}
case len(imgFiles) > 0:
res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern)
@ -285,10 +286,10 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
}
switch {
case pattern == externalCandidate:
if rd, name, isErr := r.fetchExternalArtist(ctx, *ar); rd != nil {
if rd, name, err := r.fetchExternalArtist(ctx, *ar); rd != nil {
return resolution{reader: rd, source: ExternalPrefix + name}, nil
} else if isErr {
chain.extErr = true
} else if err != nil {
chain.extErr = longerRetry(chain.extErr, err)
}
case pattern == "image-folder":
res, ok := resolveArtistImageFolder(ar)
@ -332,7 +333,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
return resolution{}, err
}
var extErr bool
var extErr error
for _, src := range []struct{ path, source string }{
{pl.UploadedImagePath(), "upload"},
{findPlaylistSidecarPath(ctx, pl.Path), "folder"},
@ -366,7 +367,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
if res, ok, err := resolveExternalStep(r.ext.gate, "m3u", sf); ok {
return res, nil
} else if err != nil {
extErr = true
extErr = longerRetry(extErr, err)
// Record it here with its detail: once album sampling adds its own steps, the processor's
// empty-trace fallback no longer fires, and the error that forced the retry would be lost.
traceFrom(ctx).add(TraceStep{Candidate: ExternalPrefix + "m3u", Outcome: OutcomeError, Detail: err.Error()})
@ -389,8 +390,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
}
continue
}
if res.extError {
extErr = true
if res.extErr != nil {
extErr = longerRetry(extErr, res.extErr)
}
if res.reader == nil {
continue
@ -409,7 +410,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
if tileErr != nil {
return resolution{}, fmt.Errorf("resolvePlaylist: sampled album art failed: %w", tileErr)
}
return resolution{extError: extErr}, nil
return resolution{extErr: extErr}, nil
}
// Grow to 4 tiles by repeating what we have.
switch len(tiles) {
@ -420,9 +421,9 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
}
grid, err := assembleTiles(tiles)
if err != nil {
return resolution{extError: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolution error
return resolution{extErr: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolution error
}
return resolution{reader: grid, source: "generated", extError: extErr}, nil
return resolution{reader: grid, source: "generated", extErr: extErr}, nil
}
// resolveRadio serves only an uploaded image; there is no fallback.
@ -571,7 +572,7 @@ func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFold
// resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other
// open failure says nothing about whether the image exists.
func resolveLocalFile(path, source string) (resolution, bool) {
if path == "" {
if path == "" || !model.IsImageFile(path) {
return resolution{}, false
}
f, err := os.Open(path)

View File

@ -100,7 +100,7 @@ var _ = Describe("resolveItem", func() {
Expect(res.source).To(Equal("embedded"))
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
Expect(res.refMtime).To(BeNumerically(">", 0))
Expect(res.extError).To(BeFalse())
Expect(res.extErr).ToNot(HaveOccurred())
})
It("resolves absent when the track has no cover art", func() {
@ -111,7 +111,7 @@ var _ = Describe("resolveItem", func() {
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf2"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).To(BeNil())
Expect(res.extError).To(BeFalse())
Expect(res.extErr).ToNot(HaveOccurred())
})
It("resolves absent when media file cover art is disabled", func() {
@ -154,7 +154,7 @@ var _ = Describe("resolveItem", func() {
Expect(res.source).To(Equal("folder"))
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
Expect(res.refMtime).To(BeNumerically(">", 0))
Expect(res.extError).To(BeFalse())
Expect(res.extErr).ToNot(HaveOccurred())
})
It("falls back to embedded art when no folder image matches", func() {
@ -172,7 +172,7 @@ var _ = Describe("resolveItem", func() {
Expect(res.refMtime).To(BeNumerically(">", 0))
})
It("sets extError when the external source errors without being not-found", func() {
It("sets extErr when the external source errors without being not-found", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "al3", Name: "Album"},
@ -182,10 +182,10 @@ var _ = Describe("resolveItem", func() {
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).To(BeNil())
Expect(res.extError).To(BeTrue())
Expect(res.extErr).To(HaveOccurred())
})
It("does not set extError when the external source reports not-found", func() {
It("does not set extErr when the external source reports not-found", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "al4", Name: "Album"},
@ -195,10 +195,10 @@ var _ = Describe("resolveItem", func() {
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).To(BeNil())
Expect(res.extError).To(BeFalse())
Expect(res.extErr).ToNot(HaveOccurred())
})
It("carries extError onto a fallback folder hit after a transient external failure", func() {
It("carries extErr onto a fallback folder hit after a transient external failure", func() {
conf.Server.CoverArtPriority = "external, cover.jpg"
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
@ -214,10 +214,10 @@ var _ = Describe("resolveItem", func() {
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(res.source).To(Equal("folder"))
Expect(res.extError).To(BeTrue())
Expect(res.extErr).To(HaveOccurred())
})
It("does not carry extError onto a fallback folder hit after a definitive external not-found", func() {
It("does not carry extErr onto a fallback folder hit after a definitive external not-found", func() {
conf.Server.CoverArtPriority = "external, cover.jpg"
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
@ -233,7 +233,7 @@ var _ = Describe("resolveItem", func() {
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(res.source).To(Equal("folder"))
Expect(res.extError).To(BeFalse())
Expect(res.extErr).ToNot(HaveOccurred())
})
It("routes the external step through the injected gate, keyed by agent name", func() {
@ -250,7 +250,7 @@ var _ = Describe("resolveItem", func() {
res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
Expect(err).ToNot(HaveOccurred())
Expect(res.extError).To(BeTrue())
Expect(res.extErr).To(HaveOccurred())
Expect(gatedNames).To(Equal([]string{"failAgent"}))
})
})
@ -298,7 +298,7 @@ var _ = Describe("resolveItem", func() {
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png"))
})
It("sets extError when the external source errors without being not-found", func() {
It("sets extErr when the external source errors without being not-found", func() {
conf.Server.ArtistArtPriority = "external"
artistRepo := tests.CreateMockArtistRepo()
artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}})
@ -308,10 +308,10 @@ var _ = Describe("resolveItem", func() {
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).To(BeNil())
Expect(res.extError).To(BeTrue())
Expect(res.extErr).To(HaveOccurred())
})
It("does not set extError when the external source reports not-found", func() {
It("does not set extErr when the external source reports not-found", func() {
conf.Server.ArtistArtPriority = "external"
artistRepo := tests.CreateMockArtistRepo()
artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}})
@ -321,7 +321,7 @@ var _ = Describe("resolveItem", func() {
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).To(BeNil())
Expect(res.extError).To(BeFalse())
Expect(res.extErr).ToNot(HaveOccurred())
})
It("routes the external step through the injected gate, keyed by agent name", func() {
@ -338,7 +338,7 @@ var _ = Describe("resolveItem", func() {
res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"})
Expect(err).ToNot(HaveOccurred())
Expect(res.extError).To(BeTrue())
Expect(res.extErr).To(HaveOccurred())
Expect(gatedNames).To(Equal([]string{"failAgent"}))
})
})
@ -498,6 +498,23 @@ var _ = Describe("resolveItem", func() {
Expect(res.refMtime).To(BeNumerically(">", 0))
})
It("never opens a local ExternalImageURL that is not an image file", func() {
folderRepo.result = nil // no grid tiles, so only the local file could produce a reader
dir := GinkgoT().TempDir()
secretPath := filepath.Join(dir, "config.ini")
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
plRepo := tests.CreateMockPlaylistRepo()
plRepo.SetData(model.Playlists{{ID: "plni", Name: "Playlist", ExternalImageURL: secretPath}})
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
ds.MockedPlaylist = plRepo
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plni"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).To(BeNil())
Expect(res.sourcePath).ToNot(Equal(secretPath))
})
It("routes ExternalImageURL through extGate and sets extError on transient failure", func() {
conf.Server.EnableM3UExternalAlbumArt = true
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces
@ -516,7 +533,7 @@ var _ = Describe("resolveItem", func() {
res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).To(BeNil())
Expect(res.extError).To(BeTrue())
Expect(res.extErr).To(HaveOccurred())
Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"")
})
@ -537,7 +554,7 @@ var _ = Describe("resolveItem", func() {
res, err := newResolver(ds, ag, ffm, gate).resolve(withTrace(ctx, trace),
model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm3u"})
Expect(err).ToNot(HaveOccurred())
Expect(res.extError).To(BeTrue())
Expect(res.extErr).To(HaveOccurred())
steps := trace.Steps()
var m3u *TraceStep
@ -562,7 +579,7 @@ var _ = Describe("resolveItem", func() {
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).To(BeNil())
Expect(res.extError).To(BeFalse())
Expect(res.extErr).ToNot(HaveOccurred())
})
It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() {
@ -582,7 +599,7 @@ var _ = Describe("resolveItem", func() {
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(res.source).To(Equal("generated"))
Expect(res.extError).To(BeFalse())
Expect(res.extErr).ToNot(HaveOccurred())
})
// A local resolver holds no agents: reaching the external branch would panic, not degrade.
@ -594,7 +611,7 @@ var _ = Describe("resolveItem", func() {
res, err := newLocalResolver(ds, ffm).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).To(BeNil())
Expect(res.extError).To(BeFalse(), "a skipped step is not a failed one")
Expect(res.extErr).ToNot(HaveOccurred(), "a skipped step is not a failed one")
})
// The worker resolving the same playlist is asserted alongside, so this cannot pass vacuously.
@ -642,7 +659,7 @@ var _ = Describe("resolveItem", func() {
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).To(BeNil())
Expect(res.extError).To(BeTrue())
Expect(res.extErr).To(HaveOccurred())
})
It("yields an empty resolution when no album has art", func() {

View File

@ -10,16 +10,15 @@ import (
"net/http"
"net/url"
"path/filepath"
"reflect"
"regexp"
"runtime"
"strings"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/netguard"
"go.senan.xyz/taglib"
)
@ -29,16 +28,6 @@ var errSourceUnreadable = errors.New("artwork source unreadable")
type sourceFunc func() (r io.ReadCloser, path string, err error)
func (f sourceFunc) String() string {
name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name()
name = strings.TrimPrefix(name, "github.com/navidrome/navidrome/core/artwork.")
if _, after, found := strings.Cut(name, ")."); found {
name = after
}
name = strings.TrimSuffix(name, ".func1")
return name
}
func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc {
return func() (io.ReadCloser, string, error) {
var openErr error
@ -49,7 +38,7 @@ func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern
log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file)
continue
}
if !match {
if !match || !model.IsImageFile(name) {
continue
}
f, err := libFS.Open(file)
@ -162,11 +151,18 @@ type readCloser struct {
io.Closer
}
// remoteImageClient fetches URLs from playlists and agents (plugins included), so it must not reach
// internal hosts. Shared so fetches reuse connections.
var remoteImageClient = httpclient.NewExternal(5 * time.Second)
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
hc := http.Client{Timeout: 5 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
req.Header.Set("User-Agent", consts.HTTPUserAgent)
resp, err := hc.Do(req) //nolint:gosec
resp, err := remoteImageClient.Do(req)
if errors.Is(err, netguard.ErrPrivateAddress) {
// Retrying cannot change where the URL points: settle absent instead of tripping the breaker.
log.Warn(ctx, "Artwork: Refused to fetch image from a private or loopback address", "url", imageUrl, err)
return nil, "", model.ErrNotFound
}
if err != nil {
return nil, "", err
}

View File

@ -5,13 +5,87 @@ import (
"errors"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"os"
"strings"
"sync/atomic"
"testing/fstest"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/httpclient"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("fromURL", func() {
var (
hits atomic.Int32
target *httptest.Server
)
BeforeEach(func() {
hits.Store(0)
target = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte("image-bytes"))
}))
DeferCleanup(target.Close)
})
useClient := func(c *http.Client) {
prev := remoteImageClient
remoteImageClient = c
DeferCleanup(func() { remoteImageClient = prev })
}
fetch := func(rawURL string) ([]byte, error) {
u, err := url.Parse(rawURL)
Expect(err).ToNot(HaveOccurred())
r, _, err := fromURL(GinkgoT().Context(), u)
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
// Stand-in for a public host: only 127.0.0.1 is allowed, so every other private address stays refused.
onlyLocalhostV4 := func() *http.Client {
return httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.1/32"))
}
DescribeTable("refuses private and loopback targets as a definitive miss",
func(rawURL string) {
useClient(productionImageClient)
u, _ := url.Parse(target.URL)
_, err := fetch(strings.ReplaceAll(rawURL, "PORT", u.Port()))
Expect(err).To(MatchError(model.ErrNotFound))
Expect(hits.Load()).To(BeZero())
},
Entry("IPv4 loopback", "http://127.0.0.1:PORT/x"),
Entry("localhost", "http://localhost:PORT/x"),
Entry("cloud metadata", "http://169.254.169.254/"),
Entry("IPv6 loopback", "http://[::1]/"),
)
It("refuses a redirect from an allowed host to a loopback address", func() {
useClient(onlyLocalhostV4())
redirector := httptest.NewServer(http.RedirectHandler(strings.Replace(target.URL, "127.0.0.1", "127.0.0.2", 1), http.StatusFound))
DeferCleanup(redirector.Close)
_, err := fetch(redirector.URL)
Expect(err).To(MatchError(model.ErrNotFound))
Expect(hits.Load()).To(BeZero())
})
It("fetches from an allowed address", func() {
useClient(onlyLocalhostV4())
Expect(fetch(target.URL + "/cover.jpg")).To(Equal([]byte("image-bytes")))
})
})
var _ = Describe("fromExternalFile", func() {
It("opens a matching file via the library FS", func() {
fsys := fstest.MapFS{
@ -48,6 +122,18 @@ var _ = Describe("fromExternalFile", func() {
Expect(b).To(Equal([]byte("a")))
Expect(path).To(Equal("a/cover.jpg"))
})
It("skips a matching file that is not an image", func() {
fsys := fstest.MapFS{
"a/cover.ini": &fstest.MapFile{Data: []byte("password=secret")},
"a/cover.jpg": &fstest.MapFile{Data: []byte("a")},
}
f := fromExternalFile(GinkgoT().Context(), fsys, []string{"a/cover.ini", "a/cover.jpg"}, "cover.*")
r, path, err := f()
Expect(err).ToNot(HaveOccurred())
defer r.Close()
Expect(path).To(Equal("a/cover.jpg"))
})
})
var _ = Describe("fromTag", func() {

View File

@ -23,8 +23,8 @@ import (
const (
workerPollInterval = 5 * time.Second
backoffBase = 5 * time.Second
// giveUpAfter bounds the retry budget from enqueue; past it the item falls to the
// periodic stale-absent recheck.
// giveUpAfter bounds the retry budget from enqueue; past it the item settles and only an
// explicit reprocess retries it.
giveUpAfter = 12 * time.Hour
)
@ -40,7 +40,6 @@ type drainPool struct {
// independently, and pruneMu serializes prune against the store-write window.
type Worker struct {
proc *processor
agents *agents.Agents
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
broker events.Broker
@ -55,7 +54,6 @@ type Worker struct {
func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker {
w := &Worker{
proc: &processor{ds: ds, store: store},
agents: ag,
cache: imgCache,
ffmpeg: ffmpeg,
broker: broker,
@ -133,17 +131,9 @@ func (w *Worker) RunPrune(ctx context.Context) error {
return prune(ctx, w.proc.ds, w.proc.store)
}
// Backfill enqueues every entity for re-resolution when the artwork config fingerprint changed,
// artists first. It reports whether the backfill ran.
func (w *Worker) Backfill(ctx context.Context) (bool, error) {
s, err := backfill(ctx, w.proc.ds, func() ImageAgentCount { return NewImageAgentCount(w.agents) })
return s.Ran, err
}
// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge, at most
// StaleAbsentRecheckBatch per kind, oldest first.
func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error {
return enqueueStaleAbsentAll(ctx, w.proc.ds)
// ReconcileConfig records the artwork config fingerprint, or warns when it changed.
func (w *Worker) ReconcileConfig(ctx context.Context) error {
return ReconcileConfigFingerprint(ctx, w.proc.ds)
}
// EnqueueMissingAll requeues entities with no artwork state row: the safety net for anything
@ -241,7 +231,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
trace := &ChainTrace{}
ctx = withTrace(ctx, trace)
out, got := w.proc.acquire(ctx, item)
out, got, retryIn := w.proc.acquire(ctx, item)
queue := w.proc.ds.ArtworkQueue(ctx)
switch out {
@ -252,7 +242,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
}
case outcomeFoundStale, outcomeFailed:
retryAt := time.Now().Add(backoff(item.Attempts))
retryAt := time.Now().Add(retryDelay(item.Attempts, retryIn))
encoded := trace.encode("")
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
@ -265,10 +255,9 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
"budgetLeft", time.Until(item.EnqueuedAt.Add(giveUpAfter)))
break
}
// Absent is only recoverable where a periodic recheck revisits it, so other kinds keep
// no row; art already being served is kept, as exhaustion means unreachable, not removed.
// Art already being served is kept: exhaustion means unreachable, not removed.
settled := "kept previous state"
if out == outcomeFailed && hasRecheckPath(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) {
if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) {
writeAbsent(ctx, w.proc.ds.Artwork(ctx), item)
settled = "recorded absent"
}
@ -341,3 +330,8 @@ func backoffFor(attempts int, jitter float64) time.Duration {
func backoff(attempts int) time.Duration {
return backoffFor(attempts, rand.Float64()*0.8-0.4) //nolint:gosec // retry jitter, not security-sensitive
}
// retryDelay is how long a failed item waits: our backoff, unless the provider asked for longer.
func retryDelay(attempts int, hint time.Duration) time.Duration {
return max(backoff(attempts), hint)
}

View File

@ -95,7 +95,7 @@ var _ = Describe("Worker soak", func() {
start := time.Now()
for i := range soakCycles {
it := items[i%len(items)]
out, _ := proc.acquire(context.Background(), it)
out, _, _ := proc.acquire(context.Background(), it)
// Read-back exercises the surfaces a caller would use after acquisition.
if out == outcomeFound {

View File

@ -15,6 +15,7 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/cache"
@ -115,6 +116,29 @@ func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQu
return nil
}
// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only
// visible when the ctx carries an admin, so headless work must wrap ctx with one first.
type visibilityPlaylistDS struct {
*tests.MockDataStore
private model.Playlist
tracks model.PlaylistTrackRepository
}
func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository {
repo := tests.CreateMockPlaylistRepo()
repo.TracksRepo = v.tracks
if u, ok := request.UserFrom(ctx); ok && u.IsAdmin {
repo.SetData(model.Playlists{v.private})
}
return repo
}
func adminUserRepo() *tests.MockedUserRepo {
repo := tests.CreateMockUserRepo()
Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
return repo
}
var _ = Describe("Worker", func() {
var (
ctx context.Context
@ -245,6 +269,23 @@ var _ = Describe("Worker", func() {
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
})
It("reschedules past the provider's requested delay when it exceeds the backoff", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
// Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at.
const askedFor = 90 * time.Minute
imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
n, err := w.drain(ctx, 2)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(1))
it := findQueued(queueRepo, "al", "al9")
Expect(it).ToNot(BeNil())
Expect(it.RetryAt).To(BeTemporally("~", time.Now().Add(askedFor), time.Minute))
})
It("reschedules a found-stale item via MarkFailed while keeping its served state", func() {
conf.Server.CoverArtPriority = "external, cover.jpg"
folderRepo.result = []model.Folder{{
@ -424,9 +465,9 @@ var _ = Describe("Worker", func() {
Expect(ia.Hash).To(Equal("cafebabe"), "recording the failure must not disturb the served art")
})
// Media files are excluded from RecheckKinds, so an absent row here would never be
// revisited: a transient read error would look permanent.
It("does not settle absent on exhaustion for a kind with no recheck path", func() {
// Only a view enqueues a media file, and an absent row is exactly what stops a view from
// doing so: a transient read error would look permanent.
It("does not settle absent on exhaustion for a media file", func() {
conf.Server.EnableMediaFileCoverArt = true
ds.MockedMediaFile = tests.CreateMockMediaFileRepo()
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
@ -911,3 +952,19 @@ var _ = Describe("backoff", func() {
}
})
})
var _ = Describe("retryDelay", func() {
It("uses the backoff schedule when the provider asked for nothing", func() {
d := retryDelay(0, 0)
Expect(d).To(BeNumerically(">=", 3*time.Second))
Expect(d).To(BeNumerically("<=", 7*time.Second))
})
It("waits the provider's delay when it is longer than the backoff", func() {
Expect(retryDelay(0, time.Hour)).To(Equal(time.Hour))
})
It("keeps the backoff when it is longer than the provider's delay", func() {
Expect(retryDelay(4, time.Second)).To(BeNumerically(">=", 3*time.Second))
})
})

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"sort"
"strings"
"time"
@ -14,6 +15,7 @@ import (
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
@ -33,12 +35,14 @@ type Provider interface {
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
TopSongs(ctx context.Context, artist, artistId string, count int) (model.MediaFiles, error)
RefreshInfo(ctx context.Context, kind model.Kind, id string) error
}
type provider struct {
ds model.DataStore
ag Agents
matcher *matcher.Matcher
broker events.Broker
artistQueue refreshQueue[auxArtist]
albumQueue refreshQueue[auxAlbum]
}
@ -83,13 +87,17 @@ type Agents interface {
agents.SimilarSongsByArtistRetriever
}
func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher) Provider {
e := &provider{ds: ds, ag: agents, matcher: m}
func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher, broker events.Broker) Provider {
e := &provider{ds: ds, ag: agents, matcher: m, broker: broker}
e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
return e
}
func (e *provider) broadcastRefresh(ctx context.Context, resource, id string) {
e.broker.SendBroadcastMessage(ctx, (&events.RefreshResource{}).With(resource, id))
}
func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
var entity any
entity, err := model.GetEntityByID(ctx, e.ds, id)
@ -140,7 +148,8 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
start := time.Now()
albumName := album.Name()
info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if errors.Is(err, agents.ErrNotFound) {
// Throttled joins not-found: no answer to store, and an unstamped timestamp retries next call.
if errors.Is(err, agents.ErrNotFound) || errors.Is(err, agents.ErrRetryLater) {
return album, nil
}
if err != nil {
@ -179,6 +188,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
e.broadcastRefresh(ctx, "album", album.ID)
}
return album, nil
@ -244,38 +254,81 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
start := time.Now()
// Get MBID first, if it is not yet available
artistName := artist.Name()
var mbidErr error
if artist.MbzArtistID == "" {
mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName)
mbidErr = err
if mbid != "" && err == nil {
artist.MbzArtistID = mbid
}
}
// Call all registered agents and collect information
// Call all registered agents and collect information. The group carries no context, so a
// returned error does not cancel the siblings; only throttling is reported back.
g := errgroup.Group{}
g.SetLimit(2)
g.Go(func() error { _ = e.callGetImage(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
_ = g.Wait()
g.Go(func() error { return retryLaterOnly(e.callGetImage(ctx, e.ag, &artist)) })
g.Go(func() error { return retryLaterOnly(e.callGetBiography(ctx, e.ag, &artist)) })
g.Go(func() error { return retryLaterOnly(e.callGetURL(ctx, e.ag, &artist)) })
g.Go(func() error {
return retryLaterOnly(e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true))
})
throttled := errors.Is(g.Wait(), agents.ErrRetryLater) || errors.Is(mbidErr, agents.ErrRetryLater)
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err())
return artist, ctx.Err()
}
artist.ExternalInfoUpdatedAt = new(time.Now())
// A throttled round keeps the previous timestamp, so the next call retries instead of
// serving an empty cache entry for the whole TTL.
if !throttled {
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,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
e.broadcastRefresh(ctx, "artist", artist.ID)
}
return artist, nil
}
// infoKinds are the kinds RefreshInfo can act on. Callers check this instead of restating
// the set, so the switch below stays the only place that has to know how each kind loads.
var infoKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork}
// HasInfo reports whether a kind has external info to refresh.
func HasInfo(kind model.Kind) bool { return slices.Contains(infoKinds, kind) }
// RefreshInfo re-fetches external info for one item, ignoring the TTL. It is synchronous:
// callers that must not block are responsible for detaching it.
func (e *provider) RefreshInfo(ctx context.Context, kind model.Kind, id string) error {
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
defer cancel()
switch kind {
case model.KindArtistArtwork:
artist, err := e.getArtist(ctx, id)
if err != nil {
return err
}
_, err = e.populateArtistInfo(ctx, artist)
return err
case model.KindAlbumArtwork:
album, err := e.getAlbum(ctx, id)
if err != nil {
return err
}
_, err = e.populateAlbumInfo(ctx, album)
return err
default:
return model.ErrNotFound
}
}
func (e *provider) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) {
artist, err := e.findArtist(ctx, artistName, id)
if err != nil {
@ -291,8 +344,9 @@ func (e *provider) TopSongs(ctx context.Context, artistName, id string, count in
songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count)
if err != nil {
switch {
case errors.Is(err, agents.ErrNotFound):
log.Trace(ctx, "TopSongs not found", "name", artistName)
// Throttled is not an answer, but the caller keeps the empty 200 it got before.
case errors.Is(err, agents.ErrNotFound), errors.Is(err, agents.ErrRetryLater):
log.Trace(ctx, "TopSongs not found", "name", artistName, err)
return nil, model.ErrNotFound
case errors.Is(err, context.Canceled):
log.Debug(ctx, "TopSongs call canceled", err)
@ -342,22 +396,33 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
return mfs, nil
}
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
// retryLaterOnly discards every failure the caller does not act on, so errgroup's
// first-error slot is reserved for the throttling signal.
func retryLaterOnly(err error) error {
if errors.Is(err, agents.ErrRetryLater) {
return err
}
artist.ExternalUrl = artisURL
return nil
}
func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) error {
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return err
}
artist.ExternalUrl = artisURL
return nil
}
func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) error {
bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
return err
}
bio = str.SanitizeText(bio)
bio = strings.ReplaceAll(bio, "\n", " ")
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
return nil
}
// callGetImage populates artist's image URLs. A transient agent failure is
@ -385,19 +450,20 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet
}
func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
limit int, includeNotPresent bool) {
limit int, includeNotPresent bool) error {
artistName := artist.Name()
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit)
if len(similar) == 0 || err != nil {
return
return err
}
start := time.Now()
sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent)
log.Debug(ctx, "Mapped Similar Artists", "artist", artistName, "numSimilar", len(sa), "elapsed", time.Since(start))
if err != nil {
return
return err
}
artist.SimilarArtists = sa
return nil
}
func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, limit int, includeNotPresent bool) (model.Artists, error) {

View File

@ -0,0 +1,156 @@
package external_test
import (
"context"
"slices"
"sync"
"time"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
)
type fakeBroker struct {
events.Broker
mu sync.Mutex
events []events.Event
}
func (f *fakeBroker) SendBroadcastMessage(_ context.Context, e events.Event) {
f.mu.Lock()
defer f.mu.Unlock()
f.events = append(f.events, e)
}
func (f *fakeBroker) sent() []events.Event {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(f.events)
}
var _ = Describe("Provider - RefreshInfo", func() {
var (
ctx context.Context
p external.Provider
ds *tests.MockDataStore
ag *mockAgents
broker *fakeBroker
mockArtistRepo *tests.MockArtistRepo
mockAlbumRepo *tests.MockAlbumRepo
)
expectArtistAgents := func() {
ag.On("GetArtistMBID", mock.Anything, mock.Anything, mock.Anything).Return("mbid-1", nil)
ag.On("GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return([]agents.ExternalImage{}, nil)
ag.On("GetArtistBiography", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return("Fresh Bio", nil)
ag.On("GetArtistURL", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return("http://artist.url", nil)
ag.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return([]agents.Artist{}, nil)
}
expectAlbumAgents := func() {
ag.On("GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(&agents.AlbumInfo{URL: "http://album.url", Description: "Fresh Notes"}, nil)
ag.On("GetAlbumImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return([]agents.ExternalImage{}, nil)
}
BeforeEach(func() {
ctx = GinkgoT().Context()
ds = new(tests.MockDataStore)
ag = new(mockAgents)
broker = &fakeBroker{}
p = external.NewProvider(ds, ag, matcher.New(ds), broker)
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
})
It("repopulates an artist even when its info is fresh", func() {
fresh := time.Now()
mockArtistRepo.SetData(model.Artists{{
ID: "ar-1", Name: "Test Artist", Biography: "stale", ExternalInfoUpdatedAt: &fresh,
}})
expectArtistAgents()
Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed())
saved, err := mockArtistRepo.Get("ar-1")
Expect(err).ToNot(HaveOccurred())
Expect(saved.Biography).To(Equal("Fresh Bio"))
})
It("repopulates an album even when its info is fresh", func() {
fresh := time.Now()
mockAlbumRepo.SetData(model.Albums{{
ID: "al-1", Name: "Test Album", AlbumArtist: "Test Artist",
Description: "stale", ExternalInfoUpdatedAt: &fresh,
}})
expectAlbumAgents()
Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed())
saved, err := mockAlbumRepo.Get("al-1")
Expect(err).ToNot(HaveOccurred())
Expect(saved.Description).To(Equal("Fresh Notes"))
})
It("returns ErrNotFound for an unknown id", func() {
Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "nope")).To(MatchError(model.ErrNotFound))
})
It("returns ErrNotFound for a kind with no external info", func() {
Expect(p.RefreshInfo(ctx, model.KindPlaylistArtwork, "pl-1")).To(MatchError(model.ErrNotFound))
})
It("broadcasts a RefreshResource naming the artist", func() {
mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Test Artist"}})
expectArtistAgents()
Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed())
sent := broker.sent()
Expect(sent).To(HaveLen(1))
rr, ok := sent[0].(*events.RefreshResource)
Expect(ok).To(BeTrue())
Expect(rr.Data(rr)).To(ContainSubstring("ar-1"))
Expect(rr.Data(rr)).To(ContainSubstring("artist"))
})
It("broadcasts a RefreshResource naming the album", func() {
mockAlbumRepo.SetData(model.Albums{{ID: "al-1", Name: "Test Album", AlbumArtist: "Test Artist"}})
expectAlbumAgents()
Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed())
sent := broker.sent()
Expect(sent).To(HaveLen(1))
Expect(sent[0].Data(sent[0])).To(ContainSubstring("album"))
Expect(sent[0].Data(sent[0])).To(ContainSubstring("al-1"))
})
It("does not broadcast when the artist cannot be loaded", func() {
mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Test Artist"}})
expectArtistAgents()
mockArtistRepo.SetError(true)
_ = p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")
Expect(broker.sent()).To(BeEmpty())
})
It("reports which kinds have external info", func() {
Expect(external.HasInfo(model.KindArtistArtwork)).To(BeTrue())
Expect(external.HasInfo(model.KindAlbumArtwork)).To(BeTrue())
Expect(external.HasInfo(model.KindPlaylistArtwork)).To(BeFalse())
})
})

View File

@ -167,6 +167,7 @@ func (e *provider) seedMix(ctx context.Context, count int, sample func() (model.
if len(matched) == 0 {
matched = seeds
}
//nolint:gosec // shuffle order is not a security decision
rand.Shuffle(len(matched), func(i, j int) { matched[i], matched[j] = matched[j], matched[i] })
if len(matched) > count {
matched = matched[:count]
@ -239,7 +240,7 @@ func (e *provider) similarSongsFallback(ctx context.Context, id string, count in
return nil, err
}
e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false)
_ = e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false)
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
return nil, ctx.Err()

View File

@ -61,7 +61,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
similarAgent: mockSimilarAgent,
}
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
provider = NewProvider(ds, agentsCombined, matcher.New(ds), &fakeBroker{})
})
// Resolves track-1 through the GetEntityByID probe order and on to its artist. Left permissive:

View File

@ -45,7 +45,7 @@ var _ = Describe("Provider - TopSongs", func() {
ag = new(mockAgents)
p = NewProvider(ds, ag, matcher.New(ds))
p = NewProvider(ds, ag, matcher.New(ds), &fakeBroker{})
})
It("returns top songs for a known artist", func() {
@ -232,6 +232,21 @@ var _ = Describe("Provider - TopSongs", func() {
ag.AssertExpectations(GinkgoT())
})
// This endpoint answered with an empty list before retry-later existed; it must keep doing so.
It("returns an empty list, not a client error, when the agents are throttled", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).
Return(nil, agents.ErrRetryLater).Once()
songs, err := p.TopSongs(ctx, "Artist One", "", 5)
Expect(songs).To(BeEmpty())
Expect(err).To(MatchError(model.ErrNotFound), "the handler renders this as an empty 200")
Expect(err).ToNot(MatchError(agents.ErrRetryLater))
ag.AssertExpectations(GinkgoT())
})
It("returns fewer songs if count is less than available top songs", func() {
// Mock finding the artist
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}

View File

@ -34,7 +34,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ctx = GinkgoT().Context()
ds = new(tests.MockDataStore)
ag = new(mockAgents)
p = external.NewProvider(ds, ag, matcher.New(ds))
p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{})
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
})
@ -164,4 +164,26 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ag.AssertExpectations(GinkgoT())
})
It("returns the original album, unstamped, when the agents are throttled", func() {
originalAlbum := &model.Album{
ID: "al-throttled",
Name: "Throttled Album",
AlbumArtist: "Throttled Artist",
MbzAlbumID: "mbid-throttled",
}
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
ag.On("GetAlbumInfo", ctx, "Throttled Album", "Throttled Artist", "mbid-throttled").
Return(nil, agents.ErrRetryLater)
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-throttled")
Expect(err).NotTo(HaveOccurred())
Expect(updatedAlbum).NotTo(BeNil())
Expect(*updatedAlbum).To(Equal(*originalAlbum))
Expect(updatedAlbum.ExternalInfoUpdatedAt).To(BeNil())
ag.AssertExpectations(GinkgoT())
})
})

View File

@ -37,7 +37,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
ctx = GinkgoT().Context()
ds = new(tests.MockDataStore)
ag = new(mockAgents)
p = external.NewProvider(ds, ag, matcher.New(ds))
p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{})
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
})
@ -104,6 +104,25 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
ag.AssertExpectations(GinkgoT())
})
// Stamping a throttled round would cache the empty result for the whole TTL.
It("does not stamp ExternalInfoUpdatedAt when the agents are throttled", func() {
originalArtist := &model.Artist{ID: "ar-throttled", Name: "Throttled Artist"}
mockArtistRepo.SetData(model.Artists{*originalArtist})
ag.On("GetArtistMBID", ctx, "ar-throttled", "Throttled Artist").Return("", agents.ErrRetryLater).Once()
ag.On("GetArtistImages", ctx, "ar-throttled", "Throttled Artist", "").Return(nil, agents.ErrRetryLater).Once()
ag.On("GetArtistBiography", ctx, "ar-throttled", "Throttled Artist", "").Return("", agents.ErrRetryLater).Once()
ag.On("GetArtistURL", ctx, "ar-throttled", "Throttled Artist", "").Return("", agents.ErrRetryLater).Once()
ag.On("GetSimilarArtists", ctx, "ar-throttled", "Throttled Artist", "", 100).Return(nil, agents.ErrRetryLater).Once()
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-throttled", 10, false)
Expect(err).ToNot(HaveOccurred())
Expect(updatedArtist).NotTo(BeNil())
Expect(updatedArtist.ExternalInfoUpdatedAt).To(BeNil())
ag.AssertExpectations(GinkgoT())
})
It("preserves decoded plain text in biography storage", func() {
originalArtist := &model.Artist{
ID: "ar-encoded-bio",

View File

@ -27,11 +27,12 @@ type TranscodeOptions struct {
Command string // DB command template (used to detect custom vs default)
Format string // Target format (mp3, opus, aac, flac)
FilePath string
BitRate int // kbps, 0 = codec default
SampleRate int // 0 = no constraint
Channels int // 0 = no constraint
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
Offset int // seconds
BitRate int // kbps, 0 = codec default
SampleRate int // 0 = no constraint
Channels int // 0 = no constraint
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
Offset int // seconds
Duration float32 // seconds; 0 = unknown. Only used to repair a piped FLAC header.
}
// AudioProbeResult contains authoritative audio stream properties from ffprobe.
@ -86,7 +87,11 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC
} else {
args = buildTemplateArgs(opts)
}
return e.start(ctx, args)
out, err := e.start(ctx, args)
if err != nil {
return nil, err
}
return patchFLACDuration(out, opts.Duration-float32(opts.Offset)), nil
}
func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) {

View File

@ -3,6 +3,7 @@ package ffmpeg
import (
"context"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
@ -684,6 +685,40 @@ var _ = Describe("ffmpeg", func() {
})
Expect(err).To(MatchError(context.Canceled))
})
It("fills in total_samples on a piped FLAC transcode", func() {
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
Format: "flac",
FilePath: "tests/fixtures/test.flac",
Duration: 1, // the fixture is exactly 1s at 44100Hz
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
out, err := io.ReadAll(stream)
Expect(err).ToNot(HaveOccurred())
Expect(string(out[:4])).To(Equal("fLaC"))
Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
})
It("patches the duration net of the requested offset", func() {
// The command has no %t, so ffmpeg still emits the whole fixture.
// What is under test is the header arithmetic, not the audio.
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
Format: "flac",
FilePath: "tests/fixtures/test.flac",
Duration: 3,
Offset: 1,
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
out, err := io.ReadAll(stream)
Expect(err).ToNot(HaveOccurred())
Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100)))
})
})
Context("stderr capture", func() {

View File

@ -0,0 +1,66 @@
package ffmpeg
import (
"bytes"
"encoding/binary"
"errors"
"io"
"math"
)
const (
flacPrefixLen = 26 // through the last total_samples byte
flacMaxTotalSamples = 1<<36 - 1
)
// patchFLACDuration fills in the STREAMINFO total_samples that ffmpeg leaves at 0
// when writing to a pipe, since a decoder cannot seek a cached FLAC without it.
func patchFLACDuration(r io.ReadCloser, duration float32) io.ReadCloser {
if duration <= 0 {
return r
}
return &flacPatcher{ReadCloser: r, duration: duration}
}
type flacPatcher struct {
io.ReadCloser
duration float32
// Peeking here rather than in the constructor keeps Transcode from blocking
// until ffmpeg has emitted its first bytes.
stream io.Reader
}
func (f *flacPatcher) Read(p []byte) (int, error) {
if f.stream == nil {
prefix := make([]byte, flacPrefixLen)
n, err := io.ReadFull(f.ReadCloser, prefix)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
return 0, err
}
prefix = prefix[:n]
if err == nil {
setFLACTotalSamples(prefix, f.duration)
}
f.stream = io.MultiReader(bytes.NewReader(prefix), f.ReadCloser)
}
return f.stream.Read(p)
}
// setFLACTotalSamples takes the rate from the header rather than the transcode
// options, so a resampled (-ar) output still gets the right count.
func setFLACTotalSamples(prefix []byte, duration float32) {
if string(prefix[:4]) != "fLaC" || prefix[4]&0x7F != 0 {
return
}
// 20-bit rate | 3-bit channels | 5-bit depth | 36-bit total_samples
info := binary.BigEndian.Uint64(prefix[18:])
rate := info >> 44
if rate == 0 || info&flacMaxTotalSamples != 0 {
return
}
total := math.Round(float64(duration) * float64(rate))
if total > flacMaxTotalSamples {
return
}
binary.BigEndian.PutUint64(prefix[18:], info|uint64(total))
}

View File

@ -0,0 +1,142 @@
package ffmpeg
import (
"bytes"
"errors"
"io"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Decoded independently so the specs do not mirror the production bit-twiddling.
func readSampleRate(b []byte) int {
return int(b[18])<<12 | int(b[19])<<4 | int(b[20])>>4
}
func readTotalSamples(b []byte) uint64 {
return uint64(b[21]&0x0F)<<32 | uint64(b[22])<<24 | uint64(b[23])<<16 | uint64(b[24])<<8 | uint64(b[25])
}
var _ = Describe("patchFLACDuration", func() {
var fileFLAC []byte
// Zeroing total_samples reproduces what a piped transcode emits.
pipedFLAC := func() []byte {
b := bytes.Clone(fileFLAC)
b[21] &= 0xF0
clear(b[22:26])
return b
}
readAll := func(in []byte, duration float32) []byte {
out, err := io.ReadAll(patchFLACDuration(io.NopCloser(bytes.NewReader(in)), duration))
Expect(err).ToNot(HaveOccurred())
return out
}
BeforeEach(func() {
var err error
fileFLAC, err = os.ReadFile("tests/fixtures/test.flac")
Expect(err).ToNot(HaveOccurred())
Expect(readSampleRate(fileFLAC)).To(Equal(44100)) // specs below hard-code this rate
})
It("fills in total_samples from the duration", func() {
out := readAll(pipedFLAC(), 1.0)
Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
})
It("takes the sample rate from the header, not from the source file", func() {
in := pipedFLAC()
// Rewrite the header's rate to 48000, as -ar would.
in[18], in[19] = 0x0B, 0xB8
in[20] &= 0x0F
out := readAll(in, 2.0)
Expect(readSampleRate(out)).To(Equal(48000))
Expect(readTotalSamples(out)).To(Equal(uint64(96000)))
})
It("rounds to the nearest sample rather than truncating", func() {
// float32(0.7)*44100 is 30869.9995, so truncation would lose a sample.
out := readAll(pipedFLAC(), 0.7)
Expect(readTotalSamples(out)).To(Equal(uint64(30870)))
})
It("passes through when the duration overflows the 36-bit field", func() {
in := pipedFLAC()
Expect(readAll(in, 2e6)).To(Equal(in))
})
It("leaves everything after the header untouched", func() {
in := pipedFLAC()
out := readAll(in, 1.0)
Expect(out).To(HaveLen(len(in)))
Expect(out[26:]).To(Equal(in[26:]))
Expect(out[:18]).To(Equal(in[:18]))
})
It("leaves an already-populated total_samples alone", func() {
out := readAll(fileFLAC, 99.0)
Expect(out).To(Equal(fileFLAC))
})
It("passes through a stream that is not FLAC", func() {
in := []byte("ID3\x04\x00\x00\x00\x00\x00\x00 not a flac stream at all, just bytes")
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through when the first metadata block is not STREAMINFO", func() {
in := pipedFLAC()
in[4] = 0x04 // VORBIS_COMMENT
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through a stream shorter than the STREAMINFO fields it patches", func() {
in := pipedFLAC()[:20]
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through an empty stream", func() {
Expect(readAll(nil, 1.0)).To(BeEmpty())
})
It("passes through when the duration is zero or negative", func() {
in := pipedFLAC()
Expect(readAll(in, 0)).To(Equal(in))
Expect(readAll(in, -5)).To(Equal(in))
})
It("passes through when the header declares no sample rate", func() {
in := pipedFLAC()
in[18], in[19] = 0, 0
in[20] &= 0x0F
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("propagates a read error from the underlying stream", func() {
_, err := io.ReadAll(patchFLACDuration(io.NopCloser(io.MultiReader(
bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0))
Expect(err).To(MatchError("boom"))
})
It("closes the underlying stream", func() {
c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())}
Expect(patchFLACDuration(c, 1.0).Close()).To(Succeed())
Expect(c.closed).To(BeTrue())
})
})
type errReader struct{}
func (e *errReader) Read([]byte) (int, error) { return 0, errors.New("boom") }
type closeSpy struct {
io.Reader
closed bool
}
func (c *closeSpy) Close() error { c.closed = true; return nil }

View File

@ -2,7 +2,6 @@ package core
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
@ -191,7 +190,7 @@ func (r *libraryRepositoryWrapper) Save(entity any) (string, error) {
return strconv.Itoa(lib.ID), nil
}
func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) error {
func (r *libraryRepositoryWrapper) Update(id string, entity any, cols ...string) error {
lib := entity.(*model.Library)
libID, err := strconv.Atoi(id)
if err != nil {
@ -211,7 +210,7 @@ func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) er
pathChanged := originalLib.Path != lib.Path
err = r.LibraryRepository.Put(lib)
err = r.LibraryRepository.Put(lib, cols...)
if err != nil {
return r.mapError(err)
}
@ -307,14 +306,7 @@ func (r *libraryRepositoryWrapper) mapError(err error) error {
}
}
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
return err
}
func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error {

View File

@ -188,6 +188,15 @@ var _ = Describe("Library Service", func() {
Expect(libraryRepo.Data[1].Path).To(Equal(newTempDir))
})
It("forwards the columns sent by the client to the repository", func() {
library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir}
err := repo.Update("1", library, "name", "path")
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.PutCols).To(Equal([]string{"name", "path"}))
})
It("fails when library doesn't exist", func() {
// Create a unique temporary directory to avoid path conflicts
uniqueTempDir, err := os.MkdirTemp("", "navidrome-nonexistent-")

View File

@ -2,6 +2,7 @@ package core
import (
"context"
"errors"
"fmt"
"slices"
"sync"
@ -14,11 +15,23 @@ import (
"github.com/navidrome/navidrome/utils/slice"
)
var (
// ErrNotMissing is returned when a remap is attempted from a file not marked as missing.
ErrNotMissing = errors.New("file is not marked as missing")
// ErrTargetMissing is returned when the remap target is itself a missing file.
ErrTargetMissing = errors.New("target file is missing")
// ErrSameFile is returned when the remap source and target are the same file.
ErrSameFile = errors.New("missing and target are the same file")
)
type Maintenance interface {
// DeleteMissingFiles deletes specific missing files by their IDs
DeleteMissingFiles(ctx context.Context, ids []string) error
// DeleteAllMissingFiles deletes all files marked as missing
DeleteAllMissingFiles(ctx context.Context) error
// RemapMissingFile moves a missing file's identity onto an existing file, the manual
// counterpart to the scanner's move detection (phaseMissingTracks.moveMatched).
RemapMissingFile(ctx context.Context, missingID, targetID string) error
}
type maintenanceService struct {
@ -40,6 +53,87 @@ func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error {
return s.deleteMissing(ctx, nil)
}
func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, targetID string) error {
if missingID == targetID {
return fmt.Errorf("%w: %q", ErrSameFile, missingID)
}
missing, err := s.ds.MediaFile(ctx).Get(missingID)
if err != nil {
return fmt.Errorf("loading missing file %q: %w", missingID, err)
}
if !missing.Missing {
return fmt.Errorf("%w: %q", ErrNotMissing, missingID)
}
target, err := s.ds.MediaFile(ctx).GetWithParticipants(targetID)
if err != nil {
return fmt.Errorf("loading target file %q: %w", targetID, err)
}
if target.Missing {
return fmt.Errorf("%w: %q", ErrTargetMissing, targetID)
}
oldAlbumID, newAlbumID := missing.AlbumID, target.AlbumID
err = s.ds.WithTx(func(tx model.DataStore) error {
discardedID := target.ID
// Preserve the original created_at so the remapped track doesn't resurface in "Recently Added"
target.CreatedAt = missing.CreatedAt
target.ID = missing.ID
if err := tx.MediaFile(ctx).Put(target); err != nil {
return fmt.Errorf("update matched track: %w", err)
}
// Unlike the scanner's freshly-imported target, this one may carry history of its own
if err := tx.MediaFile(ctx).ReassignReferences(discardedID, missing.ID); err != nil {
return fmt.Errorf("reassign target references: %w", err)
}
if err := tx.MediaFile(ctx).Delete(discardedID); err != nil {
return fmt.Errorf("delete discarded track: %w", err)
}
if oldAlbumID != newAlbumID {
oldAlbumTracks, err := tx.MediaFile(ctx).CountAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": oldAlbumID}})
if err != nil {
return fmt.Errorf("get old album tracks: %w", err)
}
if oldAlbumTracks == 0 {
if err := tx.Album(ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil {
return fmt.Errorf("reassign album annotations: %w", err)
}
if err := tx.Album(ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil && !errors.Is(err, model.ErrNotFound) {
return fmt.Errorf("copy album attributes: %w", err)
}
}
}
return nil
})
if err != nil {
log.Error(ctx, "Error remapping missing file", "missing", missing.Path, "target", target.Path, err)
return err
}
if err := s.ds.GC(ctx); err != nil {
log.Error(ctx, "Error running GC after remapping missing file", err)
return err
}
// Stats are refreshed synchronously, unlike deleteMissing, so the CLI sees them before it exits.
// album/artist play count aggregates are not recalculated here; they are refreshed by the next scan.
if _, err := s.ds.Artist(ctx).RefreshStats(true); err != nil {
log.Error(ctx, "Error refreshing artist stats after remapping missing file", err)
}
affectedAlbumIDs := []string{newAlbumID}
if oldAlbumID != newAlbumID {
affectedAlbumIDs = append(affectedAlbumIDs, oldAlbumID)
}
if err := s.refreshAlbums(ctx, affectedAlbumIDs); err != nil {
log.Error(ctx, "Error refreshing album stats after remapping missing file", err)
}
return nil
}
// deleteMissing handles the deletion of missing files and triggers necessary cleanup operations
func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error {
// Track affected album IDs before deletion for refresh
@ -68,7 +162,8 @@ func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) er
return err
}
// Refresh statistics in background
// Refresh statistics in background. album/artist play count aggregates are not recalculated
// here; they are refreshed by the next scan.
s.refreshStatsAsync(ctx, affectedAlbumIDs)
return nil

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"sync"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -250,6 +251,167 @@ var _ = Describe("Maintenance", func() {
})
})
})
Describe("RemapMissingFile", func() {
It("relocates the missing file's identity onto the target and runs GC", func() {
created := time.Now().Add(-30 * 24 * time.Hour)
mfRepo.SetData(model.MediaFiles{
{ID: "m1", Path: "old/song.mp3", AlbumID: "album1", CreatedAt: created, Missing: true},
{ID: "t1", Path: "new/song.mp3", AlbumID: "album1", Missing: false},
})
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed())
got, err := mfRepo.Get("m1")
Expect(err).ToNot(HaveOccurred())
Expect(got.Path).To(Equal("new/song.mp3")) // moved to target's location
Expect(got.Missing).To(BeFalse())
Expect(got.CreatedAt).To(BeTemporally("==", created)) // created_at preserved
exists, _ := mfRepo.Exists("t1")
Expect(exists).To(BeFalse()) // discarded row removed
Expect(ds.GCCalled).To(BeTrue())
})
It("moves the target's annotations, bookmarks and playlist entries onto the surviving id", func() {
mfRepo.SetData(model.MediaFiles{
{ID: "m1", AlbumID: "album1", Missing: true},
{ID: "t1", AlbumID: "album1", Missing: false},
})
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed())
Expect(mfRepo.ReassignReferencesCalls).To(HaveKeyWithValue("t1", "m1"))
})
It("reassigns album annotations when the old album is emptied", func() {
albumRepo := ds.MockedAlbum.(*extendedAlbumRepo)
mfRepo.SetData(model.MediaFiles{
{ID: "m1", AlbumID: "album1", Missing: true},
{ID: "t1", AlbumID: "album2", Missing: false},
})
mfRepo.SetCountAll(0)
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed())
Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue("album1", "album2"))
})
It("does not reassign album annotations when the old album is not emptied", func() {
albumRepo := ds.MockedAlbum.(*extendedAlbumRepo)
mfRepo.SetData(model.MediaFiles{
{ID: "m1", AlbumID: "album1", Missing: true},
{ID: "m2", AlbumID: "album1", Missing: false},
{ID: "t1", AlbumID: "album2", Missing: false},
})
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed())
Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty())
})
It("does not reassign annotations when the album is unchanged", func() {
albumRepo := ds.MockedAlbum.(*extendedAlbumRepo)
mfRepo.SetData(model.MediaFiles{
{ID: "m1", AlbumID: "album1", Missing: true},
{ID: "t1", AlbumID: "album1", Missing: false},
})
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed())
Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty())
})
It("returns ErrNotFound when the missing file does not exist", func() {
mfRepo.SetData(model.MediaFiles{{ID: "t1", Missing: false}})
Expect(service.RemapMissingFile(ctx, "nope", "t1")).To(MatchError(model.ErrNotFound))
})
It("refuses to remap from a file that is not missing", func() {
mfRepo.SetData(model.MediaFiles{
{ID: "m1", Missing: false},
{ID: "t1", Missing: false},
})
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ErrNotMissing))
})
It("refuses to remap a file onto itself", func() {
mfRepo.SetData(model.MediaFiles{{ID: "m1", Missing: true}})
Expect(service.RemapMissingFile(ctx, "m1", "m1")).To(MatchError(ErrSameFile))
})
It("refuses to remap onto a target that is itself missing", func() {
mfRepo.SetData(model.MediaFiles{
{ID: "m1", Missing: true},
{ID: "t1", Missing: true},
})
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ErrTargetMissing))
})
It("refreshes artist and album stats right after the remap", func() {
artistRepo := ds.MockedArtist.(*extendedArtistRepo)
albumRepo := ds.MockedAlbum.(*extendedAlbumRepo)
albumRepo.SetData(model.Albums{
{ID: "album1", Name: "Old Album", SongCount: 2, Size: 1100, Duration: 110},
{ID: "album2", Name: "New Album", SongCount: 1, Size: 2000, Duration: 200},
})
mfRepo.SetData(model.MediaFiles{
{ID: "m1", Path: "old/1.mp3", Album: "Old Album", AlbumID: "album1", Missing: true, Size: 100, Duration: 10},
{ID: "k1", Path: "old/2.mp3", Album: "Old Album", AlbumID: "album1", Missing: false, Size: 1000, Duration: 100},
{ID: "t1", Path: "new/1.mp3", Album: "New Album", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200},
})
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed())
Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed")
// The old album lost the remapped track, so its stats are recalculated from the remaining one
oldAlbum, err := albumRepo.Get("album1")
Expect(err).ToNot(HaveOccurred())
Expect(oldAlbum.SongCount).To(Equal(1))
Expect(oldAlbum.Size).To(Equal(int64(1000)))
Expect(oldAlbum.Duration).To(BeNumerically("==", 100))
// The target album keeps the track, now under the missing file's ID
newAlbum, err := albumRepo.Get("album2")
Expect(err).ToNot(HaveOccurred())
Expect(newAlbum.SongCount).To(Equal(1))
Expect(newAlbum.Size).To(Equal(int64(2000)))
Expect(newAlbum.Duration).To(BeNumerically("==", 200))
})
It("returns an error if GC fails", func() {
mfRepo.SetData(model.MediaFiles{
{ID: "m1", AlbumID: "album1", Missing: true},
{ID: "t1", AlbumID: "album1", Missing: false},
})
ds.GCError = errors.New("gc failed")
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ContainSubstring("gc failed")))
})
It("preserves the target's participants on the remapped track", func() {
participant := model.Participant{
Artist: model.Artist{ID: "a1", Name: "Artist", OrderArtistName: "artist", MbzArtistID: "mbz-artist"},
}
mfRepo.SetData(model.MediaFiles{
{ID: "m1", AlbumID: "album1", Missing: true},
{ID: "t1", AlbumID: "album2", Missing: false, Participants: model.Participants{
model.RoleArtist: model.ParticipantList{participant},
}},
})
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed())
// The surviving row is the missing file's ID, holding the target's data
got, err := mfRepo.GetWithParticipants("m1")
Expect(err).ToNot(HaveOccurred())
Expect(got.Participants).To(HaveKeyWithValue(model.RoleArtist, model.ParticipantList{participant}))
})
})
})
// Test helper to create a mock DataStore with controllable behavior

View File

@ -26,6 +26,7 @@ import (
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/singleton"
)
@ -95,9 +96,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) {
log.Trace(ctx, "No users found, skipping Insights data collection")
return
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
data := c.collect(ctx)
if data == nil {
return
@ -199,7 +198,7 @@ var staticData = sync.OnceValue(func() insights.Data {
// Config info
data.Config.LogLevel = conf.Server.LogLevel
data.Config.LogFileConfigured = conf.Server.LogFile != ""
data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != ""
data.Config.TLSConfigured = conf.Server.TLSEnabled()
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload

View File

@ -100,6 +100,7 @@ func (pd *Queue) Shuffle() {
backupID = current.ID
}
//nolint:gosec // shuffle order is not a security decision
rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] })
var err error

View File

@ -38,7 +38,7 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip
user, _ := request.UserFrom(ctx)
if playerID != "" {
plr, err = p.ds.Player(ctx).Get(playerID)
if err == nil && plr.Client != client {
if err == nil && (plr.Client != client || plr.UserId != user.ID) {
playerID = ""
}
}

View File

@ -61,8 +61,19 @@ var _ = Describe("Players", func() {
Expect(trc).To(BeNil())
})
It("does not reuse another user's player by ID", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "otheruser", UserAgent: "Pixel", TranscodingId: "1"}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).ToNot(Equal("123"))
Expect(p.UserId).To(Equal("userid"))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc).To(BeNil())
})
It("finds players by ID", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}}
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
@ -93,7 +104,7 @@ var _ = Describe("Players", func() {
})
It("finds player by ID and return its transcoding", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"}
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}, TranscodingId: "1"}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())

View File

@ -236,6 +236,24 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.ExternalImageURL).To(BeEmpty())
})
It("rejects #EXTALBUMARTURL pointing at a non-image file inside the library", func() {
tmpDir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(tmpDir, "config.ini"), []byte("password=secret"), 0600)).To(Succeed())
m3u := "#EXTALBUMARTURL:config.ini\ntest.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
It("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() {
conf.Server.EnableM3UExternalAlbumArt = false
@ -1011,6 +1029,19 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.ExternalImageURL).To(BeEmpty())
})
DescribeTable("restricts a local #EXTALBUMARTURL to the owner's libraries",
func(imageURL, expected string) {
ctx = request.WithUser(ctx, model.User{ID: "123", Libraries: model.Libraries{{ID: 1, Path: "/music"}}})
repo.data = []string{"tests/test.mp3"}
m3u := "#EXTALBUMARTURL:" + imageURL + "\n/music/tests/test.mp3\n"
pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(expected))
},
Entry("accepts a library the owner can access", "file:///music/cover.jpg", filepath.Clean("/music/cover.jpg")),
Entry("ignores a library the owner cannot access", "file:///new/cover.jpg", ""),
)
// Fullwidth characters (e.g., ABCD) are not handled by SQLite's NOCASE collation,
// so we need exact matching for non-ASCII characters.
It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() {

View File

@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
"golang.org/x/text/unicode/norm"
)
@ -25,8 +26,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
return err
}
var mfs model.MediaFiles
// Chunk size of 100 lines, as each line can generate up to 4 lookup candidates
// (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000.
// Chunked so a huge playlist is not held in memory at once. Each line yields up to
// 4 lookup candidates (NFC/NFD × raw/lowercase), far below SQLite's 32766 variables.
for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) {
filteredLines := make([]string, 0, len(lines))
for _, line := range lines {
@ -36,7 +37,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
continue
}
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher)
owner, _ := request.UserFrom(ctx)
pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher, owner)
continue
}
// Skip empty lines and extended info
@ -286,7 +288,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt).
// Local paths (file://, absolute, or relative) are resolved to an absolute path
// and validated against known library boundaries via matcher.
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher) string {
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
@ -302,12 +304,13 @@ func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher
// Resolve to local absolute path
localPath, ok := resolveLocalPath(value, folder)
if !ok {
if !ok || !model.IsImageFile(localPath) {
return ""
}
// Validate path is within a known library
if libID, _ := matcher.findLibraryForPath(localPath); libID == 0 {
lib, ok := matcher.findLibrary(localPath)
// A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries.
if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) {
return ""
}
return localPath

View File

@ -32,6 +32,7 @@ type Playlists interface {
// Track management
AddTracks(ctx context.Context, playlistID string, ids []string) (int, error)
InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error)
AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error)
AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error)
AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error)
@ -268,6 +269,22 @@ func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []stri
return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids)
}
// InsertTracks adds tracks before the 1-based position pos; a position past the end appends.
func (s *playlists) InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
var count int
// Immediate: a deferred tx that reads before shifting fails at once with SQLITE_BUSY under
// concurrent writers instead of waiting for the lock.
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
var err error
count, err = tx.Playlist(ctx).Tracks(playlistID, false).Insert(ids, pos)
return err
})
return count, err
}
func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
@ -302,7 +319,7 @@ func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return err
}
return s.ds.WithTx(func(tx model.DataStore) error {
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
})
}

View File

@ -324,6 +324,43 @@ var _ = Describe("Playlists", func() {
})
})
Describe("InsertTracks", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
})
It("inserts the tracks at the given position for the owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
count, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1", "song-2"}, 3)
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(2))
Expect(mockTracks.AddedIds).To(Equal([]string{"song-1", "song-2"}))
Expect(mockTracks.InsertPos).To(Equal(3))
})
It("denies non-owner, non-admin", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
_, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1"}, 1)
Expect(err).To(MatchError(model.ErrNotAuthorized))
Expect(mockTracks.AddedIds).To(BeEmpty())
})
It("denies editing smart playlists", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.InsertTracks(ctx, "pls-smart", []string{"song-1"}, 1)
Expect(err).To(MatchError(model.ErrPlaylistNotEditable))
})
})
Describe("ReorderTrack", func() {
var mockTracks *tests.MockPlaylistTrackRepo

View File

@ -2,7 +2,6 @@ package playlists
import (
"context"
"errors"
"reflect"
"strings"
@ -41,15 +40,7 @@ func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string
}
func (r *playlistRepositoryWrapper) Delete(id string) error {
err := r.service.Delete(r.ctx, id)
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
return r.service.Delete(r.ctx, id)
}
func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository {
@ -92,14 +83,7 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
current, err := s.checkWritable(ctx, id)
if err != nil {
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
return err
}
sent := sentFields(cols)

View File

@ -2,7 +2,7 @@ package publicurl
import (
"cmp"
"net/http"
"context"
"net/url"
"path"
"strconv"
@ -13,35 +13,36 @@ import (
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/gg"
)
// ImageURL generates a public URL for artwork images.
// It creates a signed token for the artwork ID and builds a complete public URL.
func ImageURL(req *http.Request, artID model.ArtworkID, size int) string {
func ImageURL(ctx context.Context, artID model.ArtworkID, size int) string {
token, _ := auth.CreatePublicToken(auth.Claims{ID: artID.String()})
uri := path.Join(consts.URLPathPublicImages, token)
params := url.Values{}
if size > 0 {
params.Add("size", strconv.Itoa(size))
}
return PublicURL(req, uri, params)
return PublicURL(ctx, uri, params)
}
// PublicURL builds a full URL for public-facing resources.
// It uses ShareURL from config if available, otherwise falls back to extracting
// the scheme and host from the provided http.Request.
// If req is nil and ShareURL is not set, it defaults to http://localhost.
func PublicURL(req *http.Request, u string, params url.Values) string {
// It uses ShareURL from config if available, otherwise falls back to the address the
// client used to reach the server, recorded in the context.
func PublicURL(ctx context.Context, u string, params url.Values) string {
if conf.Server.ShareURL == "" {
return AbsoluteURL(req, u, params)
return AbsoluteURL(ctx, u, params)
}
shareUrl, err := url.Parse(conf.Server.ShareURL)
if err != nil {
return AbsoluteURL(req, u, params)
return AbsoluteURL(ctx, u, params)
}
buildUrl, err := url.Parse(u)
if err != nil {
return AbsoluteURL(req, u, params)
return AbsoluteURL(ctx, u, params)
}
buildUrl.Scheme = shareUrl.Scheme
buildUrl.Host = shareUrl.Host
@ -55,13 +56,12 @@ func PublicURL(req *http.Request, u string, params url.Values) string {
}
// AbsoluteURL builds an absolute URL from a relative path.
// It uses BaseHost/BaseScheme from config if available, otherwise extracts
// the scheme and host from the http.Request.
// If req is nil and BaseHost is not set, it defaults to http://localhost.
func AbsoluteURL(req *http.Request, u string, params url.Values) string {
// It uses BaseHost/BaseScheme from config if available, otherwise the address the client
// used to reach the server, recorded in the context by the server's address middleware.
func AbsoluteURL(ctx context.Context, u string, params url.Values) string {
buildUrl, err := url.Parse(u)
if err != nil {
log.Error(req.Context(), "Failed to parse URL path", "url", u, err)
log.Error(ctx, "Failed to parse URL path", "url", u, err)
return ""
}
if strings.HasPrefix(u, "/") {
@ -69,12 +69,13 @@ func AbsoluteURL(req *http.Request, u string, params url.Values) string {
if conf.Server.BaseHost != "" {
buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http")
buildUrl.Host = conf.Server.BaseHost
} else if req != nil {
buildUrl.Scheme = req.URL.Scheme
buildUrl.Host = req.Host
} else if scheme, host, ok := request.ServerAddressFrom(ctx); ok {
buildUrl.Scheme = scheme
buildUrl.Host = host
} else {
buildUrl.Scheme = "http"
buildUrl.Host = "localhost"
log.Debug(ctx, "Building a public URL with no public address available; set ShareURL to make it reachable", "url", u)
buildUrl.Scheme = gg.If(conf.Server.TLSEnabled(), "https", "http")
buildUrl.Host = "localhost:" + strconv.Itoa(conf.Server.Port)
}
}
if len(params) > 0 {

View File

@ -1,7 +1,7 @@
package publicurl_test
import (
"net/http"
"context"
"net/url"
"testing"
@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/core/publicurl"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -36,24 +37,17 @@ var _ = Describe("Public URL Utilities", func() {
})
It("uses ShareURL as the base", func() {
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
result := publicurl.PublicURL(r, "/path/to/resource", nil)
result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil)
Expect(result).To(Equal("https://share.example.com/path/to/resource"))
})
It("includes query parameters", func() {
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
params := url.Values{"size": []string{"300"}, "format": []string{"png"}}
result := publicurl.PublicURL(r, "/image/123", params)
result := publicurl.PublicURL(context.Background(), "/image/123", params)
Expect(result).To(ContainSubstring("https://share.example.com/image/123"))
Expect(result).To(ContainSubstring("size=300"))
Expect(result).To(ContainSubstring("format=png"))
})
It("works without a request", func() {
result := publicurl.PublicURL(nil, "/path/to/resource", nil)
Expect(result).To(Equal("https://share.example.com/path/to/resource"))
})
})
When("ShareURL includes a path", func() {
@ -62,21 +56,19 @@ var _ = Describe("Public URL Utilities", func() {
})
It("prepends the ShareURL path to the resource", func() {
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
result := publicurl.PublicURL(r, "/share/img/hash", nil)
result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil)
Expect(result).To(Equal("https://example.com/navi/share/img/hash"))
})
It("prepends the ShareURL path and includes query parameters", func() {
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
params := url.Values{"size": []string{"600"}}
result := publicurl.PublicURL(r, "/share/img/hash", params)
result := publicurl.PublicURL(context.Background(), "/share/img/hash", params)
Expect(result).To(Equal("https://example.com/navi/share/img/hash?size=600"))
})
It("handles trailing slash in ShareURL path", func() {
conf.Server.ShareURL = "https://example.com/navi/"
result := publicurl.PublicURL(nil, "/share/img/hash", nil)
result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil)
Expect(result).To(Equal("https://example.com/navi/share/img/hash"))
})
})
@ -87,15 +79,15 @@ var _ = Describe("Public URL Utilities", func() {
})
It("falls back to AbsoluteURL with request", func() {
r, _ := http.NewRequest("GET", "https://myserver.com/test", nil)
r.Host = "myserver.com"
result := publicurl.PublicURL(r, "/path/to/resource", nil)
ctx := request.WithServerAddress(context.Background(), "https", "myserver.com")
result := publicurl.PublicURL(ctx, "/path/to/resource", nil)
Expect(result).To(Equal("https://myserver.com/path/to/resource"))
})
It("falls back to localhost without request", func() {
result := publicurl.PublicURL(nil, "/path/to/resource", nil)
Expect(result).To(Equal("http://localhost/path/to/resource"))
It("falls back to localhost on the configured port without request", func() {
conf.Server.Port = 4533
result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil)
Expect(result).To(Equal("http://localhost:4533/path/to/resource"))
})
})
})
@ -109,15 +101,13 @@ var _ = Describe("Public URL Utilities", func() {
})
It("uses BaseHost and BaseScheme", func() {
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil)
Expect(result).To(Equal("https://configured.example.com/path/to/resource"))
})
It("defaults to http scheme if BaseScheme is empty", func() {
conf.Server.BaseScheme = ""
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil)
Expect(result).To(Equal("http://configured.example.com/path/to/resource"))
})
})
@ -129,15 +119,30 @@ var _ = Describe("Public URL Utilities", func() {
})
It("extracts host from request", func() {
r, _ := http.NewRequest("GET", "https://request.example.com/test", nil)
r.Host = "request.example.com"
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
ctx := request.WithServerAddress(context.Background(), "https", "request.example.com")
result := publicurl.AbsoluteURL(ctx, "/path/to/resource", nil)
Expect(result).To(Equal("https://request.example.com/path/to/resource"))
})
It("falls back to localhost without request", func() {
result := publicurl.AbsoluteURL(nil, "/path/to/resource", nil)
Expect(result).To(Equal("http://localhost/path/to/resource"))
It("falls back to localhost on the configured port without request", func() {
conf.Server.Port = 8080
result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil)
Expect(result).To(Equal("http://localhost:8080/path/to/resource"))
})
It("uses https in the fallback when TLS is configured", func() {
conf.Server.Port = 4533
conf.Server.TLSCert = "cert.pem"
conf.Server.TLSKey = "key.pem"
result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil)
Expect(result).To(Equal("https://localhost:4533/path/to/resource"))
})
It("stays on http when only the certificate is configured", func() {
conf.Server.Port = 4533
conf.Server.TLSCert = "cert.pem"
result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil)
Expect(result).To(Equal("http://localhost:4533/path/to/resource"))
})
})
@ -149,24 +154,21 @@ var _ = Describe("Public URL Utilities", func() {
})
It("prepends BasePath to the URL", func() {
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil)
Expect(result).To(Equal("https://example.com/navidrome/path/to/resource"))
})
})
It("passes through absolute URLs unchanged", func() {
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
result := publicurl.AbsoluteURL(r, "https://other.example.com/path", nil)
result := publicurl.AbsoluteURL(context.Background(), "https://other.example.com/path", nil)
Expect(result).To(Equal("https://other.example.com/path"))
})
It("includes query parameters", func() {
conf.Server.BaseHost = "example.com"
conf.Server.BaseScheme = "https"
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
params := url.Values{"key": []string{"value"}}
result := publicurl.AbsoluteURL(r, "/path", params)
result := publicurl.AbsoluteURL(context.Background(), "/path", params)
Expect(result).To(Equal("https://example.com/path?key=value"))
})
})
@ -180,20 +182,51 @@ var _ = Describe("Public URL Utilities", func() {
It("generates a URL with the artwork token", func() {
artID := model.NewArtworkID(model.KindAlbumArtwork, "album-123", nil)
result := publicurl.ImageURL(nil, artID, 0)
result := publicurl.ImageURL(context.Background(), artID, 0)
Expect(result).To(HavePrefix("https://share.example.com/share/img/"))
})
It("includes size parameter when provided", func() {
artID := model.NewArtworkID(model.KindArtistArtwork, "artist-1", nil)
result := publicurl.ImageURL(nil, artID, 300)
result := publicurl.ImageURL(context.Background(), artID, 300)
Expect(result).To(ContainSubstring("size=300"))
})
It("omits size parameter when zero", func() {
artID := model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil)
result := publicurl.ImageURL(nil, artID, 0)
result := publicurl.ImageURL(context.Background(), artID, 0)
Expect(result).ToNot(ContainSubstring("size="))
})
})
Describe("ImageURL address precedence", func() {
var artID model.ArtworkID
BeforeEach(func() {
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
artID = model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil)
})
It("uses the address of the request that triggered the call", func() {
ctx := request.WithServerAddress(context.Background(), "https", "music.example.com")
result := publicurl.ImageURL(ctx, artID, 300)
Expect(result).To(HavePrefix("https://music.example.com/share/img/"))
Expect(result).To(ContainSubstring("size=300"))
})
It("prefers ShareURL over the address in the context", func() {
conf.Server.ShareURL = "https://share.example.com"
ctx := request.WithServerAddress(context.Background(), "https", "music.example.com")
result := publicurl.ImageURL(ctx, artID, 0)
Expect(result).To(HavePrefix("https://share.example.com/share/img/"))
})
It("falls back to localhost on the configured port when no address is available", func() {
conf.Server.Port = 4533
result := publicurl.ImageURL(context.Background(), artID, 0)
Expect(result).To(HavePrefix("http://localhost:4533/share/img/"))
})
})
})

View File

@ -0,0 +1,203 @@
// Package quickconnect implements Jellyfin-style Quick Connect: a new client shows a short code, and
// an already signed-in user approves it, so the client can sign in without a password.
package quickconnect
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/random"
"github.com/navidrome/navidrome/utils/singleton"
)
const timeout = 10 * time.Minute
// Initiate is unauthenticated, so the number of live requests must be bounded.
const maxPending = 1000
var (
ErrAlreadyAuthorized = errors.New("quick connect request already authorized")
ErrTooManyRequests = errors.New("too many pending quick connect requests")
)
type Device struct {
ID string
Name string
App string
AppVersion string
}
type Request struct {
Device Device
Secret string
Code string
DateAdded time.Time
UserID string
}
func (r Request) Authorized() bool {
return r.UserID != ""
}
type QuickConnect interface {
Initiate(device Device) (Request, error)
Status(secret string) (Request, error)
// Lookup returns a request still waiting for approval.
Lookup(code string) (Request, error)
Authorize(code, userID string) (Request, error)
// Redeem returns the id of the user who approved the request. It succeeds only once per secret.
Redeem(secret string) (string, error)
}
type entry struct {
Request
expiresAt time.Time
}
type quickConnect struct {
mu sync.Mutex
bySecret map[string]*entry
byCode map[string]*entry
}
// GetInstance returns the shared store: the Jellyfin and native API routers must see the same requests.
func GetInstance() QuickConnect {
return singleton.GetInstance(newStore)
}
func New() QuickConnect {
return newStore()
}
func newStore() *quickConnect {
return &quickConnect{
bySecret: map[string]*entry{},
byCode: map[string]*entry{},
}
}
// Enabled reports whether users can approve codes from the web UI, which needs the Jellyfin API.
func Enabled() bool {
return conf.Server.Jellyfin.Enabled && conf.Server.Jellyfin.QuickConnect
}
func (qc *quickConnect) Initiate(device Device) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
qc.expire()
if len(qc.bySecret) >= maxPending {
return Request{}, ErrTooManyRequests
}
// The fields may be substrings of a much larger header; copy them so the header isn't retained.
device = Device{ID: strings.Clone(device.ID), Name: strings.Clone(device.Name),
App: strings.Clone(device.App), AppVersion: strings.Clone(device.AppVersion)}
now := time.Now()
e := &entry{
Request: Request{Device: device, Secret: newSecret(), Code: qc.newCode(), DateAdded: now},
expiresAt: now.Add(timeout),
}
qc.bySecret[e.Secret] = e
qc.byCode[e.Code] = e
return e.Request, nil
}
func (qc *quickConnect) Status(secret string) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.find(qc.bySecret, secret)
if err != nil {
return Request{}, err
}
return e.Request, nil
}
func (qc *quickConnect) Lookup(code string) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.findPending(code)
if err != nil {
return Request{}, err
}
return e.Request, nil
}
func (qc *quickConnect) Authorize(code, userID string) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.findPending(code)
if err != nil {
return Request{}, err
}
e.UserID = userID
e.expiresAt = time.Now().Add(timeout)
return e.Request, nil
}
func (qc *quickConnect) Redeem(secret string) (string, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.find(qc.bySecret, secret)
if err != nil || !e.Authorized() {
return "", model.ErrNotFound
}
qc.remove(e)
return e.UserID, nil
}
func (qc *quickConnect) find(index map[string]*entry, key string) (*entry, error) {
qc.expire()
e, ok := index[key]
if !ok {
return nil, model.ErrNotFound
}
return e, nil
}
func (qc *quickConnect) findPending(code string) (*entry, error) {
e, err := qc.find(qc.byCode, normalizeCode(code))
if err == nil && e.Authorized() {
return nil, ErrAlreadyAuthorized
}
return e, err
}
func (qc *quickConnect) expire() {
now := time.Now()
for _, e := range qc.bySecret {
if !now.Before(e.expiresAt) {
qc.remove(e)
}
}
}
func (qc *quickConnect) remove(e *entry) {
delete(qc.bySecret, e.Secret)
delete(qc.byCode, e.Code)
}
func (qc *quickConnect) newCode() string {
for {
code := fmt.Sprintf("%06d", random.Int64N(900000)+100000)
if _, taken := qc.byCode[code]; !taken {
return code
}
}
}
func newSecret() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// Users often type the code in groups ("123 456").
func normalizeCode(code string) string {
return strings.Join(strings.Fields(code), "")
}

View File

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

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