Compare commits

..

1 commit

Author SHA1 Message Date
Deluan
1c83f36ddf perf(persistence): make album list pages index-served
The two album list requests reported in discussion #5929 (a 110k-album,
two-library instance) were dominated by work that scaled with the whole
library instead of the page:

- Every native API list request also runs a count for the X-Total-Count
  header. album had no index covering library_id, so that count scanned the
  entire table on every request, dragging each row's JSON blobs through the
  page cache. count(distinct id) additionally built a temp b-tree over the
  full result set even when the query had no join that could duplicate rows.
- A random sort cannot be served by an index, so SQLite evaluated SEEDEDRAND
  for every matching row and pushed each one, with its library and annotation
  joins and full column projection, through a sorter that kept only 36 rows.

Add an album(id, library_id) index so library-filtered counts are served by a
covering index scan, and make the shared count() helper use count(*) when the
rendered query has no join, keeping count(distinct id) wherever a join could
fan out rows. id leads the index deliberately: with library_id leading, the
planner drives sorted list queries off it and sorts an entire library rather
than walking a sort-satisfying index, and fresh statistics do not change that
choice.

Resolve random pages by id before hydrating them, so the sort pass runs on
that covering index with no joins and only the page itself is materialized.
Pages larger than 1000 keep the single-query path. The seeded shuffle is
unchanged: the id query carries the caller's options, so SEEDEDRAND, explicit
seeds, and the reseed-only-at-offset-0 rule all still apply, and the page is
reordered in Go to preserve it.

Also make the album name sort a total order by appending album.id, and widen
album_order_album_name to (order_album_name, order_album_artist_name, id) to
serve it. Without a tiebreaker, equal names made page order plan-dependent,
which can skip or duplicate rows across paginated requests.
2026-08-10 15:41:22 -04:00
824 changed files with 13059 additions and 41698 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.27",
"VARIANT": "1.26",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v24"

View file

@ -68,11 +68,6 @@ runs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
with:
# Runner IPs are shared, so anonymous base image pulls get rate-limited.
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["mirror.gcr.io"]
- name: Extract metadata for Docker image
id: meta

View file

@ -1,60 +0,0 @@
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,19 +34,16 @@ jobs:
}
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-'));
if (!downloadable.length) {
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
const header = `Download the artifacts for this pull request:`;
let body = `${header}\n`;
for (const art of downloadable) {
let body = `Download the artifacts for this pull request:\n`;
for (const art of artifacts) {
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});
// 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));
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
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

@ -68,16 +68,10 @@ jobs:
with:
go-version-file: go.mod
# Keep CI on the same version `make lint` installs, so a clean local run
# cannot turn red in CI just because a new golangci-lint was released.
- name: Resolve golangci-lint version
id: golangci-version
run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT"
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: ${{ steps.golangci-version.outputs.version }}
version: latest
problem-matchers: true
args: --timeout 2m
@ -92,23 +86,8 @@ jobs:
exit 1
fi
- name: Resolve OpenAPI tool versions
id: api-tools
run: echo "key=$(grep -E '^(VACUUM|OAPI_CODEGEN|OASDIFF)_VERSION' Makefile | tr -d ' \n')" >> "$GITHUB_OUTPUT"
- name: Cache OpenAPI tools
uses: actions/cache@v6
with:
path: bin
key: api-tools-${{ runner.os }}-${{ steps.api-tools.outputs.key }}
- name: Lint OpenAPI spec
run: make api-lint
- name: Run go generate
run: |
make api-gen
go generate ./...
run: go generate ./...
- name: Verify no changes from go generate
run: |
git status --porcelain
@ -117,12 +96,6 @@ jobs:
exit 1
fi
- name: Check for breaking OpenAPI changes
if: github.event_name == 'pull_request'
run: |
git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }}
make api-diff API_DIFF_BASE=${{ github.event.pull_request.base.sha }}
validate-migrations:
name: Validate DB migrations
runs-on: ubuntu-latest
@ -158,10 +131,8 @@ jobs:
- name: Download dependencies
run: go mod download
# 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
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
- name: Test ndpgen
run: |
@ -170,84 +141,6 @@ 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
@ -314,12 +207,12 @@ jobs:
run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
- name: Test ndpgen
shell: bash
shell: pwsh
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
@ -385,7 +278,7 @@ jobs:
build:
name: Build
needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
needs: [js, go, 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 ]
@ -570,7 +463,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
for artifact in $(gh api repos/${{ github.repository }}/actions/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,8 +43,4 @@ go.work*
.playwright-mcp/
# Temp benchmark files
zz_*_test.go
# wazero compilation cache for the plugins test suite
/plugins/testdata/.wazero-cache/
/plugins/testdata/*.stage/
zz_*_test.go

View file

@ -9,7 +9,6 @@ linters:
- asciicheck
- bidichk
- bodyclose
- containedctx
- copyloopvar
- dogsled
- durationcheck
@ -28,9 +27,6 @@ linters:
disable:
- staticcheck
settings:
errcheck:
exclude-functions:
- (*github.com/zeebo/xxh3.Hasher).Write
gocritic:
disable-all: true
enabled-checks:
@ -58,9 +54,6 @@ linters:
- gosec
path: _test\.go
text: "G703"
- path: _test\.go
linters:
- containedctx
- path-except: 'db/migrations/'
linters:
- forbidigo

View file

@ -1,44 +0,0 @@
# 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.22 AS xx-build
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
# v1.9.0
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
########################################################################################################################
### Build Navidrome UI
FROM --platform=$BUILDPLATFORM node:lts-alpine AS ui
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
WORKDIR /app
# Install node dependencies
@ -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.27-alpine AS build-alpine
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-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.27-trixie AS base
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base
RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / /
WORKDIR /workspace
@ -152,52 +152,19 @@ 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.22 AS final
FROM public.ecr.aws/docker/library/alpine:3.20 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
# 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 && \
RUN apk add -U --no-cache 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 && \
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
done
# Copy navidrome binary (musl build for Docker, enables native libwebp)
COPY --from=build-alpine /out/navidrome /app/

View file

@ -20,11 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
GOLANGCI_LINT_VERSION ?= v2.14.0
VACUUM_VERSION ?= v0.30.6
OAPI_CODEGEN_VERSION ?= v2.8.0
OASDIFF_VERSION ?= v1.32.1
API_DIFF_BASE ?= origin/master
GOLANGCI_LINT_VERSION ?= v2.12.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
@ -96,45 +92,6 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
fi
.PHONY: install-golangci-lint
install-api-tools: ##@Development Install OpenAPI tools (vacuum, oapi-codegen, oasdiff) into ./bin
@STAMP=bin/.api-tools-$(VACUUM_VERSION)-$(OAPI_CODEGEN_VERSION)-$(OASDIFF_VERSION); \
if [ ! -f $$STAMP ] || [ ! -x bin/vacuum ] || [ ! -x bin/oapi-codegen ] || [ ! -x bin/oasdiff ]; then \
echo "Installing OpenAPI tools..."; \
GOBIN=$(CURDIR)/bin go install github.com/daveshanley/vacuum@$(VACUUM_VERSION) && \
GOBIN=$(CURDIR)/bin go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) && \
GOBIN=$(CURDIR)/bin go install github.com/oasdiff/oasdiff@$(OASDIFF_VERSION) && \
rm -f bin/.api-tools-* && touch $$STAMP; \
fi
.PHONY: install-api-tools
api-lint: install-api-tools ##@Development Lint the OpenAPI spec
./bin/vacuum lint -r api/.vacuum.yaml -d -q -b --fail-severity error api/openapi/openapi.yaml
.PHONY: api-lint
api-bundle: install-api-tools ##@Development Bundle the multi-file OpenAPI spec into api/bundled
./bin/vacuum bundle -q --composed -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.yaml
./bin/vacuum bundle -q --composed --format json -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.json
.PHONY: api-bundle
api-gen: api-bundle ##@Development Generate the API v1 server code from the bundled spec
./bin/oapi-codegen -config server/apiv1/oapi-codegen.yaml api/bundled/openapi.json
.PHONY: api-gen
api-diff: api-bundle ##@Development Fail on breaking OpenAPI changes against the merge-base with $(API_DIFF_BASE)
@git rev-parse --verify --quiet $(API_DIFF_BASE)^{commit} >/dev/null || { echo "Base ref $(API_DIFF_BASE) not found; set API_DIFF_BASE"; exit 1; }; \
BASE="$$(git merge-base HEAD $(API_DIFF_BASE) 2>/dev/null)"; \
if [ -z "$$BASE" ]; then \
echo "No merge-base with $(API_DIFF_BASE); falling back to its tip"; \
BASE=$(API_DIFF_BASE); \
fi; \
if git cat-file -e $$BASE:api/bundled/openapi.json 2>/dev/null; then \
git show $$BASE:api/bundled/openapi.json > $(CURDIR)/bin/api-base.json && \
./bin/oasdiff breaking $(CURDIR)/bin/api-base.json api/bundled/openapi.json --fail-on ERR --severity-levels api/.oasdiff-levels.txt; \
else \
echo "No bundled spec at $$BASE; skipping breaking-change check"; \
fi
.PHONY: api-diff
lint: install-golangci-lint ##@Development Lint Go code
PATH=./bin:$$PATH golangci-lint run --timeout 5m
.PHONY: lint
@ -154,7 +111,7 @@ wire: check_go_env ##@Development Update Dependency Injection
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
.PHONY: wire
gen: check_go_env api-gen ##@Development Run go generate for code generation
gen: check_go_env ##@Development Run go generate for code generation
go generate ./...
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types

View file

@ -13,26 +13,15 @@ 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"
// 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)
}
var (
ErrNotFound = errors.New("deezer: not found")
)
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
@ -67,7 +56,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A
}
if len(results.Data) == 0 {
return nil, agents.ErrNotFound
return nil, ErrNotFound
}
return results.Data, nil
}
@ -85,31 +74,20 @@ 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 fmt.Errorf("deezer http status: (%d)", resp.StatusCode)
return c.parseError(data)
}
return json.Unmarshal(data, response)
}
// 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
func (c *client) parseError(data []byte) error {
var deezerError Error
err := json.Unmarshal(data, &deezerError)
if err != nil {
return err
}
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,14 +2,12 @@ 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"
)
@ -43,37 +41,7 @@ var _ = Describe("client", func() {
})
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
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")
Expect(err).To(MatchError(ErrNotFound))
})
})

View file

@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"net/http"
"slices"
"strings"
@ -14,7 +15,6 @@ 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,7 +36,9 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface {
dataStore: dataStore,
languages: conf.Server.Deezer.Languages,
}
httpClient := httpclient.New(consts.DefaultHttpClientTimeOut)
httpClient := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
agent.client = newClient(cachedHttpClient)
return agent
@ -91,6 +93,9 @@ 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,7 +3,6 @@ package deezer
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
@ -81,22 +80,6 @@ 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,8 +22,12 @@ type Artist struct {
Type string `json:"type"`
}
type errorResponse struct {
Error *deezerError `json:"error"`
type Error struct {
Error struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `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 errorResponse
var errorResp Error
body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`)
err := json.Unmarshal(body, &errorResp)
Expect(err).To(BeNil())

View file

@ -90,7 +90,7 @@ var _ = Describe("Extractor", func() {
info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info)
return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
return new(metadata.ToMediaFile(1, "folderID"))
}
BeforeEach(func() {

View file

@ -18,7 +18,6 @@ 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"
)
@ -60,7 +59,9 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
secret: conf.Server.LastFM.Secret,
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.httpClient = chc
l.client = newClient(l.apiKey, l.secret, chc)
@ -241,10 +242,6 @@ 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) {
@ -271,9 +268,7 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
var res []agents.ExternalImage
n := cascadia.Query(node, artistOpenGraphQuery)
if n == 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
return res, nil
}
for _, attr := range n.Attr {
if attr.Key != "content" {
@ -411,8 +406,7 @@ 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)
}
// 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) {
if lfErr.Code == 11 || lfErr.Code == 16 {
return errors.Join(err, scrobbler.ErrRetryLater)
}
return errors.Join(err, scrobbler.ErrUnrecoverable)

View file

@ -100,15 +100,6 @@ 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() {
@ -357,7 +348,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient
var track *model.MediaFile
BeforeEach(func() {
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
@ -506,16 +497,6 @@ 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`)),
@ -648,41 +629,18 @@ var _ = Describe("lastfmAgent", func() {
Expect(images).To(BeEmpty())
})
It("errors when the page has no meta tags", func() {
It("returns empty list if 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).To(MatchError(errNoArtistPage))
Expect(err).ToNot(HaveOccurred())
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,7 +18,6 @@ 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"
)
@ -42,7 +41,9 @@ func NewRouter(ds model.DataStore) *Router {
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
r.Handler = r.routes()
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
r.client = newClient(r.apiKey, r.secret, hc)
return r
}
@ -132,7 +133,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,
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token,
"requestId", middleware.GetReqID(ctx), err)
return err
}

View file

@ -50,7 +50,7 @@ var _ = Describe("auth_router", func() {
})
storedSessionKey := func(userID string) string {
key, _ := userProps.Get(GinkgoT().Context(), userID, sessionKeyProperty)
key, _ := userProps.Get(userID, sessionKeyProperty)
return key
}
@ -214,14 +214,5 @@ var _ = Describe("auth_router", func() {
_, err = verifyLinkToken(nonExpiringToken)
Expect(err).To(MatchError("link token missing expiration"))
})
It("rejects a Jellyfin access token", func() {
usr := &model.User{ID: "u1", UserName: "johndoe"}
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
_, err = verifyLinkToken(tokenStr)
Expect(err).To(HaveOccurred())
})
})
})

View file

@ -5,7 +5,6 @@ import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@ -15,15 +14,11 @@ 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 {
@ -49,12 +44,6 @@ 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")
@ -72,7 +61,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", escapePlus(name))
params.Add("artist", name)
params.Add("lang", lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
@ -84,7 +73,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", escapePlus(name))
params.Add("artist", name)
params.Add("limit", strconv.Itoa(limit))
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
@ -96,7 +85,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", escapePlus(name))
params.Add("artist", name)
params.Add("limit", strconv.Itoa(limit))
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
@ -108,8 +97,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", escapePlus(name))
params.Add("artist", escapePlus(artist))
params.Add("track", name)
params.Add("artist", artist)
params.Add("limit", strconv.Itoa(limit))
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
@ -236,11 +225,7 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
return nil, jsonErr
}
if response.Error != 0 {
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, &lastFMError{Code: response.Error, Message: response.Message}
}
return &response, nil

View file

@ -35,15 +35,6 @@ 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() {
@ -57,15 +48,6 @@ 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`)),
@ -125,15 +107,6 @@ 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() {
@ -146,15 +119,6 @@ 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() {
@ -171,17 +135,6 @@ 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,6 +3,7 @@ package listenbrainz
import (
"context"
"errors"
"net/http"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
@ -11,7 +12,6 @@ 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,7 +33,9 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
baseURL: conf.Server.ListenBrainz.BaseURL,
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = newClient(l.baseURL, chc)
return l

View file

@ -30,7 +30,7 @@ var _ = Describe("listenBrainzAgent", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
ctx = context.Background()
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{}
agent = listenBrainzConstructor(ds)
agent.client = newClient("http://localhost:8080", httpClient)
@ -164,19 +164,6 @@ 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,7 +16,6 @@ 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 {
@ -38,7 +37,9 @@ func NewRouter(ds model.DataStore) *Router {
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
r.Handler = r.routes()
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc)
return r
}

View file

@ -13,7 +13,6 @@ import (
"slices"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
)
@ -22,12 +21,6 @@ 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")
)
@ -181,9 +174,6 @@ 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
@ -195,10 +185,6 @@ 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}
}
@ -225,9 +211,6 @@ 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,17 +4,13 @@ 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"
@ -465,73 +461,4 @@ 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

@ -1 +0,0 @@
response-property-enum-value-added INFO

View file

@ -1,155 +0,0 @@
extends: [[spectral:oas, recommended]]
rules:
# vacuum's `enumeration` function mis-resolves hyphenated `then.field` names,
# so the value check below targets `x-module` via `given` instead.
nd-operation-x-module-required:
description: Every operation belongs to exactly one capability module.
severity: error
given: $.paths[*][get,put,post,delete,patch]
then:
field: x-module
function: truthy
nd-operation-x-module:
description: Every operation's capability module is one of the known values.
severity: error
given: $.paths[*][get,put,post,delete,patch]['x-module']
then:
function: enumeration
functionOptions:
values:
- core
- streaming
- download
- artwork
- lyrics
- transcoding
- annotations
- playback
- queue
- custom-tags
- grouping
- playlists
- smart-playlists
- sync
- events
- jukebox
- sharing
- radio
- admin
nd-operation-stability-level-required:
description: Every operation declares its stability level, which the breaking-change gate relies on.
severity: error
given: $.paths[*][get,put,post,delete,patch]
then:
field: x-stability-level
function: truthy
nd-operation-stability-level:
description: Every operation's stability level is alpha, beta, or stable.
severity: error
given: $.paths[*][get,put,post,delete,patch]['x-stability-level']
then:
function: enumeration
functionOptions:
values:
- alpha
- beta
- stable
nd-operation-required-fields:
description: Operations need a stable operationId, summary, description and tags.
severity: error
given: $.paths[*][get,put,post,delete,patch]
then:
- field: operationId
function: truthy
- field: summary
function: truthy
- field: description
function: truthy
- field: tags
function: truthy
# Our schemas live in path/response files, not root components, so this
# walks every resolved `properties` map in the document via `$..` instead.
nd-schema-property-descriptions:
description: Every schema property is documented.
severity: error
given: $..properties[*]
then:
field: description
function: truthy
# patternProperties covers the full 4xx/5xx range; needs an explicit
# `properties` entry too, or `additionalProperties: false` rejects it.
nd-error-responses-are-problems:
description: 4xx and 5xx responses use application/problem+json.
severity: error
given: $.paths[*][*].responses
then:
function: schema
functionOptions:
forceValidationOnCurrentNode: true
schema:
type: object
patternProperties:
"^[45][0-9][0-9]$":
type: object
required: [content]
properties:
content:
type: object
properties:
application/problem+json: {}
required: [application/problem+json]
additionalProperties: false
# Same filter limitation applies here: "is this a list endpoint" is expressed
# as a JSON Schema if/then on the operation object instead of a `given` filter.
nd-list-endpoints-paginate:
description: List endpoints declare the shared offset and limit parameters.
severity: error
given: $.paths[*].get
then:
function: schema
functionOptions:
forceValidationOnCurrentNode: true
schema:
type: object
if:
required: [responses]
properties:
responses:
type: object
required: ['200']
properties:
'200':
type: object
required: [content]
properties:
content:
type: object
required: [application/json]
properties:
application/json:
type: object
required: [schema]
properties:
schema:
type: object
required: [properties]
properties:
properties:
type: object
required: [items]
then:
required: [parameters]
properties:
parameters:
type: array
allOf:
- contains:
type: object
properties:
name:
const: offset
- contains:
type: object
properties:
name:
const: limit

View file

@ -1,262 +0,0 @@
{
"openapi": "3.0.3",
"info": {
"title": "Navidrome API",
"version": "1.0.0",
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n",
"license": {
"name": "GPL-3.0",
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
}
},
"servers": [
{
"url": "/api/v1"
}
],
"tags": [
{
"name": "server",
"description": "Server discovery and the published OpenAPI document."
}
],
"paths": {
"/server": {
"get": {
"operationId": "getServerInfo",
"x-module": "core",
"x-stability-level": "alpha",
"tags": [
"server"
],
"summary": "Describe the server",
"description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n",
"responses": {
"200": {
"description": "Server description.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerInfo"
}
}
}
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/openapi.json": {
"get": {
"operationId": "getOpenAPISpecJSON",
"x-module": "core",
"x-stability-level": "alpha",
"tags": [
"server"
],
"summary": "Get the OpenAPI document (JSON)",
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
"responses": {
"200": {
"description": "The OpenAPI document.",
"headers": {
"ETag": {
"$ref": "#/components/headers/ETag"
}
},
"content": {
"application/json": {
"schema": {
"type": "object",
"description": "OpenAPI 3.0 document."
}
}
}
},
"304": {
"$ref": "#/components/responses/NotModified"
}
}
}
},
"/openapi.yaml": {
"get": {
"operationId": "getOpenAPISpecYAML",
"x-module": "core",
"x-stability-level": "alpha",
"tags": [
"server"
],
"summary": "Get the OpenAPI document (YAML)",
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
"responses": {
"200": {
"description": "The OpenAPI document.",
"headers": {
"ETag": {
"$ref": "#/components/headers/ETag"
}
},
"content": {
"application/yaml": {
"schema": {
"type": "object",
"description": "OpenAPI 3.0 document."
}
}
}
},
"304": {
"$ref": "#/components/responses/NotModified"
}
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "Short-lived access token minted from a device grant. Not yet applied to any operation."
}
},
"schemas": {
"ServerInfo": {
"type": "object",
"description": "Public server description. Everything an add-server screen needs before login.",
"required": [
"name",
"serverVersion",
"specVersion",
"setupRequired",
"loginMethods"
],
"properties": {
"name": {
"type": "string",
"description": "Human-readable server product name."
},
"serverVersion": {
"type": "string",
"description": "Version of the running server build."
},
"specVersion": {
"type": "string",
"description": "Version of the OpenAPI document this server implements."
},
"setupRequired": {
"type": "boolean",
"description": "True until the first admin user has been created."
},
"loginMethods": {
"type": "array",
"description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.",
"items": {
"type": "string",
"enum": [
"password"
]
}
}
}
},
"Problem": {
"type": "object",
"description": "RFC 9457 problem details, returned for every 4xx and 5xx response.",
"required": [
"title",
"status",
"code"
],
"properties": {
"type": {
"type": "string",
"description": "URI reference identifying the problem type. Omitted while the problem carries no semantics\nbeyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their\nown semantics get their own URI; switch on `code` instead.\n"
},
"title": {
"type": "string",
"description": "Short human-readable summary, the same for all occurrences of this problem type."
},
"status": {
"type": "integer",
"description": "HTTP status code of this response."
},
"detail": {
"type": "string",
"description": "Human-readable explanation specific to this occurrence. Omitted for internal errors."
},
"code": {
"type": "string",
"description": "Machine-readable error code, and the value clients switch on. New codes may be added.",
"enum": [
"validation",
"unauthorized",
"forbidden",
"not_found",
"method_not_allowed",
"unavailable",
"internal"
]
},
"errors": {
"type": "array",
"description": "Per-field failures. Present only when `code` is `validation`.",
"items": {
"$ref": "#/components/schemas/ValidationError"
}
}
}
},
"ValidationError": {
"type": "object",
"description": "One field-level validation failure.",
"required": [
"field",
"message"
],
"properties": {
"field": {
"type": "string",
"description": "Name of the offending query parameter, path parameter, or body field (dotted for nested)."
},
"message": {
"type": "string",
"description": "Why the value was rejected."
}
}
}
},
"responses": {
"InternalError": {
"description": "Unexpected server failure. Details are in the server log.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"NotModified": {
"description": "Not modified.",
"headers": {
"ETag": {
"$ref": "#/components/headers/ETag"
}
}
}
},
"headers": {
"ETag": {
"description": "Entity tag for `If-None-Match` revalidation.",
"schema": {
"type": "string"
}
}
}
}
}

View file

@ -1,194 +0,0 @@
openapi: 3.0.3
info:
title: Navidrome API
version: 1.0.0
description: |
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
capability modules through `GET /server` and never sniff versions.
Enums are open: new values may be added to any enum within v1. Clients must
accept values they do not recognise instead of failing.
Every operation declares `x-stability-level`: `alpha` operations may change or
disappear without notice, `beta` and `stable` operations only change additively.
A level is only ever raised, never lowered.
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
in its `Allow` header.
license:
name: GPL-3.0
url: https://www.gnu.org/licenses/gpl-3.0.html
servers:
- url: /api/v1
tags:
- name: server
description: Server discovery and the published OpenAPI document.
paths:
/server:
get:
operationId: getServerInfo
x-module: core
x-stability-level: alpha
tags: [server]
summary: Describe the server
description: |
Returns the public server description. No authentication required.
Authenticated requests will additionally receive the implemented capability modules
once authentication is available.
responses:
'200':
description: Server description.
content:
application/json:
schema:
$ref: '#/components/schemas/ServerInfo'
'500':
$ref: '#/components/responses/InternalError'
/openapi.json:
get:
operationId: getOpenAPISpecJSON
x-module: core
x-stability-level: alpha
tags: [server]
summary: Get the OpenAPI document (JSON)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses:
'200':
description: The OpenAPI document.
headers:
ETag:
$ref: '#/components/headers/ETag'
content:
application/json:
schema:
type: object
description: OpenAPI 3.0 document.
'304':
$ref: '#/components/responses/NotModified'
/openapi.yaml:
get:
operationId: getOpenAPISpecYAML
x-module: core
x-stability-level: alpha
tags: [server]
summary: Get the OpenAPI document (YAML)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses:
'200':
description: The OpenAPI document.
headers:
ETag:
$ref: '#/components/headers/ETag'
content:
application/yaml:
schema:
type: object
description: OpenAPI 3.0 document.
'304':
$ref: '#/components/responses/NotModified'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
schemas:
ServerInfo:
type: object
description: Public server description. Everything an add-server screen needs before login.
required:
- name
- serverVersion
- specVersion
- setupRequired
- loginMethods
properties:
name:
type: string
description: Human-readable server product name.
serverVersion:
type: string
description: Version of the running server build.
specVersion:
type: string
description: Version of the OpenAPI document this server implements.
setupRequired:
type: boolean
description: True until the first admin user has been created.
loginMethods:
type: array
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
items:
type: string
enum:
- password
Problem:
type: object
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
required:
- title
- status
- code
properties:
type:
type: string
description: |
URI reference identifying the problem type. Omitted while the problem carries no semantics
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
own semantics get their own URI; switch on `code` instead.
title:
type: string
description: Short human-readable summary, the same for all occurrences of this problem type.
status:
type: integer
description: HTTP status code of this response.
detail:
type: string
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
code:
type: string
description: Machine-readable error code, and the value clients switch on. New codes may be added.
enum:
- validation
- unauthorized
- forbidden
- not_found
- method_not_allowed
- unavailable
- internal
errors:
type: array
description: Per-field failures. Present only when `code` is `validation`.
items:
$ref: '#/components/schemas/ValidationError'
ValidationError:
type: object
description: One field-level validation failure.
required:
- field
- message
properties:
field:
type: string
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
message:
type: string
description: Why the value was rejected.
responses:
InternalError:
description: Unexpected server failure. Details are in the server log.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
NotModified:
description: Not modified.
headers:
ETag:
$ref: '#/components/headers/ETag'
headers:
ETag:
description: Entity tag for `If-None-Match` revalidation.
schema:
type: string

View file

@ -1,35 +0,0 @@
package api
import (
_ "embed"
"encoding/json"
"sync"
)
//go:embed bundled/openapi.json
var specJSON []byte
//go:embed bundled/openapi.yaml
var specYAML []byte
func SpecJSON() []byte {
return specJSON
}
func SpecYAML() []byte {
return specYAML
}
var specVersion = sync.OnceValue(func() string {
var doc struct {
Info struct {
Version string `json:"version"`
} `json:"info"`
}
_ = json.Unmarshal(SpecJSON(), &doc)
return doc.Info.Version
})
func SpecVersion() string {
return specVersion()
}

View file

@ -1,37 +0,0 @@
package api_test
import (
"os"
"github.com/getkin/kin-openapi/openapi3"
"github.com/navidrome/navidrome/api"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v3"
)
var _ = Describe("Bundled spec", func() {
It("embeds a valid OpenAPI 3 document", func() {
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
Expect(err).ToNot(HaveOccurred())
Expect(doc.Validate(GinkgoT().Context())).To(Succeed())
Expect(doc.Paths.Find("/server")).ToNot(BeNil())
})
It("embeds the YAML variant", func() {
var doc map[string]any
Expect(yaml.Unmarshal(api.SpecYAML(), &doc)).To(Succeed())
Expect(doc).To(HaveKey("paths"))
})
It("reports the version from the bundle, matching the source root document", func() {
src, err := os.ReadFile("api/openapi/openapi.yaml")
Expect(err).ToNot(HaveOccurred())
var root struct {
Info struct{ Version string } `yaml:"info"`
}
Expect(yaml.Unmarshal(src, &root)).To(Succeed())
Expect(api.SpecVersion()).To(Equal(root.Info.Version))
Expect(api.SpecVersion()).ToNot(BeEmpty())
})
})

View file

@ -1,3 +0,0 @@
description: Entity tag for `If-None-Match` revalidation.
schema:
type: string

View file

@ -1,9 +0,0 @@
name: limit
in: query
description: Maximum number of items to return.
required: false
schema:
type: integer
minimum: 1
maximum: 2000
default: 100

View file

@ -1,8 +0,0 @@
name: offset
in: query
description: Zero-based index of the first item to return.
required: false
schema:
type: integer
minimum: 0
default: 0

View file

@ -1,5 +0,0 @@
description: The request is malformed or fails validation.
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,5 +0,0 @@
description: The caller is authenticated but not allowed to do this.
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,5 +0,0 @@
description: Unexpected server failure. Details are in the server log.
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,5 +0,0 @@
description: No such resource or endpoint.
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,4 +0,0 @@
description: Not modified.
headers:
ETag:
$ref: ../headers/ETag.yaml

View file

@ -1,5 +0,0 @@
description: Missing, invalid, or expired credentials.
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,13 +0,0 @@
type: object
description: Pagination metadata carried by every list response.
required: [total, offset, limit]
properties:
total:
type: integer
description: Total number of items matching the request, ignoring pagination.
offset:
type: integer
description: Zero-based index of the first returned item.
limit:
type: integer
description: Maximum number of items in this page.

View file

@ -1,35 +0,0 @@
type: object
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
required: [title, status, code]
properties:
type:
type: string
description: |
URI reference identifying the problem type. Omitted while the problem carries no semantics
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
own semantics get their own URI; switch on `code` instead.
title:
type: string
description: Short human-readable summary, the same for all occurrences of this problem type.
status:
type: integer
description: HTTP status code of this response.
detail:
type: string
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
code:
type: string
description: Machine-readable error code, and the value clients switch on. New codes may be added.
enum:
- validation
- unauthorized
- forbidden
- not_found
- method_not_allowed
- unavailable
- internal
errors:
type: array
description: Per-field failures. Present only when `code` is `validation`.
items:
$ref: ./ValidationError.yaml

View file

@ -1,22 +0,0 @@
type: object
description: Public server description. Everything an add-server screen needs before login.
required: [name, serverVersion, specVersion, setupRequired, loginMethods]
properties:
name:
type: string
description: Human-readable server product name.
serverVersion:
type: string
description: Version of the running server build.
specVersion:
type: string
description: Version of the OpenAPI document this server implements.
setupRequired:
type: boolean
description: True until the first admin user has been created.
loginMethods:
type: array
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
items:
type: string
enum: [password]

View file

@ -1,10 +0,0 @@
type: object
description: One field-level validation failure.
required: [field, message]
properties:
field:
type: string
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
message:
type: string
description: Why the value was rejected.

View file

@ -1,39 +0,0 @@
openapi: 3.0.3
info:
title: Navidrome API
version: 1.0.0
description: |
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
capability modules through `GET /server` and never sniff versions.
Enums are open: new values may be added to any enum within v1. Clients must
accept values they do not recognise instead of failing.
Every operation declares `x-stability-level`: `alpha` operations may change or
disappear without notice, `beta` and `stable` operations only change additively.
A level is only ever raised, never lowered.
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
in its `Allow` header.
license:
name: GPL-3.0
url: https://www.gnu.org/licenses/gpl-3.0.html
servers:
- url: /api/v1
tags:
- name: server
description: Server discovery and the published OpenAPI document.
paths:
/server:
$ref: ./paths/server.yaml
/openapi.json:
$ref: ./paths/openapi.yaml#/json
/openapi.yaml:
$ref: ./paths/openapi.yaml#/yaml
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: Short-lived access token minted from a device grant. Not yet applied to any operation.

View file

@ -1,42 +0,0 @@
json:
get:
operationId: getOpenAPISpecJSON
x-module: core
x-stability-level: alpha
tags: [server]
summary: Get the OpenAPI document (JSON)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses:
'200':
description: The OpenAPI document.
headers:
ETag:
$ref: ../components/headers/ETag.yaml
content:
application/json:
schema:
type: object
description: OpenAPI 3.0 document.
'304':
$ref: ../components/responses/NotModified.yaml
yaml:
get:
operationId: getOpenAPISpecYAML
x-module: core
x-stability-level: alpha
tags: [server]
summary: Get the OpenAPI document (YAML)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses:
'200':
description: The OpenAPI document.
headers:
ETag:
$ref: ../components/headers/ETag.yaml
content:
application/yaml:
schema:
type: object
description: OpenAPI 3.0 document.
'304':
$ref: ../components/responses/NotModified.yaml

View file

@ -1,19 +0,0 @@
get:
operationId: getServerInfo
x-module: core
x-stability-level: alpha
tags: [server]
summary: Describe the server
description: |
Returns the public server description. No authentication required.
Authenticated requests will additionally receive the implemented capability modules
once authentication is available.
responses:
'200':
description: Server description.
content:
application/json:
schema:
$ref: ../components/schemas/ServerInfo.yaml
'500':
$ref: ../components/responses/InternalError.yaml

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,9 @@ package cmd
import (
"context"
"path/filepath"
"fmt"
"os"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
@ -29,7 +31,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", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
_ = restoreCommand.MarkFlagRequired("backup-file")
backupRoot.AddCommand(restoreCommand)
@ -76,12 +78,24 @@ func runBackup(ctx context.Context) {
conf.Server.Backup.Path = conf.NewDir(backupDir)
}
requireExistingDB()
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
}
start := time.Now()
path, err := db.Backup(ctx)
if err != nil {
log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err)
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
@ -97,17 +111,36 @@ func runPrune(ctx context.Context) {
conf.Server.Backup.Count = backupCount
}
if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") {
log.Warn("Prune cancelled")
return
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
}
}
requireExistingDB()
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
}
start := time.Now()
count, err := db.Prune(ctx)
if err != nil {
log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err)
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
@ -116,29 +149,36 @@ func runPrune(ctx context.Context) {
}
func runRestore(ctx context.Context) {
requireExistingDB()
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
// 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 idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") {
log.Warn("Restore cancelled")
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")
return
}
}
start := time.Now()
err := db.Restore(ctx, restorePath)
if err != nil {
log.Fatal("Error restoring database", "backupFile", restorePath, err)
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)

View file

@ -1,99 +0,0 @@
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
}

View file

@ -1,124 +0,0 @@
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"))
})
})

View file

@ -1,17 +1,13 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
@ -32,7 +28,7 @@ var inspectCmd = &cobra.Command{
Long: "Show file tags as seen by Navidrome",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runInspector(cmd.Context(), args)
runInspector(args)
},
}
@ -59,24 +55,18 @@ func prettyMarshal(v any) ([]byte, error) {
return []byte(res.String()), nil
}
func runInspector(ctx context.Context, args []string) {
func runInspector(args []string) {
marshal := marshalers[format]
if marshal == nil {
log.Fatal("Invalid format", "format", format)
}
libs := loadLibraries(ctx)
matcher := model.NewLibraryMatcher(libs)
var out []core.InspectOutput
for _, filePath := range args {
if !model.IsAudioFile(filePath) {
log.Warn("Not an audio file", "file", filePath)
continue
}
lib, ok := libraryForFile(matcher, filePath)
if !ok && len(libs) > 0 {
log.Warn("File is not in any library, using the global PID config", "file", filePath)
}
output, err := core.Inspect(filePath, lib, "")
output, err := core.Inspect(filePath, 1, "")
if err != nil {
log.Warn("Unable to process file", "file", filePath, "error", err)
continue
@ -87,33 +77,3 @@ func runInspector(ctx context.Context, args []string) {
data, _ := marshal(out)
fmt.Println(string(data))
}
// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB.
func loadLibraries(ctx context.Context) model.Libraries {
if dbFile, ok := existingDBFile(); !ok {
log.Warn(ctx, "No database found, using the global PID config", "path", dbFile)
return nil
}
defer db.Init(ctx)()
libs, err := persistence.New(db.Db()).Library().GetAll(ctx)
if err != nil {
log.Warn(ctx, "Could not load libraries, using the global PID config", err)
return nil
}
for i := range libs {
if absPath, err := filepath.Abs(libs[i].Path); err == nil {
libs[i].Path = absPath
}
}
return libs
}
// libraryForFile falls back to the default library with no overrides, which uses the global PID config.
func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) {
if absPath, err := filepath.Abs(filePath); err == nil {
if lib, ok := matcher.FindLibrary(absPath); ok {
return lib, true
}
}
return model.Library{ID: model.DefaultLibraryID}, false
}

View file

@ -1,61 +0,0 @@
package cmd
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("inspect", func() {
Describe("libraryForFile", func() {
var matcher *model.LibraryMatcher
var root string
BeforeEach(func() {
root = GinkgoT().TempDir()
cwd, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
matcher = model.NewLibraryMatcher(model.Libraries{
{ID: 1, Path: filepath.Join(root, "music")},
{ID: 2, Path: filepath.Join(cwd, "loose"), PIDAlbum: "folder"},
})
})
It("returns the library that contains an absolute path", func() {
lib, ok := libraryForFile(matcher, filepath.Join(root, "music", "album", "track.mp3"))
Expect(ok).To(BeTrue())
Expect(lib.ID).To(Equal(1))
})
It("resolves a relative path against the working directory", func() {
lib, ok := libraryForFile(matcher, filepath.Join("loose", "track.mp3"))
Expect(ok).To(BeTrue())
Expect(lib.PIDAlbum).To(Equal("folder"))
})
It("falls back to the default library without overrides", func() {
lib, ok := libraryForFile(matcher, filepath.Join(root, "elsewhere", "track.mp3"))
Expect(ok).To(BeFalse())
Expect(lib).To(Equal(model.Library{ID: model.DefaultLibraryID}))
})
})
Describe("loadLibraries", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("does not create a database when there is none", func() {
dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db")
conf.Server.DbPath = dbFile + "?_journal_mode=WAL"
Expect(loadLibraries(GinkgoT().Context())).To(BeNil())
Expect(dbFile).ToNot(BeAnExistingFile())
})
})
})

View file

@ -1,171 +0,0 @@
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().GetCursor(ctx, 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().Get(ctx, 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().FindByPaths(ctx, []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
}

View file

@ -1,81 +0,0 @@
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

@ -6,7 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
@ -109,7 +108,7 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
}
options.Filters = squirrel.Eq{"owner_id": user.ID}
}
pls, err := ds.Playlist().GetAll(ctx, options)
pls, err := ds.Playlist(ctx).GetAll(options)
if err != nil {
log.Fatal(ctx, "Failed to retrieve playlists", err)
}
@ -117,17 +116,17 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
}
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
playlist, err := ds.Playlist().GetWithTracks(ctx, nameOrID, true, false)
playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
}
if errors.Is(err, model.ErrNotFound) {
playlists, err := ds.Playlist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
if err != nil {
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
}
if len(playlists) > 0 {
playlist, err = ds.Playlist().GetWithTracks(ctx, playlists[0].ID, true, false)
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
if err != nil {
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
}
@ -142,16 +141,14 @@ func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *mod
func runExporter(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
playlist := findPlaylist(ctx, ds, playlistID)
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
}
func writePlaylist(m3u string, out io.Writer, file string) {
if file == "" || file == "-" {
fmt.Fprint(out, m3u)
pls := playlist.ToM3U8()
if outputFile == "-" || outputFile == "" {
println(pls)
return
}
if err := os.WriteFile(file, []byte(m3u), 0600); err != nil {
log.Fatal("Error writing to the output file", "file", file, err)
err := os.WriteFile(outputFile, []byte(pls), 0600)
if err != nil {
log.Fatal("Error writing to the output file", "file", outputFile, err)
}
}
@ -160,7 +157,7 @@ func runExport(ctx context.Context) {
if playlistID != "" && outputFile == "" {
playlist := findPlaylist(ctx, ds, playlistID)
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
println(playlist.ToM3U8())
return
}
@ -194,7 +191,7 @@ func runExport(ctx context.Context) {
exported := 0
for _, pls := range allPls {
plsWithTracks, err := ds.Playlist().GetWithTracks(ctx, pls.ID, true, false)
plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
if err != nil {
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
continue

View file

@ -1,35 +0,0 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("writePlaylist", func() {
const m3u = "#EXTM3U\n#PLAYLIST:DJ Wave\n#EXTINF:364,Bel Canto - Dreaming Girl\n"
plsFile := filepath.Join(os.TempDir(), fmt.Sprintf("navidrome-pls-%d.m3u8", os.Getpid()))
BeforeEach(func() {
DeferCleanup(func() { _ = os.Remove(plsFile) })
})
DescribeTable("writes the playlist to exactly one destination",
func(file, wantStream, wantFile string) {
var out strings.Builder
writePlaylist(m3u, &out, file)
written, _ := os.ReadFile(plsFile)
Expect(out.String()).To(Equal(wantStream))
Expect(string(written)).To(Equal(wantFile))
},
Entry("no file name writes to the stream", "", m3u, ""),
Entry("a dash writes to the stream", "-", m3u, ""),
Entry("a path writes to the file", plsFile, "", m3u),
)
})

View file

@ -9,6 +9,7 @@ import (
"os"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/navidrome/navidrome/conf"
@ -243,7 +244,7 @@ func runPluginInfo(ctx context.Context, arg string) {
}
requirePluginsEnabled(ctx)
ds, ctx := getAdminContext(ctx)
p, err := ds.Plugin().Get(ctx, arg)
p, err := ds.Plugin(ctx).Get(arg)
if err != nil {
log.Fatal(ctx, "Plugin not found", "id", arg, err)
}
@ -264,7 +265,7 @@ func runPluginValidate(ctx context.Context, arg string) {
}
requirePluginsEnabled(ctx)
ds, ctx := getAdminContext(ctx)
p, err := ds.Plugin().Get(ctx, arg)
p, err := ds.Plugin(ctx).Get(arg)
if err != nil {
log.Fatal(ctx, "Plugin not found", "id", arg, err)
}
@ -313,7 +314,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
return sb.String(), w.Error()
case "table":
var sb strings.Builder
w := newTabWriter(&sb)
w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR")
for _, p := range list {
name, version := manifestSummary(p)
@ -329,7 +330,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
func runPluginList(ctx context.Context) {
requirePluginsEnabled(ctx)
ds, ctx := getAdminContext(ctx)
list, err := ds.Plugin().GetAll(ctx)
list, err := ds.Plugin(ctx).GetAll()
if err != nil {
log.Fatal(ctx, "Failed to list plugins", err)
}
@ -372,7 +373,7 @@ var pluginEditCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
requirePluginsEnabled(cmd.Context())
ds, ctx := getAdminContext(cmd.Context())
cur, err := ds.Plugin().Get(ctx, args[0])
cur, err := ds.Plugin(ctx).Get(args[0])
if err != nil {
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
}

View file

@ -2,7 +2,6 @@ package cmd
import (
"context"
"net/http"
"os"
"os/signal"
"strings"
@ -44,9 +43,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
preRun()
},
Run: func(cmd *cobra.Command, args []string) {
if err := runNavidrome(cmd.Context()); err != nil {
log.Fatal("Fatal error in Navidrome. Aborting", err)
}
runNavidrome(cmd.Context())
},
PostRun: func(cmd *cobra.Command, args []string) {
postRun()
@ -78,17 +75,16 @@ func postRun() {
}
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
// If any of the services returns an error, it stops the others and returns that error, so the caller can
// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil.
func runNavidrome(parentCtx context.Context) error {
defer db.Init(parentCtx)()
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
// it will cancel the context and exit gracefully.
func runNavidrome(ctx context.Context) {
defer db.Init(ctx)()
g, ctx := errgroup.WithContext(parentCtx)
g, ctx := errgroup.WithContext(ctx)
g.Go(startServer(ctx))
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))
@ -104,11 +100,9 @@ func runNavidrome(parentCtx context.Context) error {
log.Warn(ctx, "Automatic Scanning is DISABLED")
}
// Errors caused by a normal shutdown are not failures
if err := g.Wait(); err != nil && parentCtx.Err() == nil {
return err
if err := g.Wait(); err != nil {
log.Error("Fatal error in Navidrome. Aborting", err)
}
return nil
}
// mainContext returns a context that is cancelled when the process receives a signal to exit.
@ -137,9 +131,6 @@ func startServer(ctx context.Context) func() error {
if conf.Server.Jellyfin.Enabled {
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
}
if conf.Server.DevAPIv1 {
a.MountRouter("API v1", consts.URLPathAPIv1, CreateAPIv1Router(ctx))
}
if conf.Server.Prometheus.Enabled {
p := CreatePrometheus()
// blocking call because takes <100ms but useful if fails
@ -147,7 +138,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", profilerHandler())
a.MountRouter("Profiling", "/debug", middleware.Profiler())
}
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
@ -156,14 +147,6 @@ 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 {
@ -190,50 +173,46 @@ func schedulePeriodicScan(ctx context.Context) func() error {
}
}
// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from
// the one used by their last finished scan
func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) {
libs, err := ds.Library().GetAll(ctx)
func pidHashChanged(ds model.DataStore) (bool, error) {
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
if err != nil {
return nil, err
return false, err
}
var names []string
for _, lib := range libs {
if lib.PIDChanged() {
names = append(names, lib.Name)
}
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
if err != nil {
return false, err
}
return names, nil
return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil
}
// runInitialScan runs an initial scan of the music library if needed.
func runInitialScan(ctx context.Context) func() error {
return func() error {
ds := CreateDataStore()
fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0")
fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0")
if err != nil {
return err
}
inProgress, err := ds.Library().ScanInProgress(ctx)
inProgress, err := ds.Library(ctx).ScanInProgress()
if err != nil {
return err
}
pidChangedLibs, err := librariesWithChangedPID(ctx, ds)
pidHasChanged, err := pidHashChanged(ds)
if err != nil {
return err
}
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
if scanNeeded {
s := CreateScanner(ctx)
switch {
case fullScanRequired == "1":
log.Warn(ctx, "Full scan required after migration")
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
case len(pidChangedLibs) > 0:
// Includes never-scanned libraries. The scanner rescans in full only the ones that need it
log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs)
_ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey)
case pidHasChanged:
log.Warn(ctx, "PID config changed, performing full scan")
fullScanRequired = "1"
case inProgress:
log.Warn(ctx, "Resuming interrupted scan")
default:
@ -355,18 +334,6 @@ 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 {
@ -386,50 +353,58 @@ func startPlaybackServer(ctx context.Context) func() error {
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
return func() error {
log.Info(ctx, "Starting artwork worker")
// The scanner writes to the DB for its whole run; competing for the write lock makes both fail.
worker.PauseWhile(scanner.IsScanning)
return worker.Run(ctx)
}
}
// outsideScan runs a DB maintenance job unless a scan is running, and keeps a scan from starting
// until it ends; both write to the DB, and competing for the lock can make either fail.
func outsideScan(ctx context.Context, job string, run func(context.Context) error) {
release, ok := scanner.LockForMaintenance()
if !ok {
log.Debug(ctx, "Skipping "+job+" because a scan is in progress")
return
}
defer release()
if err := run(ctx); err != nil {
log.Error(ctx, "Error running "+job, err)
}
}
// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and
// reports an artwork config change without acting on it.
// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the
// recurring stale-absent recheck and prune jobs.
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
return func() error {
schedulerInstance := scheduler.GetInstance()
if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() {
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
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 := worker.EnqueueMissingAll(ctx); err != nil {
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
}
}); err != nil {
log.Error(ctx, "Error scheduling artwork missing-state recheck", err)
log.Error(ctx, "Error scheduling artwork stale-absent recheck", err)
}
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
outsideScan(ctx, "artwork prune", worker.RunPrune)
if err := worker.RunPrune(ctx); err != nil {
log.Error(ctx, "Error running artwork prune", err)
}
}); err != nil {
log.Error(ctx, "Error scheduling artwork prune", err)
}
// Also run the missing-row recheck once at startup so a never-scanned entity is picked up
// immediately, not only on the next hourly tick (e.g. after enabling the feature).
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
if err := worker.EnqueueMissingAll(ctx); err != nil {
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
}
if err := worker.ReconcileConfig(ctx); err != nil {
log.Error(ctx, "Error checking the artwork config fingerprint", 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():
}
return nil
}

View file

@ -1,76 +0,0 @@
package cmd
import (
"errors"
"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/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "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/"),
)
})
var _ = Describe("librariesWithChangedPID", func() {
var ds *tests.MockDataStore
var libs *tests.MockLibraryRepo
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
libs = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{MockedLibrary: libs}
})
It("returns only the libraries whose PID config changed", func() {
pid := model.Library{}.EffectivePID()
libs.SetData(model.Libraries{
{ID: 1, Name: "Same", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
{ID: 2, Name: "Changed", PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
{ID: 3, Name: "Never scanned"},
})
Expect(librariesWithChangedPID(GinkgoT().Context(), ds)).To(ConsistOf("Changed", "Never scanned"))
})
It("returns the error from the repository", func() {
libs.Err = errors.New("db down")
_, err := librariesWithChangedPID(GinkgoT().Context(), ds)
Expect(err).To(MatchError("db down"))
})
})

View file

@ -1,55 +0,0 @@
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

@ -44,7 +44,7 @@ var svcCmd = &cobra.Command{
}
type svcControl struct {
ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop
ctx context.Context
cancel context.CancelFunc
done chan struct{}
}
@ -53,13 +53,8 @@ func (p *svcControl) Start(service.Service) error {
p.done = make(chan struct{})
p.ctx, p.cancel = context.WithCancel(context.Background())
go func() {
err := runNavidrome(p.ctx)
runNavidrome(p.ctx)
close(p.done)
// service.Run() only returns when it gets a stop request, so exit here to let the
// service manager see the failure and restart the service
if err != nil {
log.Fatal("Fatal error in Navidrome. Aborting", err)
}
}()
return nil
}
@ -79,7 +74,7 @@ func (p *svcControl) Stop(service.Service) error {
var svcInstance = sync.OnceValue(func() service.Service {
options := make(service.KeyValue)
options["Restart"] = "on-failure"
options["SuccessExitStatus"] = "SIGKILL"
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
options["UserService"] = false
options["LogDirectory"] = conf.Server.DataFolder.String()
options["SystemdScript"] = systemdScript

View file

@ -183,7 +183,7 @@ func runCreateUser(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
err := ds.WithTx(func(tx model.DataStore) error {
existingUser, err := tx.User().FindByUsername(ctx, userID)
existingUser, err := tx.User(ctx).FindByUsername(userID)
if existingUser != nil {
return fmt.Errorf("existing user '%s'", userID)
}
@ -193,7 +193,7 @@ func runCreateUser(ctx context.Context) {
}
if len(libraryIds) > 0 && !setAdmin {
user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
if err != nil {
return err
}
@ -202,13 +202,13 @@ func runCreateUser(ctx context.Context) {
return libraryError(user.Libraries)
}
} else {
user.Libraries, err = tx.Library().GetAll(ctx)
user.Libraries, err = tx.Library(ctx).GetAll()
if err != nil {
return err
}
}
err = tx.User().Put(ctx, &user)
err = tx.User(ctx).Put(&user)
if err != nil {
return err
}
@ -218,7 +218,7 @@ func runCreateUser(ctx context.Context) {
updatedIds[idx] = lib.ID
}
err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
return err
})
@ -236,7 +236,7 @@ func runDeleteUser(ctx context.Context) {
var user *model.User
err = ds.WithTx(func(tx model.DataStore) error {
count, err := tx.User().CountAll(ctx)
count, err := tx.User(ctx).CountAll()
if err != nil {
return err
}
@ -250,7 +250,7 @@ func runDeleteUser(ctx context.Context) {
return err
}
return tx.User().Delete(ctx, user.ID)
return tx.User(ctx).Delete(user.ID)
})
if err != nil {
@ -276,7 +276,7 @@ func runUserEdit(ctx context.Context) {
}
if len(libraryIds) > 0 && !setAdmin {
libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
if err != nil {
return err
@ -291,7 +291,7 @@ func runUserEdit(ctx context.Context) {
}
if setAdmin && !user.IsAdmin {
libraries, err := tx.Library().GetAll(ctx)
libraries, err := tx.Library(ctx).GetAll()
if err != nil {
return err
}
@ -337,7 +337,7 @@ func runUserEdit(ctx context.Context) {
return nil
}
err := tx.User().Put(ctx, user)
err := tx.User(ctx).Put(user)
if err != nil {
return err
}
@ -348,7 +348,7 @@ func runUserEdit(ctx context.Context) {
updatedIds[idx] = lib.ID
}
err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
if err != nil {
return err
}
@ -393,11 +393,13 @@ func runUserList(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
userList, err := ds.User().ReadAll(ctx)
users, err := ds.User(ctx).ReadAll()
if err != nil {
log.Fatal(ctx, "Failed to retrieve users", err)
}
userList := users.(model.Users)
if outputFormat == "csv" {
w := csv.NewWriter(os.Stdout)
_ = w.Write([]string{

View file

@ -4,12 +4,7 @@ import (
"context"
"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"
@ -18,33 +13,6 @@ import (
"github.com/navidrome/navidrome/persistence"
)
// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists.
func existingDBFile() (string, bool) {
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
_, err := os.Stat(path)
return path, err == nil
}
// requireExistingDB aborts the command when the database file does not exist.
func requireExistingDB() {
if path, ok := existingDBFile(); !ok {
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)
}
func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
sqlDB := db.Db()
ds := persistence.New(sqlDB)
@ -57,14 +25,14 @@ func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
}
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
user, err := ds.User().FindByUsername(ctx, id)
user, err := ds.User(ctx).FindByUsername(id)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, fmt.Errorf("finding user by name: %w", err)
}
if errors.Is(err, model.ErrNotFound) {
user, err = ds.User().Get(ctx, id)
user, err = ds.User(ctx).Get(id)
if err != nil {
return nil, fmt.Errorf("finding user by id: %w", err)
}

View file

@ -21,7 +21,6 @@ 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"
@ -31,7 +30,6 @@ import (
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/apiv1"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin"
"github.com/navidrome/navidrome/server/nativeapi"
@ -72,17 +70,13 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
insights := metrics.GetInstance(dataStore)
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.New(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)
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)
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader)
return router
}
@ -95,22 +89,22 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
players := core.NewPlayers(dataStore)
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
return router
@ -135,18 +129,10 @@ 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, broker)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
quickConnect := quickconnect.GetInstance()
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect)
return router
}
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
router := apiv1.New(dataStore)
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
return router
}
@ -159,10 +145,9 @@ func CreatePublicRouter() *public.Router {
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
return router
}
@ -180,13 +165,6 @@ 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)
@ -208,7 +186,7 @@ func CreateScanner(ctx context.Context) model.Scanner {
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
return modelScanner
}
@ -219,7 +197,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
}
@ -245,18 +223,6 @@ func CreateArtworkWorker() *artwork.Worker {
return worker
}
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
fFmpeg := ffmpeg.New()
tracingResolver := artwork.NewTracingResolver(dataStore, agentsAgents, fFmpeg, trace, live)
return tracingResolver
}
func getPluginManager() *plugins.Manager {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
@ -268,7 +234,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, apiv1.New, 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)))
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)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View file

@ -23,7 +23,6 @@ import (
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/apiv1"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin"
"github.com/navidrome/navidrome/server/nativeapi"
@ -37,15 +36,13 @@ var allProviders = wire.NewSet(
server.New,
subsonic.New,
jellyfin.New,
jellyfin.NewDiscovery,
apiv1.New,
nativeapi.New,
public.New,
persistence.New,
lastfm.NewRouter,
listenbrainz.NewRouter,
events.GetBroker,
scanner.GetInstance,
scanner.New,
scanner.GetWatcher,
metrics.GetPrometheusInstance,
db.Db,
@ -93,12 +90,6 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
))
}
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
panic(wire.Build(
allProviders,
))
}
func CreatePublicRouter() *public.Router {
panic(wire.Build(
allProviders,
@ -117,12 +108,6 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
))
}
func CreateJellyfinDiscovery() *jellyfin.Discovery {
panic(wire.Build(
allProviders,
))
}
func CreateInsights() metrics.Insights {
panic(wire.Build(
allProviders,
@ -159,13 +144,6 @@ func CreateArtworkWorker() *artwork.Worker {
))
}
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
panic(wire.Build(
allProviders,
artwork.NewTracingResolver,
))
}
func getPluginManager() *plugins.Manager {
panic(wire.Build(
allProviders,

View file

@ -5,7 +5,6 @@ import (
"encoding"
"encoding/json"
"fmt"
"math"
"net/url"
"os"
"path/filepath"
@ -73,7 +72,6 @@ type configOptions struct {
Matcher matcherOptions `json:",omitzero"`
RecentlyAddedByModTime bool
PreferSortTags bool
EnableNaturalSorting bool
IgnoredArticles string
IndexGroups string
FFmpegPath string
@ -92,7 +90,6 @@ type configOptions struct {
EnableUserEditing bool
EnableArtworkUpload bool
MaxImageUploadSize string
MaxImageSize string
EnableSharing bool
ShareURL string
DefaultShareExpiration time.Duration
@ -161,7 +158,6 @@ type configOptions struct {
DevExternalArtistFetchMultiplier float64
DevPreserveUnicodeInExternalCalls bool
DevEnableMediaFileProbe bool
DevAPIv1 bool
}
type scannerOptions struct {
@ -236,8 +232,6 @@ 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.
@ -318,12 +312,6 @@ 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()
@ -354,13 +342,6 @@ 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()
@ -410,7 +391,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, 0600)
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
}
@ -428,20 +409,6 @@ 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)
@ -454,8 +421,7 @@ func Load(noConfigDump bool) {
validateBackupSchedule,
validatePlaylistsPath,
validatePurgeMissingOption,
validateByteSize("MaxImageUploadSize", Server.MaxImageUploadSize),
validateByteSize("MaxImageSize", Server.MaxImageSize),
validateMaxImageUploadSize,
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
)
if err != nil {
@ -515,19 +481,6 @@ func Load(noConfigDump bool) {
Server.UICoverArtSize = newValue
}
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
Server.Scanner.Extractor = consts.DefaultScannerExtractor
}
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
if imgSize < uploadSize {
log.Warn("MaxImageSize must be at least MaxImageUploadSize, raising", "value", Server.MaxImageSize, "newValue", Server.MaxImageUploadSize)
Server.MaxImageSize = Server.MaxImageUploadSize
}
// Call init hooks
for _, hook := range hooks {
hook()
@ -853,20 +806,11 @@ func validatePurgeMissingOption() error {
return nil
}
func validateByteSize(name, value string) func() error {
return func() error {
size, err := humanize.ParseBytes(value)
if err != nil {
return fmt.Errorf("invalid %s %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", name, value, err)
}
if size == 0 {
return fmt.Errorf("invalid %s %q: must be greater than zero", name, value)
}
if size > math.MaxInt64 {
return fmt.Errorf("invalid %s %q: value is too large", name, value)
}
return nil
func validateMaxImageUploadSize() error {
if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil {
return fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err)
}
return nil
}
func validateEnforceNonRootUser() error {
@ -996,7 +940,7 @@ func setViperDefaults() {
viper.SetDefault("autoimportplaylists", true)
viper.SetDefault("defaultplaylistpublicvisibility", false)
viper.SetDefault("playlistspath", "")
viper.SetDefault("smartPlaylistRefreshDelay", consts.DefaultSmartRefresh)
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
viper.SetDefault("enabledownloads", true)
viper.SetDefault("enableexternalservices", true)
viper.SetDefault("enablem3uexternalalbumart", false)
@ -1009,7 +953,6 @@ func setViperDefaults() {
viper.SetDefault("matcher.fuzzythreshold", 85)
viper.SetDefault("recentlyaddedbymodtime", false)
viper.SetDefault("prefersorttags", false)
viper.SetDefault("enablenaturalsorting", false)
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
viper.SetDefault("ffmpegpath", "")
@ -1037,17 +980,16 @@ func setViperDefaults() {
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
viper.SetDefault("enableartworkupload", true)
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
viper.SetDefault("maximagesize", consts.DefaultMaxImageSize)
viper.SetDefault("enablesharing", true)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultshareexpiration", consts.DefaultShareExpiration)
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
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", consts.DefaultAuthWindowLength)
viper.SetDefault("authwindowlength", 20*time.Second)
viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("extauth.userheader", "Remote-User")
viper.SetDefault("extauth.trustedsources", "")
@ -1095,8 +1037,6 @@ 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", "")
@ -1125,7 +1065,6 @@ func setViperDefaults() {
viper.SetDefault("devshowartistpage", true)
viper.SetDefault("devuishowconfig", true)
viper.SetDefault("devneweventstream", true)
viper.SetDefault("devapiv1", false)
viper.SetDefault("devoffsetoptimize", 50000)
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
// scrobbles and the UI. See MaxOpenConns.

View file

@ -5,13 +5,9 @@ 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"
@ -330,21 +326,6 @@ 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")
@ -355,10 +336,19 @@ var _ = Describe("Configuration", func() {
})
Describe("ValidateByteSize", func() {
Describe("ValidateMaxImageUploadSize", func() {
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
})
DescribeTable("accepts valid size values",
func(input string) {
Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(Succeed())
conf.Server.MaxImageUploadSize = input
Expect(conf.ValidateMaxImageUploadSize()).To(Succeed())
},
Entry("megabytes", "10MB"),
Entry("gigabytes", "1GB"),
@ -369,60 +359,14 @@ var _ = Describe("Configuration", func() {
DescribeTable("rejects invalid size values",
func(input string) {
Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(MatchError(ContainSubstring("invalid MaxImageSize")))
conf.Server.MaxImageUploadSize = input
Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize")))
},
Entry("garbage string", "not-a-size"),
Entry("negative-looking", "-10MB"),
Entry("zero", "0"),
Entry("zero with unit", "0MB"),
Entry("overflows int64", "9223372036854775808"),
)
})
Describe("MaxImageSize floor", func() {
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
})
It("is raised to MaxImageUploadSize when configured lower", func() {
viper.SetDefault("maximagesize", "5MB")
viper.SetDefault("maximageuploadsize", "50MB")
conf.Load(true)
Expect(conf.Server.MaxImageSize).To(Equal("50MB"))
})
It("keeps a larger MaxImageSize unchanged", func() {
viper.SetDefault("maximagesize", "30MB")
conf.Load(true)
Expect(conf.Server.MaxImageSize).To(Equal("30MB"))
})
})
Describe("Scanner.Extractor", func() {
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
})
It("falls back to taglib for an unknown extractor", func() {
viper.SetDefault("scanner.extractor", "ffmpeg")
conf.Load(true)
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
})
It("keeps taglib", func() {
conf.Load(true)
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
})
})
Describe("EnforceNonRootUser", func() {
It("defaults to false", func() {
conf.Load(true)
@ -492,73 +436,4 @@ 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())
})
})

View file

@ -14,7 +14,7 @@ var NormalizeSearchBackend = normalizeSearchBackend
var ToPascalCase = toPascalCase
var ValidateByteSize = validateByteSize
var ValidateMaxImageUploadSize = validateMaxImageUploadSize
func SetRuntimeInfoForTest(goos string, euid int) func() {
oldGOOS := currentGOOS

View file

@ -1,12 +0,0 @@
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"

View file

@ -1,12 +0,0 @@
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 the artwork config check
// compares against to detect artwork-affecting config changes across restarts.
// ArtConfFingerprintPropertyKey is the model.PropertyRepository key Backfill compares against
// to detect artwork-affecting config changes across restarts.
ArtConfFingerprintPropertyKey = "ArtConfFingerprint"
UIAuthorizationHeader = "X-ND-Authorization"
@ -34,22 +34,20 @@ 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
ArtworkEnqueueMissingSchedule = "@every 1h"
ArtworkPruneSchedule = "@daily"
ArtworkStaleAbsentRecheckSchedule = "@every 1h"
ArtworkPruneSchedule = "@daily"
ArtworkPostBackfillPruneDelay = 10 * time.Minute
// 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
DefaultEncryptionKey = "just for obfuscation"
PasswordsEncryptedKey = "PasswordsEncryptedKey"
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
APIKeyPrefix = "nds_"
DevInitialUserName = "admin"
DevInitialName = "Dev Admin"
@ -60,7 +58,6 @@ const (
URLPathPublic = "/share"
URLPathPublicImages = URLPathPublic + "/img"
URLPathJellyfinAPI = "/jellyfin"
URLPathAPIv1 = "/api/v1"
// JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the
// Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts.
@ -75,7 +72,6 @@ const (
DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline
DefaultMaxSidebarPlaylists = 100
DefaultAuthWindowLength = 20 * time.Second
RequestThrottleBacklogLimit = 100
RequestThrottleBacklogTimeout = time.Minute
@ -111,15 +107,11 @@ const (
DefaultScannerExtractor = "taglib"
DefaultWatcherWait = 5 * time.Second
Zwsp = string('\u200b')
DefaultActivityPanelUpdateRate = 300 * time.Millisecond
DefaultPluginCompilationTimeout = time.Minute
)
const (
DefaultUICoverArtSize = 300
DefaultMaxImageUploadSize = "10MB"
DefaultMaxImageSize = "20MB"
)
// Prometheus options
@ -156,6 +148,8 @@ const (
//DefaultAlbumPID = "album_legacy"
DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title"
PIDAlbumKey = "PIDAlbum"
PIDTrackKey = "PIDTrack"
)
const (
@ -206,7 +200,7 @@ var (
}
)
var HTTPUserAgent = "Navidrome/" + Version + " - https://github.com/navidrome"
var HTTPUserAgent = "Navidrome" + "/" + Version
var (
VariousArtists = "Various Artists"

View file

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

View file

@ -1,13 +1,9 @@
package agents
import (
"cmp"
"context"
"errors"
"maps"
"slices"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
@ -26,43 +22,11 @@ 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
@ -77,7 +41,6 @@ func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
return &Agents{
ds: ds,
pluginLoader: pluginLoader,
cooldowns: cooldowns{until: map[string]time.Time{}},
}
}
@ -127,19 +90,12 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
} else if isPlugin {
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
} else {
log.Debug("Unknown agent ignored", "name", name, "available", availableAgentNames(availablePlugins))
log.Debug("Unknown agent ignored", "name", name)
}
}
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)
@ -215,7 +171,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 "", errUnsupported
return "", ErrNotFound
}
return retriever.GetArtistMBID(ctx, id, name)
})
@ -232,7 +188,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 "", errUnsupported
return "", ErrNotFound
}
return retriever.GetArtistURL(ctx, id, name, mbid)
})
@ -249,7 +205,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 "", errUnsupported
return "", ErrNotFound
}
return retriever.GetArtistBiography(ctx, id, name, mbid)
})
@ -268,11 +224,7 @@ 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
@ -285,7 +237,6 @@ 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))
@ -295,7 +246,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
return similar, err
}
}
return nil, attempts.noResultErr()
return nil, ErrNotFound
}
func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) {
@ -309,7 +260,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, errUnsupported
return nil, ErrNotFound
}
return retriever.GetArtistImages(ctx, id, name, mbid)
})
@ -330,7 +281,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, errUnsupported
return nil, ErrNotFound
}
return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
})
@ -344,7 +295,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, errUnsupported
return nil, ErrNotFound
}
return retriever.GetAlbumInfo(ctx, name, artist, mbid)
})
@ -358,7 +309,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, errUnsupported
return nil, ErrNotFound
}
return retriever.GetAlbumImages(ctx, name, artist, mbid)
})
@ -369,7 +320,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, errUnsupported
return nil, ErrNotFound
}
return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count)
})
@ -380,7 +331,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, errUnsupported
return nil, ErrNotFound
}
return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count)
})
@ -398,61 +349,16 @@ 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, errUnsupported
return nil, ErrNotFound
}
return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count)
})
}
// 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) {
func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (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
@ -461,29 +367,41 @@ func callAgent[T any](ctx context.Context, agents *Agents, methodName string, fn
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 found(result) {
if result != zero {
log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start))
return result, nil
}
}
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
})
return zero, ErrNotFound
}
func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) {
return callAgent(ctx, agents, methodName, fn, func(results []T) bool { return len(results) > 0 })
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
}
var _ Interface = (*Agents)(nil)

View file

@ -3,8 +3,6 @@ package agents
import (
"context"
"errors"
"slices"
"time"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
@ -16,29 +14,6 @@ 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
@ -59,10 +34,10 @@ var _ = Describe("Agents", func() {
})
It("calls the placeholder GetArtistImages", func() {
mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One"}, {ID: "2", Title: "Two"}})
mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One", MbzReleaseTrackID: "111"}, {ID: "2", Title: "Two", MbzReleaseTrackID: "222"}})
songs, err := ag.GetArtistTopSongs(ctx, "123", "John Doe", "mb123", 2)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(ConsistOf([]Song{{ID: "1", Name: "One"}, {ID: "2", Name: "Two"}}))
Expect(songs).To(ConsistOf([]Song{{Name: "One", MBID: "111"}, {Name: "Two", MBID: "222"}}))
})
})
@ -92,22 +67,6 @@ 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"))
@ -201,102 +160,6 @@ 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{{
@ -560,9 +423,8 @@ var _ = Describe("Agents", func() {
})
type mockAgent struct {
Args []any
Err error
Calls int
Args []any
Err error
}
func (a *mockAgent) AgentName() string {
@ -587,7 +449,6 @@ 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,9 +3,6 @@ package agents
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/gohugoio/hashstructure"
"github.com/navidrome/navidrome/model"
@ -55,49 +52,9 @@ func (s Song) Equals(other Song) bool {
return h1 == h2
}
// 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
}
var (
ErrNotFound = errors.New("not found")
)
// AlbumInfoRetriever provides album info (no images)
type AlbumInfoRetriever interface {

View file

@ -1,42 +1,27 @@
package agents_test
package agents
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("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())
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("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 Artists differ", func() {
other := base
other.Artists = []Artist{{ID: "y", Name: "B"}}
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("false when a scalar differs", func() {
other := base
other.Name = "T"
Expect(base.Equals(other)).To(BeFalse())
})
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())
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

@ -5,8 +5,6 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/utils/slice"
)
const LocalAgentName = "local"
@ -24,7 +22,7 @@ func (p *localAgent) AgentName() string {
}
func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
top, err := p.ds.MediaFile().GetAll(ctx, model.QueryOptions{
top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Sort: "playCount",
Order: "desc",
Max: count,
@ -39,51 +37,14 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
if err != nil {
return nil, err
}
return songsFrom(top), nil
}
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
seed, err := p.ds.MediaFile().Get(ctx, id)
if err != nil {
return nil, err
var result []Song
for _, s := range top {
result = append(result, Song{
Name: s.Title,
MBID: s.MbzReleaseTrackID,
})
}
// Tag ids derive from (name, value), so the seed's genre ids need no extra query.
genreIDs := slice.Map(seed.Tags.Flatten(model.TagGenre), func(t model.Tag) string { return t.ID })
if len(genreIDs) == 0 {
return nil, nil
}
// Ask for extra so we can drop the seed itself and still fill the count.
candidates, err := p.ds.MediaFile().GetRandom(ctx, model.QueryOptions{
Filters: squirrel.And{
persistence.SongGenres.ByID(genreIDs),
squirrel.Eq{"missing": false},
},
Max: count + 1,
})
if err != nil {
return nil, err
}
filtered := make(model.MediaFiles, 0, len(candidates))
for _, s := range candidates {
if s.ID == id {
continue
}
filtered = append(filtered, s)
if len(filtered) >= count {
break
}
}
return songsFrom(filtered), nil
}
func songsFrom(mfs model.MediaFiles) []Song {
if len(mfs) == 0 {
return nil
}
return slice.Map(mfs, func(mf model.MediaFile) Song {
return Song{ID: mf.ID, Name: mf.Title}
})
return result, nil
}
func init() {

View file

@ -1,96 +0,0 @@
package agents
import (
"context"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("localAgent GetSimilarSongsByTrack", func() {
var ds *tests.MockDataStore
var mfRepo *tests.MockMediaFileRepo
var agent *localAgent
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
mfRepo = &tests.MockMediaFileRepo{}
ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
agent = &localAgent{ds: ds}
})
It("excludes the seed track from its own similars", func() {
seed := model.MediaFile{ID: "seed-1", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
related := model.MediaFile{ID: "rel-1", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
// SetData keys by ID; a duplicate "seed-1" entry would clobber the real seed.
mfRepo.SetData(model.MediaFiles{seed, related})
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-1", "Seed", "", "", 10)
Expect(err).ToNot(HaveOccurred())
names := slice.Map(songs, func(s Song) string { return s.Name })
Expect(names).ToNot(ContainElement("Seed"))
})
// The mock ignores QueryOptions.Filters, so assert the predicate itself: otherwise this spec
// would pass just as well with no genre filter at all.
It("queries the indexed genre join for the seed's own genres, skipping missing files", func() {
rock := model.NewTag(model.TagGenre, "Rock")
seed := model.MediaFile{ID: "seed-4", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
mfRepo.SetData(model.MediaFiles{seed})
_, err := agent.GetSimilarSongsByTrack(ctx, "seed-4", "Seed", "", "", 10)
Expect(err).ToNot(HaveOccurred())
sql, args, sqlErr := mfRepo.Options.Filters.ToSql()
Expect(sqlErr).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("media_file_tags"), "must use the indexed join, not a json_tree scan")
Expect(sql).To(ContainSubstring("missing"))
Expect(args).To(ContainElement(false), "must exclude missing files, not select them")
Expect(args).To(ContainElement(rock.ID), "must filter on the seed's own genre tag id")
Expect(args).ToNot(ContainElement(model.NewTag(model.TagGenre, "Jazz").ID))
})
It("returns the library id so the matcher can resolve the song", func() {
seed := model.MediaFile{ID: "seed-3", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
// Without the id the matcher falls through to its MBID/title phases and resolves nothing,
// so the local fallback silently returns an empty mix.
related := model.MediaFile{ID: "rel-3", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
mfRepo.SetData(model.MediaFiles{seed, related})
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-3", "Seed", "", "", 10)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(ContainElement(Song{ID: "rel-3", Name: "Related"}))
})
It("asks for one extra candidate so dropping the seed still fills the count", func() {
// The mock returns rows sorted by id, so the seed comes first and would consume the only
// slot if the query did not over-fetch.
seed := model.MediaFile{ID: "a-seed", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
related := model.MediaFile{ID: "b-rel", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
mfRepo.SetData(model.MediaFiles{seed, related})
songs, err := agent.GetSimilarSongsByTrack(ctx, "a-seed", "Seed", "", "", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].Name).To(Equal("Related"))
})
It("returns nil when the seed track has no genres", func() {
seed := model.MediaFile{ID: "seed-2", Title: "NoGenre"}
mfRepo.SetData(model.MediaFiles{seed})
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-2", "NoGenre", "", "", 10)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
// Without the early return an empty tag filter would scan the whole library.
Expect(mfRepo.Options).To(Equal(model.QueryOptions{}), "must not query at all")
})
})

View file

@ -13,13 +13,13 @@ type SessionKeys struct {
}
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey)
return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey)
}
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
return sk.DataStore.UserProps().Get(ctx, userId, sk.KeyName)
return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName)
}
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
return sk.DataStore.UserProps().Delete(ctx, userId, sk.KeyName)
return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName)
}

View file

@ -1,27 +0,0 @@
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,32 +2,22 @@ package core
import (
"archive/zip"
"cmp"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
const archiveCoverArtSize = 500
type Archiver interface {
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
@ -35,53 +25,43 @@ type Archiver interface {
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
}
func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork}
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver {
return &archiver{ds: ds, ms: ms, shares: shares}
}
type archiver struct {
ds model.DataStore
ms stream.MediaStreamer
decider stream.TranscodeDecider
shares Share
artwork artwork.Artwork
ds model.DataStore
ms stream.MediaStreamer
shares Share
}
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}, model.ArtworkID{})
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id})
}
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
// Match by album-artist participation, not the deprecated album_artist_id
// column (first album artist only), so co-album-artists are included too.
filter := squirrel.And{
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
squirrel.Eq{"missing": false},
}
return a.zipAlbums(ctx, id, format, bitrate, out, filter, model.Artist{ID: id}.CoverArtID())
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_artist_id": id})
}
// rootArt, when set, is added to the archive root.
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer, rootArt model.ArtworkID) error {
mfs, err := a.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: filters, Sort: "album"})
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error {
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"})
if err != nil {
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
return err
}
z := createZipWriter(out, format, bitrate)
albums := slice.GroupOrdered(mfs, func(mf model.MediaFile) string { return mf.AlbumID })
folders := albumFolders(albums)
albums := slice.Group(mfs, func(mf model.MediaFile) string {
return mf.AlbumID
})
for _, album := range albums {
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
isMultiDisc := len(discs) > 1
folder := folders[album[0].AlbumID]
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder,
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist,
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
for _, mf := range album {
req := a.resolveRequest(ctx, &mf, format, bitrate)
file := a.albumFilename(mf, req.Format, isMultiDisc, folder)
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
file := a.albumFilename(mf, format, isMultiDisc)
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
// Stop iterating: continuing would just rack up more
// rejections from the limiter. Close finalises whatever
// tracks were already written; the rejected one is not
@ -91,10 +71,7 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
return addErr
}
}
// After the tracks, so a slow artwork lookup doesn't delay the first bytes.
a.addCoverArtToZip(ctx, z, album[0].AlbumCoverArtID(), folder)
}
a.addCoverArtToZip(ctx, z, rootArt, "")
err = z.Close()
if err != nil {
log.Error(ctx, "Error closing zip file", "id", id, err)
@ -112,61 +89,7 @@ func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer {
return z
}
// Tried in order; the first one whose values are distinct across the clashing albums wins.
// One album may have an empty value: it keeps the plain name, which the others can't clash with.
var albumDisambiguators = []func(model.MediaFile) string{
func(mf model.MediaFile) string { return mf.Tags.First(model.TagAlbumVersion) },
func(mf model.MediaFile) string {
// Reissues share Year (often the original's) but not ReleaseYear.
if y := cmp.Or(mf.ReleaseYear, mf.Year); y != 0 {
return strconv.Itoa(y)
}
return ""
},
func(mf model.MediaFile) string { return mf.MbzAlbumType },
func(mf model.MediaFile) string { return mf.Tags.First(model.TagRecordLabel) },
func(mf model.MediaFile) string { return mf.CatalogNum },
func(mf model.MediaFile) string { return mf.AlbumID[:min(6, len(mf.AlbumID))] },
func(mf model.MediaFile) string { return mf.AlbumID },
}
// albumFolders maps each album id to its zip folder. Albums whose names sanitize to the
// same folder get a " [suffix]" from the first disambiguator that tells them all apart.
func albumFolders(albums [][]model.MediaFile) map[string]string {
byName := map[string][]model.MediaFile{}
for _, album := range albums {
name := str.SanitizeFilename(album[0].FullAlbumName())
byName[name] = append(byName[name], album[0])
}
folders := make(map[string]string, len(albums))
for name, group := range byName {
if len(group) == 1 {
folders[group[0].AlbumID] = name
continue
}
fields:
for _, field := range albumDisambiguators {
ids := make(map[string]string, len(group)) // suffix -> album id
for _, mf := range group {
s := str.SanitizeFilename(field(mf))
if _, dup := ids[s]; dup {
continue fields
}
ids[s] = mf.AlbumID
}
for s, id := range ids {
folders[id] = name
if s != "" {
folders[id] = fmt.Sprintf("%s [%s]", name, s)
}
}
break
}
}
return folders
}
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool, folder string) string {
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool) string {
_, file := filepath.Split(mf.Path)
if format != "raw" {
file = strings.TrimSuffix(file, mf.Suffix) + format
@ -174,7 +97,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
if isMultiDisc {
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
}
return fmt.Sprintf("%s/%s", folder, file)
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
}
// ZipShare takes an already-loaded share: Share.Load records a visit, so
@ -184,31 +107,27 @@ func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer)
return model.ErrNotAuthorized
}
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
// The share is the authorization (as in the public image handler): an anonymous lookup would
// hide a private playlist. Only the cover read is elevated.
coverCtx := request.WithUser(ctx, model.User{IsAdmin: true})
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, coverCtx, s.CoverArtID(), false)
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
}
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
pls, err := a.ds.Playlist().GetWithTracks(ctx, id, true, false)
pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true, false)
if err != nil {
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
return err
}
mfs := pls.MediaFiles()
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, ctx, pls.CoverArtID(), true)
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
}
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, coverCtx context.Context, coverArt model.ArtworkID, addM3U bool) error {
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
z := createZipWriter(out, format, bitrate)
zippedMfs := make(model.MediaFiles, len(mfs))
for idx, mf := range mfs {
req := a.resolveRequest(ctx, &mf, format, bitrate)
file := a.playlistFilename(mf, req.Format, idx)
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
file := a.playlistFilename(mf, format, idx)
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
// Abort the whole archive: continuing would silently emit
// empty zip entries since the headers are already written.
_ = z.Close()
@ -217,7 +136,6 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
mf.Path = file
zippedMfs[idx] = mf
}
a.addCoverArtToZip(coverCtx, z, coverArt, "")
// Add M3U file if requested
if addM3U && len(zippedMfs) > 0 {
@ -254,14 +172,7 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
}
func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request {
if format == "" || format == "raw" {
return stream.Request{Format: "raw"}
}
return a.decider.ResolveRequest(ctx, mf, format, bitrate, 0)
}
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, req stream.Request, filename string) error {
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
path := mf.AbsolutePath()
// Open the source before writing the zip entry header so a rejection
@ -269,13 +180,13 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
// archive.
var r io.ReadCloser
var err error
if req.Format != "raw" {
r, err = a.ms.NewStream(ctx, &mf, req)
if format != "raw" && format != "" {
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
} else {
r, err = os.Open(path)
}
if err != nil {
log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.Format, err)
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
return err
}
defer func() {
@ -302,61 +213,3 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
return nil
}
// addCoverArtToZip adds the cover as dir/folder.<ext>. Errors are logged, never returned.
func (a *archiver) addCoverArtToZip(ctx context.Context, z *zip.Writer, artID model.ArtworkID, dir string) {
if artID.ID == "" {
return
}
// Buffered so a failed read leaves no empty entry.
data, err := a.readCoverArt(ctx, artID)
if errors.Is(err, artwork.ErrUnavailable) || errors.Is(err, model.ErrNotFound) {
log.Debug(ctx, "No cover art to add to zip", "artID", artID)
return
}
if err != nil {
log.Warn(ctx, "Error reading cover art for zipping", "artID", artID, err)
return
}
ext := coverArtExtension(data)
if ext == "" {
log.Warn(ctx, "Unknown cover art image type, not adding it to zip", "artID", artID)
return
}
w, err := z.CreateHeader(&zip.FileHeader{
Name: path.Join(dir, "folder."+ext),
Modified: time.Now(),
Method: zip.Store,
})
if err != nil {
log.Warn(ctx, "Error creating cover art zip entry", "artID", artID, err)
return
}
if _, err = w.Write(data); err != nil {
log.Warn(ctx, "Error zipping cover art", "artID", artID, err)
}
}
func (a *archiver) readCoverArt(ctx context.Context, artID model.ArtworkID) ([]byte, error) {
img, err := a.artwork.Get(ctx, artID, archiveCoverArtSize, false)
if err != nil {
return nil, err
}
defer img.Close()
return io.ReadAll(img)
}
// Resizing may re-encode the image, so the type comes from its bytes.
func coverArtExtension(data []byte) string {
switch http.DetectContentType(data) {
case "image/jpeg":
return "jpg"
case "image/png":
return "png"
case "image/webp":
return "webp"
case "image/gif":
return "gif"
}
return ""
}

View file

@ -4,19 +4,13 @@ import (
"archive/zip"
"bytes"
"context"
"errors"
"io"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
@ -26,19 +20,15 @@ var _ = Describe("Archiver", func() {
var (
arch core.Archiver
ms *mockMediaStreamer
dc *fakeDecider
ds *mockDataStore
sh *mockShare
ca *mockCoverArt
)
BeforeEach(func() {
ms = &mockMediaStreamer{}
dc = &fakeDecider{}
sh = &mockShare{}
ds = &mockDataStore{}
ca = &mockCoverArt{images: map[string][]byte{}}
arch = core.NewArchiver(ms, dc, ds, sh, ca)
arch = core.NewArchiver(ms, ds, sh)
})
Context("ZipAlbum", func() {
@ -54,7 +44,7 @@ var _ = Describe("Archiver", func() {
Sort: "album",
}}).Return(mfs, nil)
ds.On("MediaFile").Return(mfRepo)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
out := new(bytes.Buffer)
@ -68,23 +58,6 @@ var _ = Describe("Archiver", func() {
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
})
It("streams the request resolved by the transcode decider and names the entry after its format", func() {
mfRepo := &mockMediaFileRepository{}
mfRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{Path: "test_data/01 - track1.flac", Suffix: "flac", AlbumID: "1"}}, nil)
ds.On("MediaFile").Return(mfRepo)
resolved := stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2}
dc.resolved = &resolved
ms.On("NewStream", mock.Anything, mock.Anything, resolved).Return(io.NopCloser(strings.NewReader("test")), nil).Once()
out := new(bytes.Buffer)
Expect(arch.ZipAlbum(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
ms.AssertExpectations(GinkgoT())
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).ToNot(HaveOccurred())
Expect(zr.File[0].Name).To(HaveSuffix("01 - track1.opus"))
})
})
Context("ZipArtist", func() {
@ -96,14 +69,11 @@ var _ = Describe("Archiver", func() {
mfRepo := &mockMediaFileRepository{}
mfRepo.On("GetAll", []model.QueryOptions{{
Filters: squirrel.And{
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
squirrel.Eq{"missing": false},
},
Sort: "album",
Filters: squirrel.Eq{"album_artist_id": "1"},
Sort: "album",
}}).Return(mfs, nil)
ds.On("MediaFile").Return(mfRepo)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
@ -117,140 +87,6 @@ var _ = Describe("Archiver", func() {
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
})
When("albums that share a name", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
// zipArtistEntries zips the given tracks as artist "1" and returns the entry names in zip order.
zipArtistEntries := func(mfs model.MediaFiles) []string {
mfRepo := &mockMediaFileRepository{}
mfRepo.On("GetAll", mock.Anything).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
out := new(bytes.Buffer)
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).To(BeNil())
names := make([]string, len(zr.File))
for i, f := range zr.File {
names[i] = f.Name
}
return names
}
It("keeps the albums in query order", func() {
names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Album C"},
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
{Path: "a/02.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album B"},
})
Expect(names).To(Equal([]string{"Album C/01.mp3", "Album A/01.mp3", "Album A/02.mp3", "Album B/01.mp3"}))
})
It("suffixes the year when it tells the albums apart", func() {
names := zipArtistEntries(model.MediaFiles{
{Path: "a/01 - Intro.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
{Path: "b/01 - Intro.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
})
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01 - Intro.mp3", "Greatest Hits [2005]/01 - Intro.mp3"}))
})
It("prefers the release year, so reissues of the same original are told apart", func() {
names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2001},
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2011},
{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 1996},
})
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2011]/01.mp3", "Greatest Hits [1996]/01.mp3"}))
})
It("names the folder after the full album name", func() {
names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
})
Expect(names).To(Equal([]string{"Greatest Hits (Original)/01.mp3", "Greatest Hits (CD_Digital)/01.mp3"}))
})
It("prefers the album version over the year when it is not part of the name", func() {
conf.Server.Subsonic.AppendAlbumVersion = false
names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
})
Expect(names).To(Equal([]string{"Greatest Hits [Original]/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
})
It("leaves the one album without the field unsuffixed", func() {
conf.Server.Subsonic.AppendAlbumVersion = false
names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
})
Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
})
It("skips a field that is empty on more than one album", func() {
conf.Server.Subsonic.AppendAlbumVersion = false
names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 2010,
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
})
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2005]/01.mp3", "Greatest Hits [2010]/01.mp3"}))
})
It("skips a field that is the same on every album", func() {
names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-1"},
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-2"},
})
Expect(names).To(Equal([]string{"Live [CAT-1]/01.mp3", "Live [CAT-2]/01.mp3"}))
})
It("falls back to the album id when nothing differs", func() {
names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "0123456789abcdef", Album: "Greatest Hits", Year: 2001},
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "fedcba9876543210", Album: "Greatest Hits", Year: 2001},
})
Expect(names).To(Equal([]string{"Greatest Hits [012345]/01.mp3", "Greatest Hits [fedcba]/01.mp3"}))
})
It("treats names that sanitize to the same folder as a clash", func() {
names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "A/B", Year: 2001},
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: `A\B`, Year: 2005},
})
Expect(names).To(Equal([]string{"A_B [2001]/01.mp3", "A_B [2005]/01.mp3"}))
})
It("sanitizes the suffix", func() {
conf.Server.Subsonic.AppendAlbumVersion = false
names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"Vinyl"}}},
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
})
Expect(names).To(Equal([]string{"Hits [Vinyl]/01.mp3", "Hits [CD_Digital]/01.mp3"}))
})
It("leaves the folder name alone when only one album has it", func() {
names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Other", Year: 2005},
})
Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Other/01.mp3"}))
})
})
})
Context("when the transcode limiter rejects a file", func() {
@ -265,7 +101,7 @@ var _ = Describe("Archiver", func() {
Filters: squirrel.Eq{"album_id": "1"},
Sort: "album",
}}).Return(mfs, nil)
ds.On("MediaFile").Return(mfRepo)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
Return(nil, stream.ErrTooManyTranscodes).Once()
@ -315,30 +151,6 @@ var _ = Describe("Archiver", func() {
})
Context("ZipPlaylist", func() {
It("names the entries and the M3U lines after the resolved format", func() {
pls := &model.Playlist{ID: "1", Name: "Test Playlist", Tracks: []model.PlaylistTrack{
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.flac", Suffix: "flac", Artist: "Artist 1", Title: "track1"}},
}}
plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
ds.On("Playlist").Return(plRepo)
dc.resolved = &stream.Request{Format: "opus", BitRate: 128}
ms.On("NewStream", mock.Anything, mock.Anything, *dc.resolved).Return(io.NopCloser(strings.NewReader("test")), nil)
out := new(bytes.Buffer)
Expect(arch.ZipPlaylist(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).ToNot(HaveOccurred())
Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.opus"))
m3u, err := zr.File[1].Open()
Expect(err).ToNot(HaveOccurred())
defer m3u.Close()
content, err := io.ReadAll(m3u)
Expect(err).ToNot(HaveOccurred())
Expect(string(content)).To(ContainSubstring("01 - Artist 1 - track1.opus"))
})
It("zips a playlist correctly", func() {
tracks := []model.PlaylistTrack{
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
@ -353,7 +165,7 @@ var _ = Describe("Archiver", func() {
plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
ds.On("Playlist").Return(plRepo)
ds.On("Playlist", mock.Anything).Return(plRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
@ -380,195 +192,24 @@ var _ = Describe("Archiver", func() {
Expect(string(m3uContent)).To(Equal(expectedM3U))
})
})
Context("cover art", func() {
var (
jpegData = []byte("\xff\xd8\xff\xe0 fake jpeg")
pngData = []byte("\x89PNG\x0d\x0a\x1a\x0a fake png")
)
mockAlbumTracks := func(filter squirrel.Sqlizer, mfs model.MediaFiles) {
mfRepo := &mockMediaFileRepository{}
mfRepo.On("GetAll", []model.QueryOptions{{Filters: filter, Sort: "album"}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
}
It("adds the album cover to the album folder", func() {
ca.images["al-1"] = jpegData
mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
})
out := new(bytes.Buffer)
Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed())
files := readZip(out)
Expect(files).To(HaveLen(2))
Expect(files).To(HaveKeyWithValue("Album_Promo/folder.jpg", jpegData))
Expect(ca.requests).To(ConsistOf(coverRequest{id: "al-1", size: 500, square: false}))
})
It("adds the artist image to the root and each album cover to its folder", func() {
ca.images["ar-1"] = pngData
ca.images["al-1"] = jpegData
ca.images["al-2"] = jpegData
mockAlbumTracks(squirrel.And{
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
squirrel.Eq{"missing": false},
}, model.MediaFiles{
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album 2", DiscNumber: 1},
})
out := new(bytes.Buffer)
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
files := readZip(out)
Expect(files).To(HaveLen(5))
Expect(files).To(HaveKeyWithValue("folder.png", pngData))
Expect(files).To(HaveKeyWithValue("Album 1/folder.jpg", jpegData))
Expect(files).To(HaveKeyWithValue("Album 2/folder.jpg", jpegData))
})
It("puts each same-named album's cover in that album's own folder", func() {
ca.images["al-1"] = jpegData
ca.images["al-2"] = pngData
mockAlbumTracks(squirrel.And{
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
squirrel.Eq{"missing": false},
}, model.MediaFiles{
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, DiscNumber: 1},
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, DiscNumber: 1},
})
out := new(bytes.Buffer)
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
files := readZip(out)
Expect(files).To(HaveKeyWithValue("Greatest Hits [2001]/folder.jpg", jpegData))
Expect(files).To(HaveKeyWithValue("Greatest Hits [2005]/folder.png", pngData))
})
It("adds the playlist cover to the root", func() {
ca.images["pl-1"] = jpegData
plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true, false).Return(&model.Playlist{
ID: "1",
Name: "Test Playlist",
Tracks: []model.PlaylistTrack{
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Artist: "Artist 1", Title: "track1"}},
},
}, nil)
ds.On("Playlist", mock.Anything).Return(plRepo)
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
out := new(bytes.Buffer)
Expect(arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
files := readZip(out)
Expect(files).To(HaveLen(3))
Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData))
Expect(files).To(HaveKey("Test Playlist.m3u"))
})
It("adds the shared item's cover to the root, even for a private playlist", func() {
ca.images["pl-10"] = jpegData
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
share := &model.Share{
ID: "1",
Downloadable: true,
Format: "mp3",
MaxBitRate: 128,
ResourceType: "playlist",
ResourceIDs: "10",
Tracks: model.MediaFiles{
{ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"},
},
}
out := new(bytes.Buffer)
Expect(arch.ZipShare(context.Background(), share, out)).To(Succeed())
files := readZip(out)
Expect(files).To(HaveLen(2))
Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData))
Expect(ca.requests).To(ConsistOf(coverRequest{id: "pl-10", size: 500, square: false, admin: true}))
})
It("still builds the archive when the cover cannot be read", func() {
ca.err = errors.New("boom")
mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
})
out := new(bytes.Buffer)
Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed())
files := readZip(out)
Expect(files).To(HaveLen(1))
Expect(files).To(HaveKey("Album/01 - track1.mp3"))
})
})
})
func readZip(out *bytes.Buffer) map[string][]byte {
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).ToNot(HaveOccurred())
files := make(map[string][]byte, len(zr.File))
for _, f := range zr.File {
r, err := f.Open()
Expect(err).ToNot(HaveOccurred())
data, err := io.ReadAll(r)
Expect(err).ToNot(HaveOccurred())
_ = r.Close()
files[f.Name] = data
}
return files
}
type coverRequest struct {
id string
size int
square bool
admin bool
}
type mockCoverArt struct {
artwork.Artwork
images map[string][]byte
err error
requests []coverRequest
}
func (m *mockCoverArt) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*artwork.Image, error) {
user, _ := request.UserFrom(ctx)
m.requests = append(m.requests, coverRequest{id: artID.String(), size: size, square: square, admin: user.IsAdmin})
if m.err != nil {
return nil, m.err
}
data, ok := m.images[artID.String()]
if !ok {
return nil, artwork.ErrUnavailable
}
return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(data))}, nil
}
type mockDataStore struct {
mock.Mock
model.DataStore
}
func (m *mockDataStore) MediaFile() model.MediaFileRepository {
args := m.Called()
func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
args := m.Called(ctx)
return args.Get(0).(model.MediaFileRepository)
}
func (m *mockDataStore) Playlist() model.PlaylistRepository {
args := m.Called()
func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
args := m.Called(ctx)
return args.Get(0).(model.PlaylistRepository)
}
func (m *mockDataStore) Library() model.LibraryRepository {
func (m *mockDataStore) Library(context.Context) model.LibraryRepository {
return &mockLibraryRepository{}
}
@ -577,7 +218,7 @@ type mockLibraryRepository struct {
model.LibraryRepository
}
func (m *mockLibraryRepository) GetPath(_ context.Context, id int) (string, error) {
func (m *mockLibraryRepository) GetPath(id int) (string, error) {
return "/music", nil
}
@ -586,7 +227,7 @@ type mockMediaFileRepository struct {
model.MediaFileRepository
}
func (m *mockMediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
args := m.Called(options)
return args.Get(0).(model.MediaFiles), args.Error(1)
}
@ -596,7 +237,7 @@ type mockPlaylistRepository struct {
model.PlaylistRepository
}
func (m *mockPlaylistRepository) GetWithTracks(_ context.Context, id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
args := m.Called(id, refreshSmartPlaylists, includeMissing)
return args.Get(0).(*model.Playlist), args.Error(1)
}
@ -614,19 +255,6 @@ func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile,
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
}
// fakeDecider echoes the legacy format/bitrate unless a resolved request is set.
type fakeDecider struct {
stream.TranscodeDecider
resolved *stream.Request
}
func (f *fakeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request {
if f.resolved != nil {
return *f.resolved
}
return stream.Request{Format: format, BitRate: bitRate, Offset: offset}
}
type mockShare struct {
mock.Mock
core.Share

View file

@ -2,7 +2,6 @@ package artwork
import (
"context"
"errors"
"io"
"net/url"
@ -42,38 +41,17 @@ func bestImageURL(imgs []agents.ExternalImage) *url.URL {
return best
}
// 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
// fetchArtistImage tries each enabled artist-image agent in order. extErr is true only when no
// agent succeeded and at least one failed transiently.
func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (io.ReadCloser, string, error) {
func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (r io.ReadCloser, agentName string, extErr bool) {
// 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, "", nil
return nil, "", false
}
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, "", nil
}
var extErr error
for _, a := range imageAgents {
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
for _, a := range ag.ArtistImageAgents() {
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID)
if err != nil {
return nil, "", err
@ -84,12 +62,11 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
}
return fromURL(ctx, u)
})
recordAgent(ctx, a.Name, reader, path, err)
if reader != nil {
return reader, a.Name, nil
return reader, a.Name, false
}
if isTransientExternal(err) {
extErr = longerRetry(extErr, err)
extErr = true
log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err)
}
}
@ -97,17 +74,10 @@ 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) (io.ReadCloser, string, error) {
func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) {
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, "", nil
}
var extErr error
for _, a := range imageAgents {
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
for _, a := range ag.AlbumImageAgents() {
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID)
if err != nil {
return nil, "", err
@ -118,12 +88,11 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m
}
return fromURL(ctx, u)
})
recordAgent(ctx, a.Name, reader, path, err)
if reader != nil {
return reader, a.Name, nil
return reader, a.Name, false
}
if isTransientExternal(err) {
extErr = longerRetry(extErr, err)
extErr = true
log.Debug(ctx, "Artwork: External album-image lookup failed", "agent", a.Name, "album", al.Name, err)
}
}

View file

@ -2,13 +2,11 @@ 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"
@ -155,11 +153,11 @@ var _ = Describe("agent images", func() {
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
ag := imageAgents(a)
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"})
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"})
Expect(r).ToNot(BeNil())
defer r.Close()
Expect(name).To(Equal("agentA"))
Expect(err).ToNot(HaveOccurred())
Expect(extErr).To(BeFalse())
})
It("skips the external lookup for synthetic artists", func() {
@ -167,38 +165,14 @@ var _ = Describe("agent images", func() {
ag := imageAgents(a)
for _, id := range []string{consts.UnknownArtistID, consts.VariousArtistsID} {
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"})
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"})
Expect(r).To(BeNil())
Expect(name).To(BeEmpty())
Expect(err).ToNot(HaveOccurred())
Expect(extErr).To(BeFalse())
}
Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents")
})
It("records a skipped external candidate when no agent provides artist images", func() {
ag := imageAgents()
t := &ChainTrace{}
r, _, err := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"})
Expect(r).To(BeNil())
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")
})
It("records a skipped external candidate for synthetic artists", func() {
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
ag := imageAgents(a)
t := &ChainTrace{}
_, _, _ = fetchArtistImage(withTrace(ctx, t), ag, passthroughGate,
model.Artist{ID: consts.VariousArtistsID, Name: "Various Artists"})
Expect(t.Steps()).To(HaveLen(1))
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeSkipped))
Expect(t.Steps()[0].Detail).To(ContainSubstring("synthetic"))
})
It("clears typographic characters from the query name unless preserving unicode", func() {
conf.Server.DevPreserveUnicodeInExternalCalls = false
a := &fakeImageAgent{name: "agentA"}
@ -213,11 +187,11 @@ var _ = Describe("agent images", func() {
b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}}
ag := imageAgents(a, b)
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
Expect(r).ToNot(BeNil())
defer r.Close()
Expect(name).To(Equal("agentB"))
Expect(err).ToNot(HaveOccurred(), "a later hit clears an earlier agent's error")
Expect(extErr).To(BeFalse(), "a later hit clears an earlier agent's error")
Expect(a.artistCalls).To(Equal(1))
Expect(b.artistCalls).To(Equal(1))
})
@ -227,43 +201,20 @@ var _ = Describe("agent images", func() {
b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound}
ag := imageAgents(a, b)
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
Expect(r).To(BeNil())
Expect(name).To(BeEmpty())
Expect(err).ToNot(HaveOccurred(), "not-found is definitive, never a transient failure")
Expect(extErr).To(BeFalse(), "not-found is definitive, never a transient failure")
})
It("reports an error when one agent fails transiently and the rest find nothing", func() {
It("reports extErr 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, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
r, _, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
Expect(r).To(BeNil())
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")
Expect(extErr).To(BeTrue())
})
})
@ -272,33 +223,21 @@ var _ = Describe("agent images", func() {
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
ag := imageAgents(a)
r, name, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"})
r, name, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"})
Expect(r).ToNot(BeNil())
defer r.Close()
Expect(name).To(Equal("agentA"))
Expect(err).ToNot(HaveOccurred())
Expect(extErr).To(BeFalse())
Expect(a.albumCalls).To(Equal(1))
})
It("records a skipped external candidate when no agent provides album images", func() {
ag := imageAgents()
t := &ChainTrace{}
r, _, err := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"})
Expect(r).To(BeNil())
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 an error when the only agent fails transiently", func() {
It("reports extErr when the only agent fails transiently", func() {
a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded}
ag := imageAgents(a)
r, _, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"})
r, _, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"})
Expect(r).To(BeNil())
Expect(err).To(HaveOccurred())
Expect(extErr).To(BeTrue())
})
})

View file

@ -11,7 +11,6 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -59,21 +58,21 @@ func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID
var err error
switch artID.Kind {
case model.KindArtistArtwork:
found, err = ds.Artist().Exists(ctx, artID.ID)
found, err = ds.Artist(ctx).Exists(artID.ID)
case model.KindAlbumArtwork:
found, err = ds.Album().Exists(ctx, artID.ID)
found, err = ds.Album(ctx).Exists(artID.ID)
case model.KindMediaFileArtwork:
found, err = ds.MediaFile().Exists(ctx, artID.ID)
found, err = ds.MediaFile(ctx).Exists(artID.ID)
case model.KindPlaylistArtwork:
found, err = ds.Playlist().Exists(ctx, artID.ID)
found, err = ds.Playlist(ctx).Exists(artID.ID)
case model.KindRadioArtwork:
found, err = ds.Radio().Exists(ctx, artID.ID)
found, err = ds.Radio(ctx).Exists(artID.ID)
case model.KindDiscArtwork:
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
if perr != nil {
return false
}
found, err = ds.Album().Exists(ctx, albumID)
found, err = ds.Album(ctx).Exists(albumID)
default:
return false
}
@ -118,15 +117,22 @@ 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().GetItemArtwork(ctx, artID.Kind, artID.ID, model.ImageTypePrimary)
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary)
switch {
case errors.Is(err, model.ErrNotFound):
return s.provisional(ctx, artID, size, square)
case err != nil:
return nil, err
case ia.Hash == "":
// Settled absent: only an explicit reprocess or refresh retries it.
// Inserts an immediately-eligible recheck for a settled absent row.
if time.Since(ia.AttemptedAt) > requestRecheckAge {
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
}
return nil, ErrUnavailable
default:
return s.serveHash(ctx, artID, ia, size, square)
@ -168,12 +174,7 @@ 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().GetImage(ctx, ia.Hash)
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return s.dangling(ctx, artID)
@ -264,13 +265,13 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
// The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL
// must fall back to disc/album instead of serving stale persisted embedded art.
if !conf.Server.EnableMediaFileCoverArt {
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
if err != nil {
return nil, err
}
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
}
ia, err := s.ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
switch {
case err == nil && ia.Hash != "":
return s.serveHash(ctx, artID, ia, size, square)
@ -283,7 +284,7 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
}
noRow := errors.Is(err, model.ErrNotFound)
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
if err != nil {
return nil, err
}
@ -316,15 +317,16 @@ func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int
return nil, err
}
// Single-disc albums run the chain too: a disc can carry art distinct from the album cover.
selectImage := func() (io.ReadCloser, error) {
res, err := dr.selectImage(ctx, s.ffmpeg, conf.Server.DiscArtPriority, &chainState{})
return res.reader, err
selectImage := func() (io.ReadCloser, string, error) {
funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority)
return selectImageReader(ctx, artID, funcs...)
}
albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID}
// Disc art has no state row, hence no content hash: keying on id, album mtime and
// DiscArtPriority lets a warm cache answer without running the chain or touching the disk.
key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority)
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage)
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square,
func() (io.ReadCloser, error) { rc, _, err := selectImage(); return rc, err })
if err != nil {
if errors.Is(err, context.Canceled) {
return nil, err
@ -342,7 +344,7 @@ func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image,
}
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
err := s.ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
ItemKind: artID.Kind.Prefix(),
ItemID: artID.ID,
ImageType: model.ImageTypePrimary,
@ -384,53 +386,6 @@ func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkI
return model.ArtworkID{}, model.ErrNotFound
}
// TracingResolver is the CLI's read-only view of resolution: it walks the priority chain, records
// the walk and reports the winning source, without ever writing artwork state.
type TracingResolver struct {
inner *resolver
trace *ChainTrace
}
// NewTracingResolver builds a TracingResolver that records its priority-chain walk. Without live
// it gets no agents at all, so neither a chain nor any fallback added later can reach a provider;
// with it, one item is at most one call per agent, so the rate limiter and breaker are bypassed.
func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver {
inner := newLocalResolver(ds, ffm)
if live {
inner = newResolver(ds, ag, ffm, passthroughGate)
}
return &TracingResolver{inner: inner, trace: t}
}
// Resolve walks kind's sources for id, recording the walk, and reports the winning source
// ("" when none produced an image).
func (r *TracingResolver) Resolve(ctx context.Context, kind model.Kind, id string) (string, error) {
switch kind {
case model.KindArtistArtwork:
return r.explain(ctx, r.inner.resolveArtist, id)
case model.KindAlbumArtwork:
return r.explain(ctx, r.inner.resolveAlbum, id)
case model.KindDiscArtwork:
return r.explain(ctx, r.inner.resolveDisc, id)
case model.KindMediaFileArtwork:
return r.explain(ctx, r.inner.resolveMediaFile, id)
}
return "", fmt.Errorf("artwork: %s artwork has no chain to explain", kind)
}
// explain discards the bytes: nothing downstream persists this resolution, so nothing else
// would close the reader either.
func (r *TracingResolver) explain(ctx context.Context, resolve func(context.Context, string) (resolution, error), id string) (string, error) {
res, err := resolve(withTrace(ctx, r.trace), id)
if err != nil {
return "", err
}
if res.reader != nil {
_ = res.reader.Close()
}
return res.source, nil
}
func unixMtime(mtime int64) time.Time {
if mtime <= 0 {
return time.Time{}

View file

@ -1,23 +1,19 @@
package artwork
import (
"context"
"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"
@ -41,14 +37,6 @@ 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 }
@ -109,15 +97,15 @@ type fakeFolderRepo struct {
otherAudioErr error
}
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
return f.result, f.err
}
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
return f.hasOtherAudio, f.otherAudioErr
}
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) {
f.getCallCount++
if f.getErr != nil {
return nil, f.getErr

View file

@ -45,8 +45,8 @@ var _ = Describe("Artwork", func() {
hash, err := hashImage(bytes.NewReader(imgBytes))
Expect(err).ToNot(HaveOccurred())
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
seedEntity(kind, id)
return hash
}
@ -56,9 +56,9 @@ var _ = Describe("Artwork", func() {
GinkgoHelper()
switch kind {
case "al":
Expect(albumRepo.Put(ctx, &model.Album{ID: id, Name: "Album"})).To(Succeed())
Expect(albumRepo.Put(&model.Album{ID: id, Name: "Album"})).To(Succeed())
case "mf":
Expect(mfRepo.Put(ctx, &model.MediaFile{ID: id})).To(Succeed())
Expect(mfRepo.Put(&model.MediaFile{ID: id})).To(Succeed())
}
}
@ -147,9 +147,9 @@ var _ = Describe("Artwork", func() {
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
mtime := fileMtime(imgPath)
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "al2")
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
})).To(Succeed())
@ -159,60 +159,13 @@ 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(ctx, &model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "alni")
Expect(artRepo.PutItemArtwork(ctx, &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(ctx, &model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "alnic")
Expect(artRepo.PutItemArtwork(ctx, &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")
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "al3")
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
})).To(Succeed())
@ -220,7 +173,7 @@ var _ = Describe("Artwork", func() {
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
})
@ -229,9 +182,9 @@ var _ = Describe("Artwork", func() {
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "al3b")
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
})).To(Succeed())
@ -251,15 +204,28 @@ var _ = Describe("Artwork", func() {
Expect(err).To(MatchError(ErrUnavailable))
})
It("never re-enqueues an absent state on view, however old", func() {
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
It("does not re-enqueue a recently-attempted absent state", func() {
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now(),
})).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() {
@ -272,7 +238,7 @@ var _ = Describe("Artwork", func() {
Expect(readAll(img)).To(Equal(coverBytes))
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
@ -283,7 +249,7 @@ var _ = Describe("Artwork", func() {
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
@ -310,7 +276,7 @@ var _ = Describe("Artwork", func() {
It("delegates to the album when the track's state is absent", func() {
seedFoundStore("al", "albm", coverBytes)
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
@ -343,7 +309,7 @@ var _ = Describe("Artwork", func() {
Expect(err).ToNot(HaveOccurred())
Expect(len(readAll(img))).To(BeNumerically(">", 0))
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
@ -494,7 +460,7 @@ var _ = Describe("Artwork", func() {
})
It("falls back to the artist placeholder for an absent artist", func() {
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
Expect(err).ToNot(HaveOccurred())
@ -535,7 +501,7 @@ var _ = Describe("EntityExists", func() {
artistRepo := tests.CreateMockArtistRepo()
artistRepo.SetData(model.Artists{{ID: "ar1"}})
radioRepo := tests.CreateMockedRadioRepo()
Expect(radioRepo.Put(ctx, &model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
Expect(radioRepo.Put(&model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
})

View file

@ -52,12 +52,6 @@ func Encode(img image.Image) (string, error) {
lin := srgbToLinearTable()
factors := make([][3]float64, xComp*yComp)
linR := make([]float64, w)
linG := make([]float64, w)
linB := make([]float64, w)
rowR := make([]float64, xComp)
rowG := make([]float64, xComp)
rowB := make([]float64, xComp)
for y := range h {
row := src.pix[y*src.stride:]
for x := range w {
@ -66,26 +60,15 @@ func Encode(img image.Image) (string, error) {
if src.straight {
r, g, b = premultiply(r, g, b, row[p+3])
}
linR[x], linG[x], linB[x] = lin[r], lin[g], lin[b]
}
// The basis is separable, so a row costs xComp dot products plus one fold over yComp,
// rather than xComp*yComp multiply-accumulates per pixel.
for i := range xComp {
var sr, sg, sb float64
for x, c := range cosX[i] {
sr += c * linR[x]
sg += c * linG[x]
sb += c * linB[x]
}
rowR[i], rowG[i], rowB[i] = sr, sg, sb
}
for j := range yComp {
cy := cosY[j][y]
for i := range xComp {
f := &factors[j*xComp+i]
f[0] += cy * rowR[i]
f[1] += cy * rowG[i]
f[2] += cy * rowB[i]
lr, lg, lb := lin[r], lin[g], lin[b]
for j := range yComp {
for i := range xComp {
basis := cosX[i][x] * cosY[j][y]
f := &factors[j*xComp+i]
f[0] += basis * lr
f[1] += basis * lg
f[2] += basis * lb
}
}
}
}

View file

@ -45,7 +45,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
}
al, err := ds.Album().Get(ctx, albumID)
al, err := ds.Album(ctx).Get(albumID)
if err != nil {
return nil, err
}
@ -61,7 +61,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
}
// Query mediafiles for this album + disc to find folder associations and first track
mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Sort: "track_number",
Order: "ASC",
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
@ -88,7 +88,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
// Resolve folder IDs to library-relative paths
discFoldersRel := make(map[string]bool)
if len(folderIDs) > 0 {
folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"folder.id": folderIDs},
})
if err != nil {
@ -113,71 +113,27 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
}, nil
}
// discCandidate is one DiscArtPriority entry. skip is set when the entry maps to no source at
// all, so a chain walk can say why instead of leaving a configured entry unaccounted for.
type discCandidate struct {
pattern string
resolve func() (resolution, bool)
skip string
}
func (d *discArtworkReader) discCandidates(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []discCandidate {
folder := func(sf sourceFunc) func() (resolution, bool) {
return func() (resolution, bool) { return resolveFolderSource(d.lib, sf) }
}
var cc []discCandidate
func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
var ff []sourceFunc
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
c := discCandidate{pattern: pattern}
switch {
case pattern == "embedded":
c.resolve = func() (resolution, bool) {
return resolveEmbedded(ctx, d.lib, ffmpeg, d.firstTrackRel)
}
case pattern == externalCandidate:
c.skip = "external sources are not supported for disc artwork"
ff = append(ff,
fromTag(ctx, d.lib.FS, d.firstTrackRel),
fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)),
)
case pattern == "external":
// Not supported for disc art, silently ignore
case pattern == "discsubtitle":
subtitle := strings.TrimSpace(d.album.Discs[d.discNumber])
if subtitle == "" {
c.skip = "disc has no subtitle"
} else {
c.resolve = folder(d.fromDiscSubtitle(ctx, subtitle))
if subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]); subtitle != "" {
ff = append(ff, d.fromDiscSubtitle(ctx, subtitle))
}
case len(d.imgFiles) == 0:
c.skip = "no images in album folder"
default:
c.resolve = folder(d.fromExternalFile(ctx, pattern))
}
cc = append(cc, c)
}
return cc
}
// selectImage walks the DiscArtPriority entries and returns the first that yields an image.
// chain records the walk; the serving path passes an untraced one and pays nothing for it.
func (d *discArtworkReader) selectImage(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string,
chain *chainState) (resolution, error) {
for _, c := range d.discCandidates(ctx, ffmpeg, priority) {
if err := ctx.Err(); err != nil {
return resolution{}, err
}
if c.skip != "" {
chain.record(c.pattern, OutcomeSkipped, c.skip)
continue
}
start := time.Now()
res, ok := c.resolve()
log.Trace(ctx, "Artwork: Tried a disc artwork candidate", "albumID", d.album.ID,
"disc", d.discNumber, "pattern", c.pattern, "hit", ok, "path", res.sourcePath,
"elapsed", time.Since(start))
if res, ok = chain.try(c.pattern, res, ok); ok {
return res, nil
case len(d.imgFiles) > 0:
ff = append(ff, d.fromExternalFile(ctx, pattern))
}
}
return chain.exhausted(), nil
return ff
}
// fromDiscSubtitle returns a sourceFunc that matches image files whose stem

View file

@ -6,8 +6,6 @@ import (
"path/filepath"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -181,19 +179,19 @@ var _ = Describe("Disc Artwork Reader", func() {
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
cc := reader.discCandidates(ctx, nil, "disc*.*, cover.*")
Expect(cc).To(HaveLen(2))
res, ok := cc[0].resolve()
Expect(ok).To(BeTrue())
Expect(res.sourcePath).To(Equal(reader.lib.Abs(f2)))
res.reader.Close()
ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*")
Expect(ff).To(HaveLen(2))
r, path, err := ff[0]()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(f2))
r.Close()
cc = reader.discCandidates(ctx, nil, "cover.*, disc*.*")
Expect(cc).To(HaveLen(2))
res, ok = cc[0].resolve()
Expect(ok).To(BeTrue())
Expect(res.sourcePath).To(Equal(reader.lib.Abs(f1)))
res.reader.Close()
ff = reader.fromDiscArtPriority(ctx, nil, "cover.*, disc*.*")
Expect(ff).To(HaveLen(2))
r, path, err = ff[0]()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(f1))
r.Close()
})
DescribeTable("numbered match wins over shared fallback within a pattern",
@ -430,109 +428,64 @@ var _ = Describe("Disc Artwork Reader", func() {
})
Describe("discArtworkReader", func() {
var (
reader *discArtworkReader
tmpDir string
)
BeforeEach(func() {
tmpDir = GinkgoT().TempDir()
reader = &discArtworkReader{
discNumber: 2,
isMultiFolder: true,
discFoldersRel: map[string]bool{"music/album/cd2": true},
imgFiles: []string{
"music/album/cd1/disc.jpg",
"music/album/cd2/disc.jpg",
"music/album/cd2/disc2.jpg",
},
firstTrackRel: "music/album/cd2/track1.flac",
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
})
Describe("selectImage", func() {
It("abandons the walk when the context is cancelled", func() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
res, err := reader.selectImage(ctx, nil, "disc*.*, cover.*", &chainState{})
Expect(err).To(MatchError(context.Canceled))
Expect(res.reader).To(BeNil())
})
// "the track has no embedded art" and "the track is there but unreadable" are the two
// answers a wrong-artwork report needs told apart; only the second is worth retrying.
It("reports a track it cannot parse as unreadable, not as a miss", func() {
trace := &ChainTrace{}
track := filepath.Join(tmpDir, filepath.FromSlash(reader.firstTrackRel))
Expect(os.MkdirAll(filepath.Dir(track), 0755)).To(Succeed())
Expect(os.WriteFile(track, []byte("not audio"), 0600)).To(Succeed())
res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded",
&chainState{trace: trace})
Expect(err).ToNot(HaveOccurred())
Expect(res.localError).To(BeTrue())
Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeUnreadable}}))
})
It("reports a disc with no tracks to read as a miss", func() {
trace := &ChainTrace{}
reader.firstTrackRel = ""
res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded",
&chainState{trace: trace})
Expect(err).ToNot(HaveOccurred())
Expect(res.localError).To(BeFalse(), "there was nothing to read, so nothing failed to read")
Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeMiss}}))
})
})
Describe("discCandidates", func() {
It("returns a resolvable candidate for glob patterns", func() {
cc := reader.discCandidates(context.Background(), nil, "disc*.*")
Expect(cc).To(HaveLen(1))
Expect(cc[0].resolve).ToNot(BeNil())
})
It("returns one candidate per entry, in order", func() {
cc := reader.discCandidates(context.Background(), nil, "disc*.*, cd*.*, embedded")
Expect(slice.Map(cc, func(c discCandidate) string { return c.pattern })).
To(Equal([]string{"disc*.*", "cd*.*", "embedded"}))
})
It("skips an empty entry rather than building a glob that matches nothing", func() {
cc := reader.discCandidates(context.Background(), nil, "disc*.*,")
Expect(cc).To(HaveLen(1))
})
// The skip reasons below are what `artwork explain` prints, so an entry that maps to no
// source must say why instead of vanishing from the walk.
DescribeTable("keeps an entry that maps to no source, with its reason",
func(setup func(), priority, reason string) {
setup()
cc := reader.discCandidates(context.Background(), nil, priority)
Expect(cc).To(HaveLen(1))
Expect(cc[0].resolve).To(BeNil())
Expect(cc[0].skip).To(Equal(reason))
},
Entry("external is unsupported", func() {}, "external",
"external sources are not supported for disc artwork"),
Entry("no images in the album folder", func() { reader.imgFiles = nil }, "disc*.*",
"no images in album folder"),
Entry("the disc has no subtitle",
func() { reader.album = model.Album{Discs: model.Discs{2: ""}} }, "discsubtitle",
"disc has no subtitle"),
Describe("fromDiscArtPriority", func() {
var (
reader *discArtworkReader
tmpDir string
)
BeforeEach(func() {
tmpDir = GinkgoT().TempDir()
reader = &discArtworkReader{
discNumber: 2,
isMultiFolder: true,
discFoldersRel: map[string]bool{"music/album/cd2": true},
imgFiles: []string{
"music/album/cd1/disc.jpg",
"music/album/cd2/disc.jpg",
"music/album/cd2/disc2.jpg",
},
firstTrackRel: "music/album/cd2/track1.flac",
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
})
It("returns source funcs for glob patterns", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
Expect(ff).To(HaveLen(1))
})
It("returns source funcs for embedded pattern", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "embedded")
Expect(ff).To(HaveLen(2)) // fromTag + fromFFmpegTag
})
It("handles multiple comma-separated patterns", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*, cd*.*, embedded")
Expect(ff).To(HaveLen(4)) // disc*.* + cd*.* + fromTag + fromFFmpegTag
})
It("ignores 'external' pattern silently", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "external")
Expect(ff).To(HaveLen(0))
})
It("returns no source funcs when imgFiles is empty and pattern is not embedded", func() {
reader.imgFiles = nil
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
Expect(ff).To(HaveLen(0))
})
It("returns source func for discsubtitle pattern", func() {
reader.album = model.Album{Discs: model.Discs{2: "Bonus Tracks"}}
cc := reader.discCandidates(context.Background(), nil, "discsubtitle")
Expect(cc).To(HaveLen(1))
Expect(cc[0].resolve).ToNot(BeNil())
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
Expect(ff).To(HaveLen(1))
})
It("returns no source func for discsubtitle when disc has no subtitle", func() {
reader.album = model.Album{Discs: model.Discs{2: ""}}
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
Expect(ff).To(HaveLen(0))
})
})
})

View file

@ -6,7 +6,6 @@ import (
"errors"
"io"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
@ -44,20 +43,20 @@ var _ = Describe("Acquisition → serve loop", func() {
itemFound := func(kind model.Kind, id string) func() bool {
return func() bool {
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
return err == nil && ia.Hash != ""
}
}
itemAbsent := func(kind model.Kind, id string) func() bool {
return func() bool {
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
return err == nil && ia.Hash == ""
}
}
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
bump := func(kind, id string) {
GinkgoHelper()
Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityBump,
})).To(Succeed())
@ -106,7 +105,7 @@ var _ = Describe("Acquisition → serve loop", func() {
func(context.Context, cache.Item) (io.Reader, error) {
return nil, errors.New("resize not exercised in e2e")
})
Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue())
Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue())
svc = artwork.NewArtwork(ds, imgCache, store, ffm)
worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache)
@ -117,31 +116,12 @@ var _ = Describe("Acquisition → serve loop", func() {
albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
}
It("acquires and serves a cover whose format has no registered decoder (#5950)", func() {
libDir := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(libDir, "an-album"), 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libDir, "an-album", "cover.jxl"), jxlFixture, 0600)).To(Succeed())
conf.Server.CoverArtPriority = "cover.*"
libRepo.SetData(model.Libraries{{ID: 0, Path: libDir}})
folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jxl"}}}
albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
bump("al", "al1")
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Placeholder).To(BeFalse())
Expect(readAll(img)).To(Equal(jxlFixture))
})
It("acquires album folder art and serves the exact bytes under its hash", func() {
seedFolderAlbum("al1")
bump("al", "al1")
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
@ -158,7 +138,7 @@ var _ = Describe("Acquisition → serve loop", func() {
bump("ar", "ar1")
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
ia, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("upload"))
@ -175,14 +155,14 @@ var _ = Describe("Acquisition → serve loop", func() {
bump("pl", "pl1")
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("generated"))
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Hash).To(Equal(ia.Hash))
art, err := artRepo.GetImage(ctx, ia.Hash)
art, err := artRepo.GetImage(ia.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(art.Mime).To(Equal("image/png"))
Expect(len(readAll(img))).To(BeNumerically(">", 0))
@ -194,7 +174,7 @@ var _ = Describe("Acquisition → serve loop", func() {
bump("ra", "ra1")
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("upload"))
@ -216,12 +196,12 @@ var _ = Describe("Acquisition → serve loop", func() {
provisionalBytes := readAll(provisional)
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
// The provisional read enqueued a Bump; drain it.
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("embedded"))
Expect(ia.Hash).To(Equal(provisional.Hash))
@ -237,9 +217,9 @@ var _ = Describe("Acquisition → serve loop", func() {
bump("al", "al1")
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
art, err := artRepo.GetImage(ctx, ia.Hash)
art, err := artRepo.GetImage(ia.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(art.Mime).To(Equal("image/jpeg"))
Expect(art.Width).To(BeNumerically(">", 0))
@ -259,9 +239,9 @@ var _ = Describe("Acquisition → serve loop", func() {
bump("ra", "ra1")
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
art, err := artRepo.GetImage(ctx, ia.Hash)
art, err := artRepo.GetImage(ia.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(art.Mime).To(Equal("image/gif"))
Expect(art.Width).To(BeNumerically("==", 4))
@ -279,9 +259,9 @@ var _ = Describe("Acquisition → serve loop", func() {
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
})
ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash")
Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes))
@ -293,7 +273,7 @@ var _ = Describe("Acquisition → serve loop", func() {
bump("ra", "ra1")
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
staleHash := ia.Hash
@ -308,7 +288,7 @@ var _ = Describe("Acquisition → serve loop", func() {
// That failed read enqueued a re-resolution.
runWorkerUntil(ctx, worker, func() bool {
cur, gerr := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
cur, gerr := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
})
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
@ -337,9 +317,6 @@ func mustGet(img *artwork.Image, err error) *artwork.Image {
}
// Raw bytes on purpose: encoding a GIF here would register image/gif in the test binary, masking
// jxlFixture is a JPEG XL bare codestream header: a real image format, with no stdlib decoder.
var jxlFixture = []byte{0xff, 0x0a, 0x00, 0x10, 0x00}
// the production import the spec above guards.
var gifFixture = []byte{
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x04, 0x00, 0x04, 0x00, 0x80, 0x00,

View file

@ -90,28 +90,6 @@ 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.*)
@ -201,7 +179,7 @@ var _ = Describe("Artist artwork resolution", func() {
uploaded := ar.ID + "_upload.jpg"
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
ar.UploadedImage = uploaded
Expect(rds.Artist().Put(rctx, &ar)).To(Succeed())
Expect(rds.Artist(rctx).Put(&ar)).To(Succeed())
ia := acquire(model.KindArtistArtwork, ar.ID)
Expect(ia.Source).To(Equal("upload"))
@ -279,7 +257,7 @@ var _ = Describe("Artist artwork resolution", func() {
func soleArtist() model.Artist {
GinkgoHelper()
artists, err := rds.Artist().GetAll(rctx, model.QueryOptions{
artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"artist.name": "Artist"},
})
Expect(err).ToNot(HaveOccurred())

View file

@ -56,8 +56,7 @@ 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) }()
// Long enough for one retry (3-7s backoff, 5s poll tick).
Eventually(until, 15*time.Second, 10*time.Millisecond).Should(BeTrue())
Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue())
cancel()
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
}
@ -67,17 +66,13 @@ type fakeFolderRepo struct {
result []model.Folder
}
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
return f.result, nil
}
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil }
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
return false, nil
}
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
return nil, model.ErrNotFound
}
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound }
func writeUpload(entityType, name, srcFixture string) string {
GinkgoHelper()

View file

@ -137,7 +137,7 @@ var _ = Describe("MediaFile artwork resolution", func() {
func mediafileOn(relPath string) model.MediaFile {
GinkgoHelper()
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{
Filters: squirrel.Like{"media_file.path": relPath},
})
Expect(err).ToNot(HaveOccurred())

View file

@ -142,13 +142,13 @@ var _ = Describe("Playlist artwork resolution", func() {
})
scan()
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(2))
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
ia := acquire(model.KindPlaylistArtwork, pl.ID)
Expect(ia.Source).To(Equal("generated"))
@ -180,14 +180,14 @@ var _ = Describe("Playlist artwork resolution", func() {
setLayout(layout)
scan()
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(4))
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
pl.AddMediaFilesByID(ids)
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
ia := acquire(model.KindPlaylistArtwork, pl.ID)
Expect(ia.Source).To(Equal("generated"))
@ -208,6 +208,6 @@ func putPlaylist(pl model.Playlist) model.Playlist {
if pl.OwnerID == "" {
pl.OwnerID = "admin-1"
}
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
return pl
}

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