Compare commits

..
Author SHA1 Message Date
Deluan e329667ab0 fix: address review feedback on authProvider
Remove unused vi import from test file. Use Number() || 0 instead of
parseInt with string fallback to handle corrupted sessionStorage values
that would produce NaN.
2026-05-03 13:50:15 -04:00
Deluan d6c5d2487c style: format authProvider test with prettier 2026-05-03 13:35:34 -04:00
Deluan 33f92275f2 fix(ui): handle network errors in checkError for externalized auth
When using externalized authentication (reverse proxy with Authentik,
Authelia, etc.), expired proxy sessions cause CORS-blocked redirects that
surface as TypeErrors instead of HTTP 401s. The existing checkError only
handled status === 401, so these network errors were shown as vague
"NetworkError" notifications instead of triggering re-authentication.

Enhanced checkError to detect network errors when extAuthLogoutURL is
configured and reload the page to let the proxy redirect to the auth
provider. A sessionStorage-based 30-second guard prevents infinite reload
loops. Also extracted an isNetworkError helper to deduplicate the
detection logic already present in the login method.

Added missing removeItem to the localStorage mock in setupTests, and
added comprehensive tests for the new checkError behavior.
2026-05-03 13:35:04 -04:00
1126 changed files with 17885 additions and 84786 deletions

No files matched your search

+1 -1
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"
+3 -3
View File
@@ -1,10 +1,10 @@
# These are supported funding model platforms
ko_fi: deluan
github: deluan
open_collective: navidrome
liberapay: deluan
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: deluan
liberapay: deluan
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
issuehunt: # Replace with a single IssueHunt username
+4 -10
View File
@@ -53,13 +53,13 @@ runs:
- name: Login to Docker Hub
if: inputs.hub_username != '' && inputs.hub_password != ''
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ inputs.hub_username }}
password: ${{ inputs.hub_password }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -67,18 +67,12 @@ 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"]
uses: docker/setup-buildx-action@v3
- name: Extract metadata for Docker image
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
github-token: ${{ inputs.github_token }}
labels: |
maintainer=deluan@navidrome.org
images: |
-60
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 }}
+11 -13
View File
@@ -8,7 +8,7 @@ jobs:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
- uses: actions/github-script@v3
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
@@ -19,7 +19,8 @@ jobs:
const pull_user_id = ${{github.event.sender.id}};
const issue_number = await (async () => {
for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) {
const pulls = await github.pulls.list({owner, repo});
for await (const {data} of github.paginate.iterator(pulls)) {
for (const pull of data) {
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
return pull.number;
@@ -33,24 +34,21 @@ jobs:
return core.error(`No matching pull request found`);
}
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-'));
if (!downloadable.length) {
const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
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 {data: comments} = await github.issues.listComments({repo, owner, issue_number});
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
if (existing_comment) {
core.info(`Updating comment ${existing_comment.id}`);
await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
} else {
core.info(`Creating a comment`);
await github.rest.issues.createComment({repo, owner, issue_number, body});
await github.issues.createComment({repo, owner, issue_number, body});
}
+22 -144
View File
@@ -24,7 +24,7 @@ jobs:
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
@@ -32,7 +32,7 @@ jobs:
- name: Show git version info
run: |
echo "git describe (dirty): $(git describe --dirty --always --tags)"
echo "git describe --tags --abbrev=0: $(git describe --tags --abbrev=0)"
echo "git describe --tags: $(git describe --tags `git rev-list --tags --max-count=1`)"
echo "git tag: $(git tag --sort=-committerdate | head -n 1)"
echo "github_ref: $GITHUB_REF"
echo "github_head_sha: ${{ github.event.pull_request.head.sha }}"
@@ -40,7 +40,7 @@ jobs:
- name: Determine git current SHA and latest tag
id: git-version
run: |
GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true)
GIT_TAG=$(git tag --sort=-committerdate | head -n 1)
if [ -n "$GIT_TAG" ]; then
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
GIT_TAG=${GIT_TAG}-SNAPSHOT
@@ -62,22 +62,16 @@ jobs:
name: Lint Go code
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
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
@@ -102,33 +96,12 @@ jobs:
exit 1
fi
validate-migrations:
name: Validate DB migrations
runs-on: ubuntu-latest
# PR-only gate is at step level: a job-level skip would propagate through
# the needs chain (actions/runner#491) and skip all release jobs on tag pushes.
steps:
- uses: actions/checkout@v7
if: github.event_name == 'pull_request'
with:
fetch-depth: 0
# Refresh the base branch so the check compares against its CURRENT tip,
# not the (possibly stale) commit the PR was opened against.
- name: Fetch latest base branch
if: github.event_name == 'pull_request'
run: git fetch --no-tags origin "+refs/heads/${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}"
- name: Validate migration ordering and naming
if: github.event_name == 'pull_request'
env:
BASE_REF: origin/${{ github.event.pull_request.base.ref }}
run: ./.github/workflows/validate-migrations.sh
go:
name: Test Go code
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v7
uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
@@ -137,10 +110,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: |
@@ -149,84 +120,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
@@ -234,7 +127,7 @@ jobs:
FFMPEG_VERSION: "7.1"
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
@@ -252,7 +145,7 @@ jobs:
- name: Cache ffmpeg
id: ffmpeg-cache
uses: actions/cache@v6
uses: actions/cache@v4
with:
path: C:\ffmpeg
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
@@ -293,12 +186,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
@@ -306,7 +199,7 @@ jobs:
env:
NODE_OPTIONS: "--max_old_space_size=4096"
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
@@ -337,7 +230,7 @@ jobs:
name: Lint i18n files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- run: |
set -e
for file in resources/i18n/*.json; do
@@ -364,7 +257,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]
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 ]
@@ -383,7 +276,7 @@ jobs:
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
@@ -407,21 +300,6 @@ jobs:
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
- name: Set up QEMU for smoke test
if: env.IS_LINUX == 'true'
uses: docker/setup-qemu-action@v4
# The binary is static, so binfmt+qemu runs it directly on the runner.
# Catches startup crashes in cross-compiled binaries before they ship,
# e.g. the broken ifunc relocations on 32-bit arm from issue #5738.
- name: Smoke-test binary
if: env.IS_LINUX == 'true'
run: |
BIN=./output/${{ env.PLATFORM }}/navidrome
chmod +x "$BIN"
"$BIN" --help >/dev/null
echo "OK: ${{ matrix.platform }} binary starts"
- name: Upload Binaries
uses: actions/upload-artifact@v7
with:
@@ -472,7 +350,7 @@ jobs:
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Download digests
uses: actions/download-artifact@v8
@@ -506,7 +384,7 @@ jobs:
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
continue-on-error: true
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Download digests
uses: actions/download-artifact@v8
@@ -559,7 +437,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: actions/download-artifact@v8
with:
@@ -593,7 +471,7 @@ jobs:
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
@@ -613,7 +491,7 @@ jobs:
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
version: '2.16.0'
version: '~> v2'
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
fetch-depth: 2
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
- uses: actions/stale@v10
- uses: actions/stale@v9
with:
operations-per-run: 999
days-before-issue-stale: 180
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Get updated translations
id: poeditor
env:
-150
View File
@@ -1,150 +0,0 @@
#!/usr/bin/env bash
#
# Validates DB migrations added by a pull request:
# 1. Ordering - an added migration must be NEWER than the latest migration
# already on the base branch. Goose applies migrations in
# timestamp order, so an older-timestamped migration would be
# silently skipped on databases already upgraded past it.
# 2. Uniqueness - no two migration files may share a timestamp.
# 3. Naming - files must match YYYYMMDDHHMMSS_lower_snake_name.(sql|go).
#
# On failure it prints a human-readable message and, when running in GitHub
# Actions, emits an error annotation bound to the offending file so the message
# also renders inline in the PR "Files changed" tab.
#
# Compares HEAD against $BASE_REF (default origin/master). Requires full history
# (fetch-depth: 0 in CI).
# -e is intentionally omitted: the script accumulates violations into $status
# and must not exit on the first non-zero command (grep no-match, a false [[ ]]
# in an if, `is_migration || continue`).
set -uo pipefail
export LC_ALL=C
MIGRATIONS_DIR="db/migrations"
BASE_REF="${BASE_REF:-origin/master}"
NAME_RE='^[0-9]{14}_[a-z0-9_]+\.(sql|go)$'
status=0
# Log a message to stderr and mark the run as failed.
fail() {
printf '%s\n' "$1" >&2
status=1
}
# Emit a GitHub Actions error annotation bound to a file, so the message renders
# inline on the offending migration in the PR "Files changed" tab. No-op outside
# CI. `%`, newline and CR are encoded as required by the workflow-command syntax
# (the `%` replacement must run first so the encodings we add aren't re-escaped).
annotate() { # $1=file $2=message
[ "${GITHUB_ACTIONS:-}" = "true" ] || return 0
local msg="$2"
msg="${msg//'%'/%25}"
msg="${msg//$'\n'/%0A}"
msg="${msg//$'\r'/%0D}"
printf '::error file=%s,line=1::%s\n' "$1" "$msg"
}
# Report a migration problem: log it, annotate the offending file, mark failed.
report() { # $1=file $2=message
fail "$2"
printf '\n' >&2
annotate "$1" "$2"
}
human_ts() {
local t="$1"
printf '%s-%s-%s %s:%s:%s' "${t:0:4}" "${t:4:2}" "${t:6:2}" "${t:8:2}" "${t:10:2}" "${t:12:2}"
}
is_migration() { # $1=basename -> 0 if a .sql/.go file with a 14-digit prefix
local b="$1"
case "$b" in
*.sql | *.go) ;;
*) return 1 ;;
esac
[[ "${b%%_*}" =~ ^[0-9]{14}$ ]]
}
if ! git rev-parse --verify --quiet "$BASE_REF" >/dev/null; then
printf '❌ Cannot resolve base ref "%s". In CI, check out with fetch-depth: 0.\n' "$BASE_REF" >&2
exit 1
fi
# --- Newest timestamp already on the base branch ---
base_max=""
base_max_file=""
while IFS= read -r f; do
[ -z "$f" ] && continue
b="$(basename "$f")"
is_migration "$b" || continue
ts="${b%%_*}"
if [[ "$ts" > "$base_max" ]]; then
base_max="$ts"
base_max_file="$f"
fi
done < <(git ls-tree -r --name-only "$BASE_REF" -- "$MIGRATIONS_DIR" 2>/dev/null)
# --- Ordering + naming on files added by this PR ---
while IFS= read -r f; do
[ -z "$f" ] && continue
b="$(basename "$f")"
case "$b" in
*.sql) ;; # any .sql in this dir must be a migration
*.go) [[ "$b" == [0-9]* ]] || continue ;; # non-timestamped .go = helper (e.g. migration.go), skip
*) continue ;;
esac
if [ "${f%/*}" != "$MIGRATIONS_DIR" ]; then
report "$f" "❌ Migration file in a subdirectory: $f
Migrations must live directly in $MIGRATIONS_DIR/ — only $MIGRATIONS_DIR/*.sql (and
top-level .go migrations) are embedded, so a nested file would be SILENTLY SKIPPED.
Move it to $MIGRATIONS_DIR/$b."
continue
fi
if ! [[ "$b" =~ $NAME_RE ]]; then
report "$f" "❌ Malformed migration filename: $f
Expected YYYYMMDDHHMMSS_lower_snake_name.(sql|go); the name segment must be lowercase.
Regenerate with: make migration-sql name=<description> (or make migration-go name=<description>)"
continue
fi
ts="${b%%_*}"
if [[ -n "$base_max" ]] && ! [[ "$ts" > "$base_max" ]]; then
report "$f" "❌ Migration ordering error: $f ($(human_ts "$ts"))
is older than (or equal to) the newest migration already on ${BASE_REF#origin/}:
$base_max_file ($(human_ts "$base_max"))
Goose applies migrations in timestamp order, so databases already upgraded
past that point would SILENTLY SKIP your migration.
Fix: regenerate it with a current timestamp:
make migration-sql name=<description> (or make migration-go name=<description>)
then move your SQL/Go body into the new file and delete the old one."
fi
done < <(git diff --diff-filter=A --name-only "$BASE_REF"...HEAD -- "$MIGRATIONS_DIR" 2>/dev/null)
# --- Duplicate timestamps across the merged set (HEAD) ---
all_migs="$(git ls-tree -r --name-only HEAD -- "$MIGRATIONS_DIR" 2>/dev/null)"
dups="$(printf '%s\n' "$all_migs" | while IFS= read -r f; do
b="$(basename "$f")"
is_migration "$b" || continue
printf '%s\n' "${b%%_*}"
done | sort | uniq -d)"
if [ -n "$dups" ]; then
while IFS= read -r ts; do
[ -z "$ts" ] && continue
colliding="$(printf '%s\n' "$all_migs" | grep "/${ts}_" || true)"
printf '❌ Duplicate migration timestamp %s used by multiple files:\n' "$ts" >&2
while IFS= read -r cf; do
[ -z "$cf" ] && continue
printf ' %s\n' "$cf" >&2
annotate "$cf" "Duplicate migration timestamp $ts — shared by another migration. Timestamps must be unique; regenerate one with make migration-*."
done <<< "$colliding"
printf ' Every migration needs a unique timestamp. Regenerate one with make migration-*.\n' >&2
status=1
done <<< "$dups"
fi
if [ "$status" -eq 0 ]; then
echo "✅ DB migrations OK (ordering, uniqueness, naming)."
fi
exit "$status"
+1 -10
View File
@@ -37,14 +37,5 @@ AGENTS.md
*.wasm
*.ndp
openspec/
.agents
go.work*
.worktrees/
.playwright-mcp/
# Temp benchmark files
zz_*_test.go
# wazero compilation cache for the plugins test suite
/plugins/testdata/.wazero-cache/
/plugins/testdata/*.stage/
.worktrees/
-17
View File
@@ -13,7 +13,6 @@ linters:
- dogsled
- durationcheck
- errorlint
- forbidigo
- gocritic
- gocyclo
- goprintffuncname
@@ -27,9 +26,6 @@ linters:
disable:
- staticcheck
settings:
errcheck:
exclude-functions:
- (*github.com/zeebo/xxh3.Hasher).Write
gocritic:
disable-all: true
enabled-checks:
@@ -40,14 +36,6 @@ linters:
- G401
- G505
- G115
forbidigo:
forbid:
- pattern: 'tx\.Exec$'
msg: "use tx.ExecContext(ctx, ...) in migrations to propagate context"
- pattern: 'tx\.Query$'
msg: "use tx.QueryContext(ctx, ...) in migrations to propagate context"
- pattern: 'tx\.QueryRow$'
msg: "use tx.QueryRowContext(ctx, ...) in migrations to propagate context"
govet:
enable:
- nilness
@@ -57,9 +45,6 @@ linters:
- gosec
path: _test\.go
text: "G703"
- path-except: 'db/migrations/'
linters:
- forbidigo
generated: lax
presets:
- comments
@@ -71,8 +56,6 @@ linters:
- builtin$
- examples$
- node_modules
- _gen\.go$
- .worktrees
formatters:
exclusions:
generated: lax
-44
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}
+10 -57
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
@@ -69,15 +69,12 @@ RUN --mount=type=bind,source=. \
set -e
xx-go --wrap
export CGO_ENABLED=1
BUILD_TAGS=$(./release/build-tags.sh)
# -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve.
go build -tags="${BUILD_TAGS}" -ldflags="-w -s \
go build -tags=netgo,sqlite_fts5 -ldflags="-w -s \
-linkmode=external -extldflags '-latomic' \
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
-o /out/navidrome .
# Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738).
./release/verify-binary.sh /out/navidrome
# Fail the build if the binary is accidentally statically linked: dlopen (and
# therefore native libwebp detection) only works with a dynamic interpreter.
file /out/navidrome | grep -q "dynamically linked" || { echo "ERROR: /out/navidrome is not dynamically linked"; file /out/navidrome; exit 1; }
@@ -85,7 +82,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
@@ -111,12 +108,11 @@ RUN --mount=type=bind,source=. \
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod <<EOT
set -e
# Setup CGO cross-compilation environment
xx-go --wrap
export CGO_ENABLED=1
cat "$(go env GOENV)" 2>/dev/null || true
cat $(go env GOENV)
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
# So let's use gcc for everything except Darwin.
@@ -125,25 +121,14 @@ RUN --mount=type=bind,source=. \
export CXX=$(xx-info)-g++
export LD_EXTRA="-extldflags '-static -latomic'"
fi
# GNU ld corrupts the R_ARM_IRELATIVE addends of libatomic's ifunc resolvers
# (wrong address, Thumb bit lost) once .text outgrows the 16MB Thumb branch
# range, making static arm binaries jump to garbage inside glibc's ifunc
# resolution and crash before main() (issue #5738). Link 32-bit arm with LLD,
# which emits correct addends.
if [ "$(xx-info arch)" = "arm" ]; then
export LD_EXTRA="-extldflags '-static -latomic -fuse-ld=lld'"
fi
if [ "$(xx-info os)" = "windows" ]; then
export EXT=".exe"
fi
BUILD_TAGS=$(./release/build-tags.sh)
go build -tags="${BUILD_TAGS}" -ldflags="${LD_EXTRA} -w -s \
go build -tags=netgo,sqlite_fts5 -ldflags="${LD_EXTRA} -w -s \
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
-o /out/navidrome${EXT} .
# Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738).
./release/verify-binary.sh /out/navidrome*
EOT
# Verify if the binary was built for the correct platform and it is statically linked
@@ -152,52 +137,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 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/
@@ -207,6 +159,7 @@ ENV ND_MUSICFOLDER=/music
ENV ND_DATAFOLDER=/data
ENV ND_CONFIGFILE=/data/navidrome.toml
ENV ND_PORT=4533
ENV ND_ENABLEWEBPENCODING=true
RUN touch /.nddockerenv
EXPOSE ${ND_PORT}
+6 -7
View File
@@ -9,7 +9,7 @@ export ND_ENABLEINSIGHTSCOLLECTOR=false
ifneq ("$(wildcard .git/HEAD)","")
GIT_SHA=$(shell git rev-parse --short HEAD)
GIT_TAG=$(shell git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)-SNAPSHOT
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
else
GIT_SHA=source_archive
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
@@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
GOLANGCI_LINT_VERSION ?= v2.13.2
GOLANGCI_LINT_VERSION ?= v2.12.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
@@ -113,11 +113,10 @@ wire: check_go_env ##@Development Update Dependency Injection
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
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -rust -shared=../../types
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust -shared=../../types
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -python -rust
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities
go mod tidy -C plugins/pdk/go
.PHONY: gen
-1
View File
@@ -52,7 +52,6 @@ A share of the revenue helps fund the development of Navidrome at no additional
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
- Ready to use binaries for all major platforms, including **Raspberry Pi**
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
- Supports **lyrics** from sidecar .ttml, .yaml/.yml Lyricsfile, .elrc, .lrc, .srt, .txt files and embedded TTML, Enhanced LRC, LRC, SRT, and plain-text tags (via `lyricspriority`)
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
+12 -34
View File
@@ -1,7 +1,7 @@
package deezer
import (
"bytes"
bytes "bytes"
"context"
"encoding/json"
"errors"
@@ -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) {
+1 -33
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))
})
})
+13 -40
View File
@@ -1,11 +1,10 @@
package deezer
import (
"cmp"
"context"
"errors"
"fmt"
"slices"
"net/http"
"strings"
"github.com/navidrome/navidrome/conf"
@@ -14,7 +13,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 +34,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
@@ -68,29 +68,21 @@ func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([
{artist.PictureSmall, deezerApiPictureSmallSize},
}
for _, imgData := range possibleImages {
if imgData.URL != "" && !isPlaceholderPicture(imgData.URL) {
if imgData.URL != "" {
res = append(res, agents.ExternalImage{
URL: imgData.URL,
Size: imgData.Size,
})
}
}
if len(res) == 0 {
return nil, agents.ErrNotFound
}
return res, nil
}
// deezerEmptyPicturePath is Deezer's empty-image-id path shape for artists with no picture
// (…/images/artist//1000x1000-…), which serves a generic silhouette on any CDN host.
const deezerEmptyPicturePath = "/images/artist//"
func isPlaceholderPicture(url string) bool {
return strings.Contains(url, deezerEmptyPicturePath)
}
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
}
@@ -103,32 +95,13 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
}
}
// Deezer's RANKING order isn't reliable for homonyms: rank name matches
// ahead of non-matches, prefer an exact-case match, then the most fans.
rank := func(a Artist) int {
switch {
case a.Name == name:
return 2
case strings.EqualFold(a.Name, name):
return 1
default:
return 0
}
}
slices.SortFunc(artists, func(a, b Artist) int {
return cmp.Or(
cmp.Compare(rank(b), rank(a)),
cmp.Compare(b.NbFan, a.NbFan),
cmp.Compare(a.ID, b.ID),
)
})
best := artists[0]
if !strings.EqualFold(best.Name, name) {
log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name)
// If the first one has the same name, that's the one
if !strings.EqualFold(artists[0].Name, name) {
log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name)
return nil, agents.ErrNotFound
}
log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan)
return new(best), nil
log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link)
return &artists[0], err
}
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
-125
View File
@@ -3,7 +3,6 @@ package deezer
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -35,130 +34,6 @@ var _ = Describe("deezerAgent", func() {
})
})
Describe("searchArtist", func() {
var agent *deezerAgent
var httpClient *fakeHttpClient
BeforeEach(func() {
httpClient = &fakeHttpClient{}
agent = &deezerAgent{
dataStore: &tests.MockDataStore{},
client: newClient(httpClient),
}
})
It("picks the exact-name match with the most fans when several share the name", func() {
// Deezer RANKING order returns a low-popularity homonym first (see issue #5802)
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":61045802,"name":"Queen","nb_fan":75},
{"id":141954732,"name":"Queen","nb_fan":397},
{"id":135041032,"name":"Queen(Ares)","nb_fan":133},
{"id":183179807,"name":"Queen","nb_fan":53},
{"id":412,"name":"Queen","nb_fan":12744378}
],"total":5}`)),
})
artist, err := agent.searchArtist(ctx, "Queen")
Expect(err).ToNot(HaveOccurred())
Expect(artist.ID).To(Equal(412))
})
It("matches the name case-insensitively", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":1,"name":"QUEEN","nb_fan":10},
{"id":2,"name":"queen","nb_fan":20}
],"total":2}`)),
})
artist, err := agent.searchArtist(ctx, "Queen")
Expect(err).ToNot(HaveOccurred())
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,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":1,"name":"Queens of the Stone Age","nb_fan":100}
],"total":1}`)),
})
_, err := agent.searchArtist(ctx, "Queen")
Expect(err).To(MatchError(agents.ErrNotFound))
})
})
Describe("GetArtistImages", func() {
var agent *deezerAgent
var httpClient *fakeHttpClient
BeforeEach(func() {
httpClient = &fakeHttpClient{}
agent = &deezerAgent{
dataStore: &tests.MockDataStore{},
client: newClient(httpClient),
}
})
It("returns the real images when the artist has a picture", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":412,"name":"Queen","nb_fan":12744378,
"picture_xl":"https://cdn-images.dzcdn.net/images/artist/abc/1000x1000-000000-80-0-0.jpg",
"picture_big":"https://cdn-images.dzcdn.net/images/artist/abc/500x500-000000-80-0-0.jpg"}
],"total":1}`)),
})
images, err := agent.GetArtistImages(ctx, "", "Queen", "")
Expect(err).ToNot(HaveOccurred())
Expect(images).To(HaveLen(2))
Expect(images[0].URL).To(ContainSubstring("1000x1000"))
})
It("returns ErrNotFound when the artist only has empty-id placeholder pictures", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":412,"name":"Queen","nb_fan":12744378,
"picture_xl":"https://cdn-images.dzcdn.net/images/artist//1000x1000-000000-80-0-0.jpg",
"picture_big":"https://cdn-images.dzcdn.net/images/artist//500x500-000000-80-0-0.jpg",
"picture_medium":"https://cdn-images.dzcdn.net/images/artist//250x250-000000-80-0-0.jpg",
"picture_small":"https://cdn-images.dzcdn.net/images/artist//56x56-000000-80-0-0.jpg"}
],"total":1}`)),
})
images, err := agent.GetArtistImages(ctx, "", "Queen", "")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(images).To(BeEmpty())
})
})
Describe("GetArtistBiography - Language Fallback", func() {
var agent *deezerAgent
var httpClient *langAwareHttpClient
+6 -2
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 {
+1 -1
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())
+6 -4
View File
@@ -8,6 +8,7 @@ import (
"github.com/djherbis/times"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -90,7 +91,8 @@ var _ = Describe("Extractor", func() {
info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info)
return new(metadata.ToMediaFile(1, "folderID"))
mf := metadata.ToMediaFile(1, "folderID")
return &mf
}
BeforeEach(func() {
@@ -107,7 +109,7 @@ var _ = Describe("Extractor", func() {
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
},
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", new(0.0), new(1.0), new(0.0), new(1.0)),
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
)
})
@@ -118,8 +120,8 @@ var _ = Describe("Extractor", func() {
DisplayTitle: "",
Lang: code,
Line: []model.Line{
{Start: new(int64(0)), Value: "This is"},
{Start: new(int64(2500)), Value: secondLine},
{Start: gg.P(int64(0)), Value: "This is"},
{Start: gg.P(int64(2500)), Value: secondLine},
},
Offset: nil,
Synced: true,
+24 -25
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)
@@ -92,7 +93,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
var resp agents.AlbumInfo
for _, lang := range l.languages {
var err error
a, err = l.callAlbumGetInfo(ctx, name, artist, lang)
a, err = l.callAlbumGetInfo(ctx, name, artist, mbid, lang)
if err != nil {
return nil, err
}
@@ -113,7 +114,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
}
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
a, err := l.callAlbumGetInfo(ctx, name, artist, l.languages[0])
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0])
if err != nil {
return nil, err
}
@@ -230,9 +231,10 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti
res := make([]agents.Song, 0, len(resp))
for _, t := range resp {
res = append(res, agents.Song{
Name: t.Name,
MBID: t.MBID,
Artists: []agents.Artist{{Name: t.Artist.Name, MBID: t.Artist.MBID}},
Name: t.Name,
MBID: t.MBID,
Artist: t.Artist.Name,
ArtistMBID: t.Artist.MBID,
})
}
return res, nil
@@ -285,18 +287,22 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
return res, nil
}
// callAlbumGetInfo matches on name+artist only. Last.fm's album.getInfo by MBID is unreliable —
// a correct MBID can return a different album (or none) — so the MBID is deliberately not passed.
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, lang string) (*Album, error) {
a, err := l.client.albumGetInfo(ctx, name, artist, "", lang)
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) {
a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang)
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && (isLastFMError && lfErr.Code == 6) {
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
return l.callAlbumGetInfo(ctx, name, artist, "", lang)
}
if err != nil {
if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
// A not-found is a definitive absence, not a fault: return the shared sentinel so the
// artwork worker's breaker/transient checks don't retry it, and log it at Debug.
log.Debug(ctx, "Album not found in Last.fm", "album", name, "artist", artist)
return nil, agents.ErrNotFound
if isLastFMError && lfErr.Code == 6 {
log.Debug(ctx, "Album not found", "album", name, "mbid", mbid, err)
} else {
log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "mbid", mbid, err)
}
log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "artist", artist, err)
return nil, err
}
return a, nil
@@ -308,12 +314,6 @@ func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang s
a, err := l.client.artistGetInfo(ctx, name, lang)
if err != nil {
if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
// A not-found is a definitive absence, not a fault: return the shared sentinel so it
// doesn't trip the artwork worker's breaker, and log at Debug instead of Error.
log.Debug(ctx, "Artist not found in Last.fm", "artist", name)
return nil, agents.ErrNotFound
}
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
return nil, err
}
@@ -405,8 +405,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)
+19 -42
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() {
@@ -318,11 +309,11 @@ var _ = Describe("lastfmAgent", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{
{Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}},
{Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}},
{Name: "Don't You Want Me", MBID: "", Artists: []agents.Artist{{Name: "The Human League", MBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}}},
{Name: "Tainted Love", MBID: "", Artists: []agents.Artist{{Name: "Soft Cell", MBID: "7fb50287-029d-47cc-825a-235ca28024b2"}}},
{Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artists: []agents.Artist{{Name: "New Order", MBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}}},
{Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"},
{Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"},
{Name: "Don't You Want Me", MBID: "", Artist: "The Human League", ArtistMBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"},
{Name: "Tainted Love", MBID: "", Artist: "Soft Cell", ArtistMBID: "7fb50287-029d-47cc-825a-235ca28024b2"},
{Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artist: "New Order", ArtistMBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"},
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough"))
@@ -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`)),
@@ -558,10 +539,7 @@ var _ = Describe("lastfmAgent", func() {
URL: "https://www.last.fm/music/Cher/Believe",
}))
Expect(httpClient.RequestCount).To(Equal(1))
// MBID is deliberately not sent — album.getInfo matches on name+artist only.
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("Believe"))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Cher"))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62"))
})
It("returns empty images if no images are available", func() {
@@ -580,7 +558,7 @@ var _ = Describe("lastfmAgent", func() {
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
})
It("returns an error if Last.fm call returns an error", func() {
@@ -588,17 +566,23 @@ var _ = Describe("lastfmAgent", func() {
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
})
It("returns an error when Last.fm returns an error 6 (album not found)", func() {
It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "")
Expect(err).To(HaveOccurred())
// A definitive not-found must satisfy the sentinel, or the artwork worker retries it.
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
})
Context("MBID non existent in Last.fm", func() {
It("calls again when last.fm returns an error 6", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, _ = agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
Expect(httpClient.RequestCount).To(Equal(2))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
})
})
})
@@ -629,13 +613,6 @@ var _ = Describe("lastfmAgent", func() {
Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
})
It("maps a Last.fm error 6 (artist not found) to the shared not-found sentinel", func() {
apiClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetArtistImages(ctx, "123", "Nonexistent Artist", "")
// Not a fault: runs of missing artists must not trip the worker's circuit breaker.
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
})
It("returns empty list if image is the ignored default image", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
+4 -16
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
}
@@ -76,13 +77,6 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
return
}
resp["status"] = key != ""
linkToken, err := createLinkToken(u.ID)
if err != nil {
log.Error(r.Context(), "Could not create LastFM link token", "userId", u.ID, err)
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
resp["linkToken"] = linkToken
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
}
@@ -103,17 +97,11 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
return
}
linkToken, err := p.String("uid")
uid, err := p.String("uid")
if err != nil {
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
return
}
uid, err := verifyLinkToken(linkToken)
if err != nil {
log.Warn(r.Context(), "Rejected LastFM callback with invalid link token", "requestId", middleware.GetReqID(r.Context()), err)
_ = rest.RespondWithError(w, http.StatusBadRequest, "invalid link token")
return
}
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
// automatically contain any user info
-227
View File
@@ -1,227 +0,0 @@
package lastfm
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"time"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("auth_router", func() {
var (
ds *tests.MockDataStore
userProps *tests.MockedUserPropsRepo
httpClient *tests.FakeHttpClient
router *Router
)
const (
victimID = "victim-user-id"
attackerID = "attacker-user-id"
)
BeforeEach(func() {
userProps = &tests.MockedUserPropsRepo{}
ds = &tests.MockDataStore{
MockedProperty: &tests.MockedPropertyRepo{},
MockedUserProps: userProps,
}
auth.Init(ds)
httpClient = &tests.FakeHttpClient{}
router = &Router{
ds: ds,
apiKey: "API_KEY",
secret: "SECRET",
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
router.client = newClient(router.apiKey, router.secret, httpClient)
router.Handler = router.routes()
})
storedSessionKey := func(userID string) string {
key, _ := userProps.Get(userID, sessionKeyProperty)
return key
}
stubGetSessionOK := func(sessionKey string) {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)),
StatusCode: 200,
}
}
Describe("getLinkStatus", func() {
It("includes a signed linkToken for the authenticated user", func() {
req := httptest.NewRequest(http.MethodGet, "/link", nil)
ctx := request.WithUser(req.Context(), model.User{ID: victimID})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
router.getLinkStatus(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
var body map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed())
Expect(body["apiKey"]).To(Equal("API_KEY"))
Expect(body["status"]).To(Equal(false))
token, ok := body["linkToken"].(string)
Expect(ok).To(BeTrue())
Expect(token).ToNot(BeEmpty())
verified, err := verifyLinkToken(token)
Expect(err).ToNot(HaveOccurred())
Expect(verified).To(Equal(victimID))
})
})
Describe("callback", func() {
It("stores the session key under the user encoded in the signed token", func() {
stubGetSessionOK("LEGIT_SESSION")
linkToken, err := createLinkToken(victimID)
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION"))
})
It("rejects a raw (unsigned) uid value", func() {
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(storedSessionKey(victimID)).To(BeEmpty())
Expect(httpClient.SavedRequest).To(BeNil())
})
It("rejects an expired link token", func() {
expiredToken, err := auth.EncodeToken(map[string]any{
"uid": victimID,
"scope": linkTokenScope,
"exp": time.Now().Add(-1 * time.Minute).UTC().Unix(),
})
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(storedSessionKey(victimID)).To(BeEmpty())
Expect(httpClient.SavedRequest).To(BeNil())
})
It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() {
sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"})
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(storedSessionKey(attackerID)).To(BeEmpty())
Expect(httpClient.SavedRequest).To(BeNil())
})
It("writes only under the user encoded in the token, regardless of query manipulation", func() {
// An attacker holds a legitimate link token for their own account.
// They attempt to call the callback hoping to overwrite the victim's
// session key — but the handler must derive the user ID from the
// signed token, not from any other input.
stubGetSessionOK("ATTACKER_SESSION")
attackerToken, err := createLinkToken(attackerID)
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION"))
Expect(storedSessionKey(victimID)).To(BeEmpty())
})
It("returns 400 when uid is missing", func() {
req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
It("returns 400 when token is missing", func() {
linkToken, err := createLinkToken(victimID)
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
})
Describe("link token helpers", func() {
It("round-trips a freshly issued token", func() {
token, err := createLinkToken(victimID)
Expect(err).ToNot(HaveOccurred())
uid, err := verifyLinkToken(token)
Expect(err).ToNot(HaveOccurred())
Expect(uid).To(Equal(victimID))
})
It("rejects garbage", func() {
_, err := verifyLinkToken("not-a-jwt")
Expect(err).To(HaveOccurred())
})
It("rejects a token whose scope claim is wrong", func() {
wrongScopeToken, err := auth.EncodeToken(map[string]any{
"uid": victimID,
"scope": "some-other-scope",
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
})
Expect(err).ToNot(HaveOccurred())
_, err = verifyLinkToken(wrongScopeToken)
Expect(err).To(MatchError("invalid link token scope"))
})
It("rejects a scoped token that has no expiration", func() {
nonExpiringToken, err := auth.EncodeToken(map[string]any{
"uid": victimID,
"scope": linkTokenScope,
})
Expect(err).ToNot(HaveOccurred())
_, err = verifyLinkToken(nonExpiringToken)
Expect(err).To(MatchError("link token missing expiration"))
})
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())
})
})
})
+1 -10
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 {
@@ -230,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
-50
View File
@@ -1,50 +0,0 @@
package lastfm
import (
"errors"
"time"
"github.com/navidrome/navidrome/core/auth"
)
const (
linkTokenScope = "lastfm-link"
linkTokenTTL = 5 * time.Minute
)
// createLinkToken issues a signed token binding the Last.fm callback to the
// user who initiated the OAuth flow. It travels back through Last.fm via the
// `cb` URL in place of the previously-trusted raw `uid` query parameter.
func createLinkToken(userID string) (string, error) {
claims := map[string]any{
"uid": userID,
"scope": linkTokenScope,
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
}
return auth.EncodeToken(claims)
}
// verifyLinkToken validates a signed link token and returns the encoded user ID.
// It enforces both the signature/expiry (via the underlying JWT verifier) and a
// dedicated scope claim, preventing tokens minted for other purposes (e.g. a
// regular session JWT) from being accepted here.
func verifyLinkToken(tokenStr string) (string, error) {
token, err := auth.DecodeAndVerifyToken(tokenStr)
if err != nil {
return "", err
}
// jwtauth treats a token without `exp` as non-expiring; require it
// explicitly so an accidental regression cannot mint permanent tokens.
if exp, ok := token.Expiration(); !ok || exp.IsZero() {
return "", errors.New("link token missing expiration")
}
var scope string
if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope {
return "", errors.New("invalid link token scope")
}
var uid string
if err := token.Get("uid", &uid); err != nil || uid == "" {
return "", errors.New("invalid link token user ID")
}
return uid, nil
}
+17 -28
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
@@ -139,37 +141,24 @@ func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistNam
res := make([]agents.Song, len(resp))
for i, t := range resp {
mbid := ""
if len(t.ArtistMBIDs) > 0 {
mbid = t.ArtistMBIDs[0]
}
res[i] = agents.Song{
Album: t.ReleaseName,
AlbumMBID: t.ReleaseMBID,
Artists: topSongArtists(t.ArtistName, t.ArtistMBIDs),
Duration: t.DurationMs,
Name: t.RecordingName,
MBID: t.RecordingMbid,
Album: t.ReleaseName,
AlbumMBID: t.ReleaseMBID,
Artist: t.ArtistName,
ArtistMBID: mbid,
Duration: t.DurationMs,
Name: t.RecordingName,
MBID: t.RecordingMbid,
}
}
return res, nil
}
// topSongArtists maps the top-recordings response, which carries a single combined display name
// (e.g. "X feat. Y") plus a per-artist MBID list, onto agents.Artist. Names and MBIDs are not
// positionally pairable, so the display name attaches to the first credit and any further MBIDs
// become MBID-only collaborators — still valid identity signals for the matcher.
func topSongArtists(name string, mbids []string) []agents.Artist {
if len(mbids) == 0 {
if name == "" {
return nil
}
return []agents.Artist{{Name: name}}
}
artists := make([]agents.Artist, len(mbids))
artists[0] = agents.Artist{Name: name, MBID: mbids[0]}
for i, m := range mbids[1:] {
artists[i+1] = agents.Artist{MBID: m}
}
return artists
}
func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) {
if mbid == "" {
return nil, agents.ErrNotFound
@@ -214,7 +203,7 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin
songs[i] = agents.Song{
Album: song.ReleaseName,
AlbumMBID: song.ReleaseMBID,
Artists: []agents.Artist{{Name: song.Artist}},
Artist: song.Artist,
MBID: song.MBID,
Name: song.Name,
}
+51 -87
View File
@@ -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() {
@@ -262,22 +249,24 @@ var _ = Describe("listenBrainzAgent", func() {
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]agents.Song{
{
ID: "",
Name: "world.execute(me);",
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
Album: "Miracle Milk",
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
Duration: 211912,
ID: "",
Name: "world.execute(me);",
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "Miracle Milk",
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
Duration: 211912,
},
{
ID: "",
Name: "String Theocracy",
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
Album: "String Theocracy",
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
Duration: 174000,
ID: "",
Name: "String Theocracy",
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "String Theocracy",
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
Duration: 174000,
},
}))
})
@@ -289,45 +278,17 @@ var _ = Describe("listenBrainzAgent", func() {
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]agents.Song{
{
ID: "",
Name: "world.execute(me);",
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
Album: "Miracle Milk",
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
Duration: 211912,
ID: "",
Name: "world.execute(me);",
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "Miracle Milk",
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
Duration: 211912,
},
}))
})
It("maps a multi-artist top song to one named artist plus MBID-only collaborators", func() {
body := `[{
"recording_name": "Collab",
"recording_mbid": "rec-1",
"artist_name": "Drake feat. Future",
"artist_mbids": ["mbid-drake", "mbid-future"],
"release_name": "Album",
"release_mbid": "rel-1",
"length": 200000
}]`
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200}
data, err := agent.GetArtistTopSongs(ctx, "", "", "mbid-drake", 1)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(HaveLen(1))
Expect(data[0].Artists).To(Equal([]agents.Artist{
{Name: "Drake feat. Future", MBID: "mbid-drake"},
{MBID: "mbid-future"},
}))
})
It("leaves Artists nil when the top song carries no name or MBIDs", func() {
body := `[{"recording_name": "Anon", "recording_mbid": "rec-1", "artist_name": "", "artist_mbids": []}]`
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200}
data, err := agent.GetArtistTopSongs(ctx, "", "", "x", 1)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(HaveLen(1))
Expect(data[0].Artists).To(BeNil())
})
})
Describe("GetSimilarArtists", func() {
@@ -432,24 +393,26 @@ var _ = Describe("listenBrainzAgent", func() {
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Song{
{
ID: "",
Name: "Take On Me",
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
ISRC: "",
Artists: []agents.Artist{{Name: "aha"}},
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
ID: "",
Name: "Take On Me",
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
ISRC: "",
Artist: "aha",
ArtistMBID: "",
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
},
{
ID: "",
Name: "Wake Me Up Before You GoGo",
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
ISRC: "",
Artists: []agents.Artist{{Name: "Wham!"}},
Album: "Make It Big",
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
Duration: 0,
ID: "",
Name: "Wake Me Up Before You GoGo",
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
ISRC: "",
Artist: "Wham!",
ArtistMBID: "",
Album: "Make It Big",
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
Duration: 0,
},
}))
})
@@ -464,14 +427,15 @@ var _ = Describe("listenBrainzAgent", func() {
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Song{
{
ID: "",
Name: "Take On Me",
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
ISRC: "",
Artists: []agents.Artist{{Name: "aha"}},
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
ID: "",
Name: "Take On Me",
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
ISRC: "",
Artist: "aha",
ArtistMBID: "",
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
},
}))
})
+3 -2
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
}
-17
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
-73
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))
})
})
})
-1022
View File
File diff suppressed because it is too large. Load diff
-1206
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -75,7 +75,7 @@ var (
func runBackup(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = conf.NewDir(backupDir)
conf.Server.Backup.Path = backupDir
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
@@ -104,7 +104,7 @@ func runBackup(ctx context.Context) {
func runPrune(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = conf.NewDir(backupDir)
conf.Server.Backup.Path = backupDir
}
if backupCount != -1 {
+3 -3
View File
@@ -32,17 +32,17 @@ var inspectCmd = &cobra.Command{
},
}
var marshalers = map[string]func(any) ([]byte, error){
var marshalers = map[string]func(interface{}) ([]byte, error){
"pretty": prettyMarshal,
"toml": toml.Marshal,
"yaml": yaml.Marshal,
"json": json.Marshal,
"jsonindent": func(v any) ([]byte, error) {
"jsonindent": func(v interface{}) ([]byte, error) {
return json.MarshalIndent(v, "", " ")
},
}
func prettyMarshal(v any) ([]byte, error) {
func prettyMarshal(v interface{}) ([]byte, error) {
out := v.([]core.InspectOutput)
var res strings.Builder
for i := range out {
+9 -12
View File
@@ -6,14 +6,13 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -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
}
@@ -263,7 +260,7 @@ func runImport(ctx context.Context, files []string) {
ctx = request.WithUser(ctx, *user)
}
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
for _, file := range files {
absPath, err := filepath.Abs(file)
-35
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),
)
})
-557
View File
@@ -1,557 +0,0 @@
package cmd
import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins"
"github.com/spf13/cobra"
)
// pluginManager is the subset of *plugins.Manager the CLI needs.
type pluginManager interface {
EnablePlugin(ctx context.Context, id string) error
DisablePlugin(ctx context.Context, id string) error
ValidatePluginConfig(ctx context.Context, id, configJSON string) error
UpdatePluginConfig(ctx context.Context, id, configJSON string) error
UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error
UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error
RescanPlugins(ctx context.Context) error
}
var (
pluginListFormat string
pluginInfoFormat string
)
var (
editConfig string
editConfigFile string
editUsers string
editAllUsers bool
editLibraries string
editAllLibs bool
editWriteAccess bool
editNoWrite bool
)
func init() {
rootCmd.AddCommand(pluginRoot)
pluginListCmd.Flags().StringVarP(&pluginListFormat, "format", "f", "table", "output format [supported values: table, csv, json]")
pluginRoot.AddCommand(pluginListCmd)
pluginRoot.AddCommand(pluginEnableCmd)
pluginRoot.AddCommand(pluginDisableCmd)
pluginEditCmd.Flags().StringVar(&editConfig, "config", "", "plugin config as JSON")
pluginEditCmd.Flags().StringVar(&editConfigFile, "config-file", "", "read plugin config JSON from a file ('-' for stdin)")
pluginEditCmd.MarkFlagsMutuallyExclusive("config", "config-file")
pluginEditCmd.Flags().StringVar(&editUsers, "users", "", `usernames the plugin may access: comma-separated (alice,bob) or a JSON array (["alice","bob"])`)
pluginEditCmd.Flags().BoolVar(&editAllUsers, "all-users", false, "grant the plugin access to all users")
pluginEditCmd.MarkFlagsMutuallyExclusive("users", "all-users")
pluginEditCmd.Flags().StringVar(&editLibraries, "libraries", "", `library IDs the plugin may access: comma-separated (1,2) or a JSON array ([1,2])`)
pluginEditCmd.Flags().BoolVar(&editAllLibs, "all-libraries", false, "grant the plugin access to all libraries")
pluginEditCmd.MarkFlagsMutuallyExclusive("libraries", "all-libraries")
pluginEditCmd.Flags().BoolVar(&editWriteAccess, "write-access", false, "allow the plugin write access to libraries")
pluginEditCmd.Flags().BoolVar(&editNoWrite, "no-write-access", false, "deny the plugin write access to libraries")
pluginEditCmd.MarkFlagsMutuallyExclusive("write-access", "no-write-access")
pluginRoot.AddCommand(pluginEditCmd)
pluginInfoCmd.Flags().StringVarP(&pluginInfoFormat, "format", "f", "text", "output format [supported values: text, json]")
pluginRoot.AddCommand(pluginInfoCmd)
pluginRoot.AddCommand(pluginValidateCmd)
pluginRoot.AddCommand(pluginRescanCmd)
}
var (
pluginRoot = &cobra.Command{
Use: "plugin",
Short: "Manage and inspect plugins",
Long: "List, inspect, enable, disable, configure, rescan, and validate plugins",
}
pluginListCmd = &cobra.Command{
Use: "list",
Short: "List installed plugins",
Run: func(cmd *cobra.Command, args []string) {
runPluginList(cmd.Context())
},
}
pluginEnableCmd = &cobra.Command{
Use: "enable <id>",
Short: "Enable a plugin",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
requirePluginsEnabled(cmd.Context())
_, ctx := getAdminContext(cmd.Context())
mgr := GetPluginManager(ctx)
if err := enablePlugin(ctx, mgr, args[0]); err != nil {
log.Fatal(ctx, "Failed to enable plugin", "id", args[0], err)
}
},
}
pluginDisableCmd = &cobra.Command{
Use: "disable <id>",
Short: "Disable a plugin",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
requirePluginsEnabled(cmd.Context())
_, ctx := getAdminContext(cmd.Context())
mgr := GetPluginManager(ctx)
if err := disablePlugin(ctx, mgr, args[0]); err != nil {
log.Fatal(ctx, "Failed to disable plugin", "id", args[0], err)
}
},
}
)
var (
pluginInfoCmd = &cobra.Command{
Use: "info <id|file.ndp>",
Short: "Show details for an installed plugin or a .ndp package",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runPluginInfo(cmd.Context(), args[0])
},
}
pluginValidateCmd = &cobra.Command{
Use: "validate <id|file.ndp>",
Short: "Validate an installed plugin or a .ndp package manifest",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runPluginValidate(cmd.Context(), args[0])
},
}
)
// isPackagePath checks only the extension (not existence) so a mistyped path
// still routes to ReadManifest, which reports a precise "no such file" error.
func isPackagePath(arg string) bool {
return strings.HasSuffix(arg, plugins.PackageExtension)
}
func formatPluginInfo(p *model.Plugin, format string) (string, error) {
switch format {
case "json":
b, err := json.MarshalIndent(p, "", " ")
if err != nil {
return "", err
}
return string(b), nil
case "text":
default:
return "", fmt.Errorf("invalid output format %q (supported: text, json)", format)
}
name, version := manifestSummary(*p)
var sb strings.Builder
fmt.Fprintf(&sb, "ID: %s\n", p.ID)
fmt.Fprintf(&sb, "Name: %s\n", name)
fmt.Fprintf(&sb, "Version: %s\n", version)
fmt.Fprintf(&sb, "Enabled: %t\n", p.Enabled)
fmt.Fprintf(&sb, "Path: %s\n", p.Path)
fmt.Fprintf(&sb, "SHA256: %s\n", p.SHA256)
fmt.Fprintf(&sb, "All users: %t\n", p.AllUsers)
fmt.Fprintf(&sb, "All libs: %t\n", p.AllLibraries)
fmt.Fprintf(&sb, "Write access: %t\n", p.AllowWriteAccess)
if p.Users != "" {
fmt.Fprintf(&sb, "Users: %s\n", p.Users)
}
if p.Libraries != "" {
fmt.Fprintf(&sb, "Libraries: %s\n", p.Libraries)
}
if m, err := plugins.ParseManifest([]byte(p.Manifest)); err == nil {
if perms := m.Permissions.DeclaredNames(); len(perms) > 0 {
fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", "))
}
}
if !p.CreatedAt.IsZero() {
fmt.Fprintf(&sb, "Created: %s\n", p.CreatedAt.Format(time.RFC3339))
}
if !p.UpdatedAt.IsZero() {
fmt.Fprintf(&sb, "Updated: %s\n", p.UpdatedAt.Format(time.RFC3339))
}
if p.Config != "" {
fmt.Fprintf(&sb, "Config: %s\n", p.Config)
}
if p.LastError != "" {
fmt.Fprintf(&sb, "Last error: %s\n", p.LastError)
}
return sb.String(), nil
}
func formatManifestInfo(m *plugins.Manifest, sha256, format string) (string, error) {
switch format {
case "json":
b, err := json.MarshalIndent(struct {
*plugins.Manifest
SHA256 string `json:"sha256"`
}{m, sha256}, "", " ")
if err != nil {
return "", err
}
return string(b), nil
case "text":
default:
return "", fmt.Errorf("invalid output format %q (supported: text, json)", format)
}
var sb strings.Builder
fmt.Fprintf(&sb, "Name: %s\n", m.Name)
fmt.Fprintf(&sb, "Version: %s\n", m.Version)
fmt.Fprintf(&sb, "Author: %s\n", m.Author)
if m.Description != nil {
fmt.Fprintf(&sb, "Description: %s\n", *m.Description)
}
if m.Website != nil {
fmt.Fprintf(&sb, "Website: %s\n", *m.Website)
}
if perms := m.Permissions.DeclaredNames(); len(perms) > 0 {
fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", "))
}
fmt.Fprintf(&sb, "SHA256: %s\n", sha256)
return sb.String(), nil
}
func runPluginInfo(ctx context.Context, arg string) {
if isPackagePath(arg) {
m, err := plugins.ReadManifest(arg)
if err != nil {
log.Fatal(ctx, "Failed to read package", "path", arg, err)
}
sha, err := plugins.ComputeFileSHA256(arg)
if err != nil {
log.Fatal(ctx, "Failed to hash package", "path", arg, err)
}
out, err := formatManifestInfo(m, sha, pluginInfoFormat)
if err != nil {
log.Fatal(ctx, "Failed to format output", err)
}
fmt.Print(out)
return
}
requirePluginsEnabled(ctx)
ds, ctx := getAdminContext(ctx)
p, err := ds.Plugin(ctx).Get(arg)
if err != nil {
log.Fatal(ctx, "Plugin not found", "id", arg, err)
}
out, err := formatPluginInfo(p, pluginInfoFormat)
if err != nil {
log.Fatal(ctx, "Failed to format output", err)
}
fmt.Print(out)
}
func runPluginValidate(ctx context.Context, arg string) {
if isPackagePath(arg) {
if _, err := plugins.ReadManifest(arg); err != nil {
log.Fatal(ctx, "Validation failed", "path", arg, err)
}
fmt.Printf("%s: OK\n", arg)
return
}
requirePluginsEnabled(ctx)
ds, ctx := getAdminContext(ctx)
p, err := ds.Plugin(ctx).Get(arg)
if err != nil {
log.Fatal(ctx, "Plugin not found", "id", arg, err)
}
if _, err := plugins.ParseManifest([]byte(p.Manifest)); err != nil {
log.Fatal(ctx, "Validation failed", "id", arg, err)
}
if p.Config != "" {
mgr := GetPluginManager(ctx)
if err := mgr.ValidatePluginConfig(ctx, arg, p.Config); err != nil {
log.Fatal(ctx, "Config validation failed", "id", arg, err)
}
}
fmt.Printf("%s: OK\n", arg)
}
// manifestSummary extracts the display name and version from a stored manifest JSON, falling
// back to the plugin ID when the manifest can't be parsed.
func manifestSummary(p model.Plugin) (name, version string) {
var m struct {
Name string `json:"name"`
Version string `json:"version"`
}
if err := json.Unmarshal([]byte(p.Manifest), &m); err != nil {
return p.ID, ""
}
return m.Name, m.Version
}
func formatPluginList(list model.Plugins, format string) (string, error) {
switch format {
case "json":
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return "", err
}
return string(b), nil
case "csv":
var sb strings.Builder
w := csv.NewWriter(&sb)
_ = w.Write([]string{"id", "name", "version", "enabled", "last error"})
for _, p := range list {
name, version := manifestSummary(p)
_ = w.Write([]string{p.ID, name, version, fmt.Sprintf("%t", p.Enabled), p.LastError})
}
w.Flush()
return sb.String(), w.Error()
case "table":
var sb strings.Builder
w := newTabWriter(&sb)
fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR")
for _, p := range list {
name, version := manifestSummary(p)
fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%s\n", p.ID, name, version, p.Enabled, p.LastError)
}
w.Flush()
return sb.String(), nil
default:
return "", fmt.Errorf("invalid output format %q (supported: table, csv, json)", format)
}
}
func runPluginList(ctx context.Context) {
requirePluginsEnabled(ctx)
ds, ctx := getAdminContext(ctx)
list, err := ds.Plugin(ctx).GetAll()
if err != nil {
log.Fatal(ctx, "Failed to list plugins", err)
}
out, err := formatPluginList(list, pluginListFormat)
if err != nil {
log.Fatal(ctx, "Failed to format output", err)
}
fmt.Print(out)
}
// requirePluginsEnabled gates DB/manager-backed commands; off-disk .ndp
// inspection deliberately skips this so it works without a configured server.
func requirePluginsEnabled(ctx context.Context) {
if !conf.Server.Plugins.Enabled {
log.Fatal(ctx, "Plugin system is disabled (set Plugins.Enabled to use this command)")
}
}
func enablePlugin(ctx context.Context, mgr pluginManager, id string) error {
return mgr.EnablePlugin(ctx, id)
}
func disablePlugin(ctx context.Context, mgr pluginManager, id string) error {
return mgr.DisablePlugin(ctx, id)
}
type pluginEditOptions struct {
config *string // nil = leave unchanged
users *string
allUsers *bool
libraries *string
allLibraries *bool
writeAccess *bool
}
var pluginEditCmd = &cobra.Command{
Use: "edit <id>",
Short: "Update a plugin's config and/or permissions",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
requirePluginsEnabled(cmd.Context())
ds, ctx := getAdminContext(cmd.Context())
cur, err := ds.Plugin(ctx).Get(args[0])
if err != nil {
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
}
mgr := GetPluginManager(ctx)
opts := buildEditOptionsFromFlags(ctx, cmd)
if err := applyPluginEdit(ctx, mgr, cur, opts); err != nil {
log.Fatal(ctx, "Failed to edit plugin", "id", args[0], err)
}
},
}
func buildEditOptionsFromFlags(ctx context.Context, cmd *cobra.Command) pluginEditOptions {
var opts pluginEditOptions
switch {
case cmd.Flags().Changed("config"):
c := editConfig
opts.config = &c
case cmd.Flags().Changed("config-file"):
c := readConfigFile(ctx, editConfigFile)
opts.config = &c
}
if cmd.Flags().Changed("users") {
u := editUsers
opts.users = &u
}
if cmd.Flags().Changed("all-users") {
v := editAllUsers
opts.allUsers = &v
}
if cmd.Flags().Changed("libraries") {
l := editLibraries
opts.libraries = &l
}
if cmd.Flags().Changed("all-libraries") {
v := editAllLibs
opts.allLibraries = &v
}
if cmd.Flags().Changed("write-access") || cmd.Flags().Changed("no-write-access") {
// write-access is part of the library-permission group, so it is updated
// alongside the (preserved) library list rather than on its own.
wa := editWriteAccess && !editNoWrite
opts.writeAccess = &wa
}
return opts
}
func readConfigFile(ctx context.Context, path string) string {
var data []byte
var err error
if path == "-" {
data, err = io.ReadAll(os.Stdin)
} else {
data, err = os.ReadFile(path)
}
if err != nil {
log.Fatal(ctx, "Failed to read config file", "path", path, err)
}
return string(data)
}
// applyPluginEdit applies the requested changes on top of the plugin's current
// state. Like the native API, it reads the existing users/libraries before
// updating so that flipping one flag (e.g. --write-access) does not wipe
// unspecified fields, and rejects non-JSON users/libraries values.
func applyPluginEdit(ctx context.Context, mgr pluginManager, cur *model.Plugin, opts pluginEditOptions) error {
if opts.config == nil && opts.users == nil && opts.allUsers == nil &&
opts.libraries == nil && opts.allLibraries == nil && opts.writeAccess == nil {
return fmt.Errorf("nothing to update: provide at least one of --config/--users/--libraries/--write-access")
}
id := cur.ID
if opts.config != nil {
if err := mgr.ValidatePluginConfig(ctx, id, *opts.config); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
if err := mgr.UpdatePluginConfig(ctx, id, *opts.config); err != nil {
return err
}
}
if opts.users != nil || opts.allUsers != nil {
users, allUsers := cur.Users, cur.AllUsers
if opts.users != nil {
parsed, err := usersToJSON(*opts.users)
if err != nil {
return err
}
users = parsed
allUsers = false // an explicit list means "restrict to these users"
}
if opts.allUsers != nil {
allUsers = *opts.allUsers
}
if err := mgr.UpdatePluginUsers(ctx, id, users, allUsers); err != nil {
return err
}
}
if opts.libraries != nil || opts.allLibraries != nil || opts.writeAccess != nil {
libs, allLibs, writeAccess := cur.Libraries, cur.AllLibraries, cur.AllowWriteAccess
if opts.libraries != nil {
parsed, err := librariesToJSON(*opts.libraries)
if err != nil {
return err
}
libs = parsed
allLibs = false // an explicit list means "restrict to these libraries"
}
if opts.allLibraries != nil {
allLibs = *opts.allLibraries
}
if opts.writeAccess != nil {
writeAccess = *opts.writeAccess
}
if err := mgr.UpdatePluginLibraries(ctx, id, libs, allLibs, writeAccess); err != nil {
return err
}
}
return nil
}
// usersToJSON accepts either a JSON array (starts with '[') or a comma-separated
// list and returns the JSON-array form the manager stores.
func usersToJSON(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "[]", nil
}
if strings.HasPrefix(strings.TrimSpace(value), "[") {
if !json.Valid([]byte(value)) {
return "", fmt.Errorf("invalid JSON in --users")
}
return value, nil
}
var names []string
for _, u := range strings.Split(value, ",") {
if u = strings.TrimSpace(u); u != "" {
names = append(names, u)
}
}
b, _ := json.Marshal(names)
return string(b), nil
}
// librariesToJSON accepts either a JSON array (starts with '[') or a
// comma-separated list of integer IDs and returns the JSON-array form stored.
func librariesToJSON(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "[]", nil
}
if strings.HasPrefix(strings.TrimSpace(value), "[") {
if !json.Valid([]byte(value)) {
return "", fmt.Errorf("invalid JSON in --libraries")
}
return value, nil
}
ids := []int{}
for _, l := range strings.Split(value, ",") {
if l = strings.TrimSpace(l); l != "" {
id, err := strconv.Atoi(l)
if err != nil {
return "", fmt.Errorf("invalid library ID %q: must be an integer", l)
}
ids = append(ids, id)
}
}
b, _ := json.Marshal(ids)
return string(b), nil
}
var pluginRescanCmd = &cobra.Command{
Use: "rescan",
Short: "Re-discover plugins in the plugins folder",
Run: func(cmd *cobra.Command, args []string) {
requirePluginsEnabled(cmd.Context())
_, ctx := getAdminContext(cmd.Context())
mgr := GetPluginManager(ctx)
if err := rescanPlugins(ctx, mgr); err != nil {
log.Fatal(ctx, "Failed to rescan plugins", err)
}
},
}
func rescanPlugins(ctx context.Context, mgr pluginManager) error {
return mgr.RescanPlugins(ctx)
}
-360
View File
@@ -1,360 +0,0 @@
package cmd
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var samplePlugins = model.Plugins{
{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true},
{ID: "beta", Manifest: `{"name":"Beta","version":"2.1.0","author":"me"}`, Enabled: false, LastError: "boom"},
}
var _ = Describe("plugin command format flags", func() {
// Regression: list and info must not share a format variable. Binding the
// same var on both commands makes the last init() registration clobber the
// other's default, breaking `plugin list` with no -f flag.
It("defaults `list -f` to table", func() {
Expect(pluginListCmd.Flags().Lookup("format").DefValue).To(Equal("table"))
})
It("defaults `info -f` to text", func() {
Expect(pluginInfoCmd.Flags().Lookup("format").DefValue).To(Equal("text"))
})
})
var _ = Describe("formatPluginList", func() {
It("renders csv with a header and one row per plugin", func() {
out, err := formatPluginList(samplePlugins, "csv")
Expect(err).ToNot(HaveOccurred())
lines := strings.Split(strings.TrimSpace(out), "\n")
Expect(lines).To(HaveLen(3)) // header + 2 rows
Expect(lines[0]).To(ContainSubstring("id"))
Expect(out).To(ContainSubstring("alpha"))
Expect(out).To(ContainSubstring("beta"))
})
It("renders valid json", func() {
out, err := formatPluginList(samplePlugins, "json")
Expect(err).ToNot(HaveOccurred())
var got []map[string]any
Expect(json.Unmarshal([]byte(out), &got)).To(Succeed())
Expect(got).To(HaveLen(2))
})
It("renders a human table by default", func() {
out, err := formatPluginList(samplePlugins, "table")
Expect(err).ToNot(HaveOccurred())
Expect(out).To(ContainSubstring("Alpha"))
Expect(out).To(ContainSubstring("1.0.0"))
})
It("errors on an unknown format", func() {
_, err := formatPluginList(samplePlugins, "yaml")
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("enable/disable plugin", func() {
It("calls EnablePlugin on the manager", func() {
mgr := &tests.MockPluginManager{}
err := enablePlugin(context.Background(), mgr, "alpha")
Expect(err).ToNot(HaveOccurred())
Expect(mgr.EnablePluginCalls).To(Equal([]string{"alpha"}))
})
It("calls DisablePlugin on the manager", func() {
mgr := &tests.MockPluginManager{}
err := disablePlugin(context.Background(), mgr, "beta")
Expect(err).ToNot(HaveOccurred())
Expect(mgr.DisablePluginCalls).To(Equal([]string{"beta"}))
})
})
var _ = Describe("applyPluginEdit", func() {
var cur *model.Plugin
BeforeEach(func() {
cur = &model.Plugin{ID: "alpha", Users: `["bob"]`, Libraries: `[1,2]`, AllowWriteAccess: true}
})
It("validates then updates config when config is provided", func() {
mgr := &tests.MockPluginManager{}
cfg := `{"key":"val"}`
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg})
Expect(err).ToNot(HaveOccurred())
Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1))
Expect(mgr.ValidatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg))
Expect(mgr.UpdatePluginConfigCalls).To(HaveLen(1))
Expect(mgr.UpdatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg))
})
It("updates users with allUsers flag", func() {
mgr := &tests.MockPluginManager{}
all := true
err := applyPluginEdit(context.Background(), mgr, cur,
pluginEditOptions{allUsers: &all})
Expect(err).ToNot(HaveOccurred())
Expect(mgr.UpdatePluginUsersCalls).To(HaveLen(1))
Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue())
})
It("updates libraries with allLibraries and write access", func() {
mgr := &tests.MockPluginManager{}
all := true
wr := true
err := applyPluginEdit(context.Background(), mgr, cur,
pluginEditOptions{allLibraries: &all, writeAccess: &wr})
Expect(err).ToNot(HaveOccurred())
Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1))
Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeTrue())
Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeTrue())
})
It("preserves existing fields when only the write-access flag changes", func() {
mgr := &tests.MockPluginManager{}
no := false
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{writeAccess: &no})
Expect(err).ToNot(HaveOccurred())
Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1))
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) // not wiped
Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeFalse())
})
It("preserves existing users when only the all-users flag changes", func() {
mgr := &tests.MockPluginManager{}
all := true
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{allUsers: &all})
Expect(err).ToNot(HaveOccurred())
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["bob"]`)) // not wiped
})
It("parses a comma-separated users value into a JSON array", func() {
mgr := &tests.MockPluginManager{}
users := "alice, bob"
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
Expect(err).ToNot(HaveOccurred())
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`))
})
It("passes a JSON-array users value through unchanged", func() {
mgr := &tests.MockPluginManager{}
users := `["alice","bob"]`
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
Expect(err).ToNot(HaveOccurred())
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`))
})
It("rejects a malformed JSON-array users value", func() {
mgr := &tests.MockPluginManager{}
users := `["alice"`
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
Expect(err).To(HaveOccurred())
Expect(mgr.UpdatePluginUsersCalls).To(BeEmpty())
})
It("parses a comma-separated libraries value into a JSON array of ints", func() {
mgr := &tests.MockPluginManager{}
libs := "1, 2"
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
Expect(err).ToNot(HaveOccurred())
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`))
})
It("rejects a non-integer library ID", func() {
mgr := &tests.MockPluginManager{}
libs := "1,abc"
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
Expect(err).To(HaveOccurred())
Expect(mgr.UpdatePluginLibrariesCalls).To(BeEmpty())
})
It("clears allUsers when an explicit users list is set", func() {
mgr := &tests.MockPluginManager{}
cur.AllUsers = true
users := "alice"
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
Expect(err).ToNot(HaveOccurred())
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice"]`))
Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeFalse())
})
It("clears allLibraries when an explicit libraries list is set", func() {
mgr := &tests.MockPluginManager{}
cur.AllLibraries = true
libs := "1"
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
Expect(err).ToNot(HaveOccurred())
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1]`))
Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeFalse())
})
It("does nothing and errors when no fields are set", func() {
mgr := &tests.MockPluginManager{}
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{})
Expect(err).To(HaveOccurred())
})
It("aborts the config update when validation fails", func() {
mgr := &tests.MockPluginManager{ValidateError: errors.New("bad config")}
cfg := `{"key":"val"}`
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg})
Expect(err).To(HaveOccurred())
Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1))
Expect(mgr.UpdatePluginConfigCalls).To(BeEmpty())
})
})
var _ = Describe("isPackagePath", func() {
It("is true for any .ndp path", func() {
Expect(isPackagePath("/some/dir/x.ndp")).To(BeTrue())
})
It("is true for a non-existent .ndp path (so ReadManifest reports the error)", func() {
Expect(isPackagePath("/nope/x.ndp")).To(BeTrue())
})
It("is false for a bare plugin id", func() {
Expect(isPackagePath("my-plugin")).To(BeFalse())
})
})
var _ = Describe("formatPluginInfo", func() {
It("renders installed plugin details as text", func() {
p := &model.Plugin{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true}
out, err := formatPluginInfo(p, "text")
Expect(err).ToNot(HaveOccurred())
Expect(out).To(ContainSubstring("alpha"))
Expect(out).To(ContainSubstring("Alpha"))
})
It("renders json", func() {
p := &model.Plugin{ID: "alpha", Manifest: `{}`}
out, err := formatPluginInfo(p, "json")
Expect(err).ToNot(HaveOccurred())
Expect(out).To(ContainSubstring("alpha"))
})
})
var _ = Describe("formatManifestInfo", func() {
It("renders text with name, version, author", func() {
m := &plugins.Manifest{Name: "My Plugin", Version: "2.0.0", Author: "me"}
out, err := formatManifestInfo(m, "abc123", "text")
Expect(err).ToNot(HaveOccurred())
Expect(out).To(ContainSubstring("My Plugin"))
Expect(out).To(ContainSubstring("2.0.0"))
Expect(out).To(ContainSubstring("me"))
})
It("omits Description and Website when nil", func() {
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"}
out, err := formatManifestInfo(m, "abc123", "text")
Expect(err).ToNot(HaveOccurred())
Expect(out).ToNot(ContainSubstring("Description:"))
Expect(out).ToNot(ContainSubstring("Website:"))
})
It("includes Description and Website when set", func() {
desc := "a cool plugin"
site := "https://example.com"
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a", Description: &desc, Website: &site}
out, err := formatManifestInfo(m, "abc123", "text")
Expect(err).ToNot(HaveOccurred())
Expect(out).To(ContainSubstring("a cool plugin"))
Expect(out).To(ContainSubstring("https://example.com"))
})
It("renders valid json", func() {
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"}
out, err := formatManifestInfo(m, "abc123", "json")
Expect(err).ToNot(HaveOccurred())
Expect(out).To(ContainSubstring("\"name\""))
})
})
var _ = Describe("formatPluginInfo enriched text", func() {
var fixedTime time.Time
BeforeEach(func() {
fixedTime = time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)
})
It("includes Users, Libraries, Permissions, Created, Updated in text output", func() {
p := &model.Plugin{
ID: "myplugin",
Manifest: `{"name":"My Plugin","version":"1.0.0","author":"me","permissions":{"users":{},"subsonicapi":{}}}`,
Enabled: true,
Users: "alice,bob",
Libraries: "1,2",
CreatedAt: fixedTime,
UpdatedAt: fixedTime.Add(time.Hour),
}
out, err := formatPluginInfo(p, "text")
Expect(err).ToNot(HaveOccurred())
Expect(out).To(ContainSubstring("alice,bob"))
Expect(out).To(ContainSubstring("1,2"))
Expect(out).To(ContainSubstring("subsonicapi"))
Expect(out).To(ContainSubstring("users"))
Expect(out).To(ContainSubstring("2025-01-15T12:00:00Z"))
})
It("omits Users and Libraries lines when empty", func() {
p := &model.Plugin{
ID: "myplugin",
Manifest: `{"name":"X","version":"1.0.0","author":"me"}`,
CreatedAt: fixedTime,
UpdatedAt: fixedTime,
}
out, err := formatPluginInfo(p, "text")
Expect(err).ToNot(HaveOccurred())
Expect(out).ToNot(ContainSubstring("Users:"))
Expect(out).ToNot(ContainSubstring("Libraries:"))
})
It("does not alter json output", func() {
p := &model.Plugin{ID: "x", Manifest: `{}`, Users: "alice"}
out, err := formatPluginInfo(p, "json")
Expect(err).ToNot(HaveOccurred())
Expect(out).To(ContainSubstring(`"x"`))
})
})
var _ = Describe("formatManifestInfo enriched text", func() {
It("includes Permissions when declared", func() {
p := &plugins.Permissions{Http: &plugins.HTTPPermission{}}
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p}
out, err := formatManifestInfo(m, "abc123", "text")
Expect(err).ToNot(HaveOccurred())
Expect(out).To(ContainSubstring("http"))
Expect(out).To(ContainSubstring("Permissions:"))
})
It("omits Permissions line when no permissions declared", func() {
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a"}
out, err := formatManifestInfo(m, "abc123", "text")
Expect(err).ToNot(HaveOccurred())
Expect(out).ToNot(ContainSubstring("Permissions:"))
})
It("does not alter json output", func() {
p := &plugins.Permissions{Http: &plugins.HTTPPermission{}}
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p}
out, err := formatManifestInfo(m, "abc123", "json")
Expect(err).ToNot(HaveOccurred())
Expect(out).To(ContainSubstring(`"name"`))
})
})
var _ = Describe("rescanPlugins", func() {
It("calls RescanPlugins on the manager", func() {
mgr := &tests.MockPluginManager{}
err := rescanPlugins(context.Background(), mgr)
Expect(err).ToNot(HaveOccurred())
Expect(mgr.RescanPluginsCalls).To(Equal(1))
})
})
+9 -77
View File
@@ -2,7 +2,6 @@ package cmd
import (
"context"
"net/http"
"os"
"os/signal"
"strings"
@@ -12,7 +11,6 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -88,11 +86,8 @@ func runNavidrome(ctx context.Context) {
g.Go(startPlaybackServer(ctx))
g.Go(schedulePeriodicBackup(ctx))
g.Go(startInsightsCollector(ctx))
g.Go(scheduleDBAnalyzer(ctx))
g.Go(scheduleDBOptimizer(ctx))
g.Go(startPluginManager(ctx))
artworkWorker := CreateArtworkWorker()
g.Go(startArtworkWorker(ctx, artworkWorker))
g.Go(scheduleArtworkHousekeeping(ctx, artworkWorker))
g.Go(runInitialScan(ctx))
if conf.Server.Scanner.Enabled {
g.Go(startScanWatcher(ctx))
@@ -129,9 +124,6 @@ func startServer(ctx context.Context) func() error {
if conf.Server.ListenBrainz.Enabled {
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
}
if conf.Server.Jellyfin.Enabled {
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
}
if conf.Server.Prometheus.Enabled {
p := CreatePrometheus()
// blocking call because takes <100ms but useful if fails
@@ -139,7 +131,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())
@@ -148,14 +140,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 {
@@ -291,24 +275,16 @@ func schedulePeriodicBackup(ctx context.Context) func() error {
}
}
func scheduleDBAnalyzer(ctx context.Context) func() error {
func scheduleDBOptimizer(ctx context.Context) func() error {
return func() error {
if !conf.Server.EnableScheduledDBAnalyze {
log.Info(ctx, "Scheduled DB analysis is DISABLED")
return nil
}
log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule)
log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule)
schedulerInstance := scheduler.GetInstance()
_, err := schedulerInstance.Add(consts.DBAnalyzeCheckSchedule, func() {
release, ok := scanner.LockForMaintenance()
if !ok {
log.Debug(ctx, "Skipping DB analysis check because a scan is in progress")
_, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
if scanner.IsScanning() {
log.Debug(ctx, "Skipping DB optimization because a scan is in progress")
return
}
defer release()
if _, err := db.OptimizeIfNeeded(ctx); err != nil {
log.Error(ctx, "Error analyzing DB", err)
}
db.Optimize(ctx)
})
return err
}
@@ -357,50 +333,6 @@ func startPlaybackServer(ctx context.Context) func() error {
}
}
// startArtworkWorker starts the background artwork acquisition worker. It always
// runs; the queue is simply empty until something enqueues work into it.
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
return func() error {
log.Info(ctx, "Starting artwork worker")
return worker.Run(ctx)
}
}
// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and
// reports an artwork config change without acting on it.
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
return func() error {
schedulerInstance := scheduler.GetInstance()
if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() {
if err := worker.EnqueueMissingAll(ctx); err != nil {
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
}
}); err != nil {
log.Error(ctx, "Error scheduling artwork missing-state recheck", err)
}
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
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).
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)
}
return nil
}
}
// startPluginManager starts the plugin manager, if configured.
func startPluginManager(ctx context.Context) func() error {
return func() error {
@@ -451,7 +383,7 @@ func init() {
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint")
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics")
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
-46
View File
@@ -1,46 +0,0 @@
package cmd
import (
"net/http"
"net/http/httptest"
"path"
"runtime/pprof"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = pprof.NewProfile("nd-profiler-test")
var _ = Describe("profilerHandler", func() {
// Mirrors how server.MountRouter mounts the handler.
mount := func() http.Handler {
router := chi.NewRouter()
router.Mount(path.Join(conf.Server.BasePath, "/debug"), profilerHandler())
return router
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
DescribeTable("serves a named profile",
func(basePath string) {
conf.Server.BasePath = basePath
w := httptest.NewRecorder()
target := path.Join(basePath, "/debug/pprof/nd-profiler-test") + "?debug=1"
mount().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(HavePrefix("nd-profiler-test profile: total 0"))
},
Entry("without a BasePath", ""),
Entry("with a BasePath", "/music"),
Entry("with a root BasePath", "/"),
Entry("with a trailing-slash BasePath", "/music/"),
)
})
+5 -36
View File
@@ -4,12 +4,11 @@ import (
"bufio"
"context"
"encoding/gob"
"errors"
"fmt"
"os"
"strings"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
@@ -44,20 +43,15 @@ var scanCmd = &cobra.Command{
},
}
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) {
var changesDetected bool
var scanErrors []error
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
for status := range pl.ReadOrDone(ctx, progress) {
if status.Warning != "" {
log.Warn(ctx, "Scan warning", "error", status.Warning)
}
if status.Error != "" {
log.Error(ctx, "Scan error", "error", status.Error)
scanErrors = append(scanErrors, errors.New(status.Error))
}
if status.ChangesDetected {
changesDetected = true
}
// Discard the progress status, we only care about errors
}
if fullScan {
@@ -65,7 +59,6 @@ func trackScanInteractively(ctx context.Context, progress <-chan *scanner.Progre
} else {
log.Info("Finished rescan")
}
return changesDetected, errors.Join(scanErrors...)
}
func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
@@ -82,7 +75,7 @@ func runScanner(ctx context.Context) {
sqlDB := db.Db()
defer db.Db().Close()
ds := persistence.New(sqlDB)
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
// Parse targets from command line or file
var scanTargets []model.ScanTarget
@@ -102,16 +95,6 @@ func runScanner(ctx context.Context) {
log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets))
}
effectiveFullScan := fullScan
if !subprocess {
effectiveFullScan = scanner.EffectiveFullScan(ctx, ds, fullScan, scanTargets)
if effectiveFullScan {
if err := db.MarkOptimizePending(ctx); err != nil {
log.Error(ctx, "Error marking DB analysis pending", err)
}
}
}
progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets)
if err != nil {
log.Fatal(ctx, "Failed to scan", err)
@@ -121,21 +104,7 @@ func runScanner(ctx context.Context) {
if subprocess {
trackScanAsSubprocess(ctx, progress)
} else {
changesDetected, scanErr := trackScanInteractively(ctx, progress)
runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr)
}
}
func runPostScanAnalysis(ctx context.Context, changesDetected, effectiveFullScan bool, scanErr error) {
if changesDetected {
if err := db.MarkOptimizePending(ctx); err != nil {
log.Error(ctx, "Error marking DB analysis pending", err)
}
}
if effectiveFullScan && scanErr == nil {
if err := db.Optimize(ctx); err != nil {
log.Error(ctx, "Error analyzing DB", err)
}
trackScanInteractively(ctx, progress)
}
}
-15
View File
@@ -1,29 +1,14 @@
package cmd
import (
"context"
"os"
"path/filepath"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/scanner"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("trackScanInteractively", func() {
It("reports changes and scan errors", func() {
progress := make(chan *scanner.ProgressInfo, 2)
progress <- &scanner.ProgressInfo{ChangesDetected: true}
progress <- &scanner.ProgressInfo{Error: "scan failed"}
close(progress)
changesDetected, err := trackScanInteractively(context.Background(), progress)
Expect(changesDetected).To(BeTrue())
Expect(err).To(MatchError("scan failed"))
})
})
var _ = Describe("readTargetsFromFile", func() {
var tempDir string
+16 -15
View File
@@ -76,13 +76,13 @@ var svcInstance = sync.OnceValue(func() service.Service {
options["Restart"] = "on-failure"
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
options["UserService"] = false
options["LogDirectory"] = conf.Server.DataFolder.String()
options["LogDirectory"] = conf.Server.DataFolder
options["SystemdScript"] = systemdScript
if conf.Server.LogFile != "" {
options["LogOutput"] = false
} else {
options["LogOutput"] = true
options["LogDirectory"] = conf.Server.DataFolder.String()
options["LogDirectory"] = conf.Server.DataFolder
}
svcConfig := &service.Config{
UserName: installUser,
@@ -131,11 +131,11 @@ func buildInstallCmd() *cobra.Command {
println("Installing service with:")
println(" working directory: " + executablePath())
println(" music folder: " + conf.Server.MusicFolder)
println(" data folder: " + conf.Server.DataFolder.String())
println(" data folder: " + conf.Server.DataFolder)
if conf.Server.LogFile != "" {
println(" log file: " + conf.Server.LogFile)
} else {
println(" logs folder: " + conf.Server.DataFolder.String())
println(" logs folder: " + conf.Server.DataFolder)
}
if cfgFile != "" {
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
@@ -232,21 +232,22 @@ func buildExecuteCmd() *cobra.Command {
}
const systemdScript = `[Unit]
Description={{Description}}
ConditionFileIsExecutable={{Path | cmdEscape}}
{{range Dependencies}}{{.}}
{{end}}
Description={{.Description}}
ConditionFileIsExecutable={{.Path|cmdEscape}}
{{range $i, $dep := .Dependencies}}
{{$dep}} {{end}}
[Service]
StartLimitInterval=5
StartLimitBurst=10
ExecStart={{Path | cmdEscape}}{{range Arguments}} {{. | cmd}}{{end}}
{{if WorkingDirectory}}WorkingDirectory={{WorkingDirectory | cmdEscape}}{{end}}
{{if UserName}}User={{UserName}}{{end}}
{{if Restart}}Restart={{Restart}}{{end}}
{{if SuccessExitStatus}}SuccessExitStatus={{SuccessExitStatus}}{{end}}
ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
{{if .WorkingDirectory}}WorkingDirectory={{.WorkingDirectory|cmdEscape}}{{end}}
{{if .UserName}}User={{.UserName}}{{end}}
{{if .Restart}}Restart={{.Restart}}{{end}}
{{if .SuccessExitStatus}}SuccessExitStatus={{.SuccessExitStatus}}{{end}}
TimeoutStopSec=20
RestartSec=120
EnvironmentFile=-/etc/sysconfig/{{Name}}
EnvironmentFile=-/etc/sysconfig/{{.Name}}
Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
DevicePolicy=closed
@@ -259,7 +260,7 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
{{if WorkingDirectory}}ReadWritePaths={{WorkingDirectory | cmdEscape}}{{end}}
{{if .WorkingDirectory}}ReadWritePaths={{.WorkingDirectory|cmdEscape}}{{end}}
ProtectSystem=full
[Install]
-55
View File
@@ -1,55 +0,0 @@
package cmd
import (
"regexp"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("systemdScript template", func() {
systemdKeys := map[string]bool{
"Description": true, "Path": true, "Name": true, "Dependencies": true,
"Arguments": true, "ChRoot": true, "WorkingDirectory": true,
"UserName": true, "ReloadSignal": true, "PIDFile": true,
"LogDirectory": true, "OutputFileSupport": true, "LimitNOFILE": true,
"Restart": true, "SuccessExitStatus": true, "EnvVars": true,
}
systemdFuncs := map[string]bool{"cmd": true, "cmdEscape": true}
actionRe := regexp.MustCompile(`\{\{(.*?)\}\}`)
parseAction := func(action string) (key string, funcs []string) {
action = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(action, "-"), "-"))
kw, rest, _ := strings.Cut(action, " ")
switch kw {
case "end", "else":
return "", nil
case "if", "range":
return strings.TrimSpace(rest), nil
}
parts := strings.Split(action, "|")
for _, p := range parts[1:] {
funcs = append(funcs, strings.TrimSpace(p))
}
return strings.TrimSpace(parts[0]), funcs
}
It("only references keys and functions the service library provides", func() {
matches := actionRe.FindAllStringSubmatch(systemdScript, -1)
Expect(matches).ToNot(BeEmpty())
for _, m := range matches {
key, funcs := parseAction(m[1])
if key != "" && key != "." {
Expect(systemdKeys).To(HaveKey(key),
"template action %q uses a key unknown to kardianos/service", m[0])
}
for _, fn := range funcs {
Expect(systemdFuncs).To(HaveKey(fn),
"template action %q uses an unknown pipeline function", m[0])
}
}
})
})
-7
View File
@@ -4,8 +4,6 @@ import (
"context"
"errors"
"fmt"
"io"
"text/tabwriter"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
@@ -15,11 +13,6 @@ import (
"github.com/navidrome/navidrome/persistence"
)
// 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)
+54 -82
View File
@@ -31,7 +31,6 @@ import (
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin"
"github.com/navidrome/navidrome/server/nativeapi"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic"
@@ -65,21 +64,25 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
insights := metrics.GetInstance(dataStore)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
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)
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider)
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService)
return router
}
@@ -87,65 +90,44 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
imageStore := artwork.GetImageStore()
fFmpeg := ffmpeg.New()
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
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)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
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)
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
lyricsLyrics := lyrics.NewLyrics(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
}
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
imageStore := artwork.GetImageStore()
fFmpeg := ffmpeg.New()
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
players := core.NewPlayers(dataStore)
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
return router
}
func CreatePublicRouter() *public.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
imageStore := artwork.GetImageStore()
fFmpeg := ffmpeg.New()
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
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)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
@@ -185,22 +167,38 @@ func CreatePrometheus() metrics.Metrics {
func CreateScanner(ctx context.Context) model.Scanner {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
return modelScanner
}
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
}
@@ -212,32 +210,6 @@ func GetPlaybackServer() playback.PlaybackServer {
return playbackServer
}
func CreateArtworkWorker() *artwork.Worker {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
imageStore := artwork.GetImageStore()
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
fFmpeg := ffmpeg.New()
fileCache := artwork.GetImageCache()
worker := artwork.NewWorker(dataStore, imageStore, agentsAgents, fFmpeg, broker, fileCache)
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)
@@ -249,7 +221,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()
-24
View File
@@ -14,7 +14,6 @@ import (
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/db"
@@ -24,7 +23,6 @@ import (
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin"
"github.com/navidrome/navidrome/server/nativeapi"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic"
@@ -35,7 +33,6 @@ var allProviders = wire.NewSet(
artwork.Set,
server.New,
subsonic.New,
jellyfin.New,
nativeapi.New,
public.New,
persistence.New,
@@ -52,12 +49,10 @@ var allProviders = wire.NewSet(
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 CreateDataStore() model.DataStore {
@@ -84,12 +79,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
))
}
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
panic(wire.Build(
allProviders,
))
}
func CreatePublicRouter() *public.Router {
panic(wire.Build(
allProviders,
@@ -138,19 +127,6 @@ func GetPlaybackServer() playback.PlaybackServer {
))
}
func CreateArtworkWorker() *artwork.Worker {
panic(wire.Build(
allProviders,
))
}
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
panic(wire.Build(
allProviders,
artwork.NewTracingResolver,
))
}
func getPluginManager() *plugins.Manager {
panic(wire.Build(
allProviders,
+4 -2
View File
@@ -2,7 +2,9 @@ package configtest
import "github.com/navidrome/navidrome/conf"
// TODO Remove this redirection and call SnapshotConfig directly from tests
func SetupConfig() func() {
return conf.SnapshotConfig()
oldValues := *conf.Server
return func() {
conf.Server = &oldValues
}
}
+93 -371
View File
@@ -2,43 +2,35 @@ package conf
import (
"cmp"
"encoding"
"encoding/json"
"fmt"
"math"
"net/url"
"os"
"path/filepath"
"reflect"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/dustin/go-humanize"
"github.com/go-viper/encoding/ini"
"github.com/go-viper/mapstructure/v2"
"github.com/kr/pretty"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/scheduler"
"github.com/navidrome/navidrome/utils/run"
"github.com/navidrome/navidrome/utils/slice"
"github.com/spf13/viper"
)
type configOptions struct {
ConfigFile string `conf:"-"`
ConfigFile string
Address string
Port int
UnixSocketPerm string
EnforceNonRootUser bool
MusicFolder string
DataFolder Dir
CacheFolder Dir
DataFolder string
CacheFolder string
DbPath string
LogLevel string
LogFile string
@@ -53,11 +45,11 @@ type configOptions struct {
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
EnableTranscodingCancellation bool
EnableDownloads bool
EnableExternalServices bool
EnableM3UExternalAlbumArt bool
EnableInsightsCollector bool
EnableScheduledDBAnalyze bool
EnableMediaFileCoverArt bool
TranscodingCacheSize string
ImageCacheSize string
@@ -73,7 +65,6 @@ type configOptions struct {
Matcher matcherOptions `json:",omitzero"`
RecentlyAddedByModTime bool
PreferSortTags bool
EnableNaturalSorting bool
IgnoredArticles string
IndexGroups string
FFmpegPath string
@@ -92,7 +83,6 @@ type configOptions struct {
EnableUserEditing bool
EnableArtworkUpload bool
MaxImageUploadSize string
MaxImageSize string
EnableSharing bool
ShareURL string
DefaultShareExpiration time.Duration
@@ -121,11 +111,9 @@ type configOptions struct {
PID pidOptions `json:",omitzero"`
Inspect inspectOptions `json:",omitzero"`
Subsonic subsonicOptions `json:",omitzero"`
Transcoding transcodingOptions `json:",omitzero"`
LastFM lastfmOptions `json:",omitzero"`
Deezer deezerOptions `json:",omitzero"`
ListenBrainz listenBrainzOptions `json:",omitzero"`
Jellyfin jellyfinOptions `json:",omitzero"`
EnableScrobbleHistory bool
Tags map[string]TagConf `json:",omitempty"`
Agents string
@@ -146,9 +134,6 @@ type configOptions struct {
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtworkThrottleBuffered bool
DevArtworkWorkerConcurrency int
DevArtworkExternalMaxRPS int
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
@@ -159,29 +144,22 @@ type configOptions struct {
DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
DevOptimizeDB bool
DevPreserveUnicodeInExternalCalls bool
DevEnableMediaFileProbe bool
}
type scannerOptions struct {
Enabled bool
Schedule string
WatcherWait time.Duration
ScanOnStartup bool
Extractor string
ArtistJoiner string
ArtistSplitExceptions []string // Artist names never split by tag separators
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
FollowSymlinks bool // Whether to follow symlinks when scanning directories
IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning
PurgeMissing string // Values: "never", "always", "full"
}
type transcodingOptions struct {
MaxConcurrent int
MaxConcurrentPerUser int
EnableCancellation bool
Enabled bool
Schedule string
WatcherWait time.Duration
ScanOnStartup bool
Extractor string
ArtistJoiner string
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
FollowSymlinks bool // Whether to follow symlinks when scanning directories
PurgeMissing string // Values: "never", "always", "full"
}
type subsonicOptions struct {
@@ -211,7 +189,7 @@ type lastfmOptions struct {
ScrobbleFirstArtistOnly bool
// Computed values
Languages []string `conf:"-"` // Computed from Language, split by comma
Languages []string // Computed from Language, split by comma
}
type deezerOptions struct {
@@ -219,7 +197,7 @@ type deezerOptions struct {
Language string
// Computed values
Languages []string `conf:"-"` // Computed from Language, split by comma
Languages []string // Computed from Language, split by comma
}
type listenBrainzOptions struct {
@@ -229,18 +207,6 @@ type listenBrainzOptions struct {
TrackAlgorithm string
}
type jellyfinOptions struct {
Enabled bool
ServerName string
// 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
// 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.
MaxConcurrentStreams int
}
type httpHeaderOptions struct {
FrameOptions string
}
@@ -262,7 +228,7 @@ type jukeboxOptions struct {
type backupOptions struct {
Count int
Path Dir
Path string
Schedule string
}
@@ -280,7 +246,7 @@ type inspectOptions struct {
type pluginsOptions struct {
Enabled bool
Folder Dir
Folder string
CacheSize string
AutoReload bool
LogLevel string
@@ -315,33 +281,11 @@ 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()
)
// SnapshotConfig returns a function that restores Server to its current state.
// Uses JSON round-tripping so Dir fields get fresh sync.Once values.
func SnapshotConfig() func() {
snapshot, err := json.Marshal(Server)
if err != nil {
panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err))
}
return func() {
var restored configOptions
if err := json.Unmarshal(snapshot, &restored); err != nil {
panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err))
}
Server = &restored
}
}
func LoadFromFile(confFile string) {
viper.SetConfigFile(confFile)
err := viper.ReadInConfig()
@@ -351,31 +295,18 @@ 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()
// Map deprecated options to their new names for backwards compatibility
for _, o := range deprecatedOptions {
if o.replacement != "" {
mapDeprecatedOption(o.name, o.replacement)
}
}
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
err := viper.Unmarshal(&Server, viper.DecodeHook(
mapstructure.ComposeDecodeHookFunc(
mapstructure.TextUnmarshallerHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
),
))
err := viper.Unmarshal(&Server)
if err != nil {
logFatal("Error parsing config:", err)
}
@@ -385,28 +316,48 @@ func Load(noConfigDump bool) {
logFatal(err)
}
if Server.CacheFolder.String() == "" {
Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache"))
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
if err != nil {
logFatal("Error creating data path:", err)
}
if Server.CacheFolder == "" {
Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
}
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
if err != nil {
logFatal("Error creating cache path:", err)
}
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
if err != nil {
logFatal("Error creating artwork path:", err)
}
if Server.Plugins.Enabled {
if Server.Plugins.Folder.String() == "" {
Server.Plugins.Folder = NewDirWithPerm(filepath.Join(Server.DataFolder.String(), "plugins"), 0700)
} else {
Server.Plugins.Folder = NewDirWithPerm(Server.Plugins.Folder.String(), 0700)
if Server.Plugins.Folder == "" {
Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
}
err = os.MkdirAll(Server.Plugins.Folder, 0700)
if err != nil {
logFatal("Error creating plugins path:", err)
}
}
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
if Server.DbPath == "" {
Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath)
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
}
if Server.Backup.Path != "" {
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
if err != nil {
logFatal("Error creating backup path:", err)
}
}
out := os.Stderr
if Server.LogFile != "" {
if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
}
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
@@ -425,34 +376,12 @@ 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)
}
logRemovedOptions(removedOptions...)
logUnknownOptions()
err = run.Sequentially(
validateScanSchedule,
validateBackupSchedule,
validatePlaylistsPath,
validatePurgeMissingOption,
validateByteSize("MaxImageUploadSize", Server.MaxImageUploadSize),
validateByteSize("MaxImageSize", Server.MaxImageSize),
validateMaxImageUploadSize,
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
)
if err != nil {
@@ -505,6 +434,20 @@ func Load(noConfigDump bool) {
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
// Deprecated options
logDeprecatedOptions("Scanner.GenreSeparators", "")
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
logDeprecatedOptions("SearchFullString", "Search.FullString")
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
// Removed options
logRemovedOptions("Spotify.ID", "Spotify.Secret")
// Validate other options
if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
newValue := max(200, min(1200, Server.UICoverArtSize))
@@ -512,40 +455,15 @@ func Load(noConfigDump bool) {
Server.UICoverArtSize = newValue
}
// 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()
}
}
// deprecatedOptions still work, but will be removed in a future release. An empty
// replacement means the option is now ignored.
var deprecatedOptions = []struct{ name, replacement string }{
{"Scanner.GenreSeparators", ""},
{"Scanner.GroupAlbumReleases", ""},
{"DevEnableBufferedScrobble", ""},
{"SearchFullString", "Search.FullString"},
{"ReverseProxyWhitelist", "ExtAuth.TrustedSources"},
{"ReverseProxyUserHeader", "ExtAuth.UserHeader"},
{"HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions"},
{"CoverJpegQuality", "CoverArtQuality"},
{"SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold"},
{"EnableTranscodingCancellation", "Transcoding.EnableCancellation"},
}
var removedOptions = []string{"Spotify.ID", "Spotify.Secret"}
func logDeprecatedOptions(oldName, newName string) {
envVar := envVarName(oldName)
newEnvVar := envVarName(newName)
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_"))
newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_"))
logWarning := func(oldName, newName string) {
if newName != "" {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName))
@@ -565,7 +483,7 @@ func logDeprecatedOptions(oldName, newName string) {
// not available anymore
func logRemovedOptions(options ...string) {
for _, option := range options {
envVar := envVarName(option)
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
logWarning := func(option string) {
log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option))
}
@@ -586,193 +504,35 @@ func remapEnvVarKeysFromConfig() {
continue
}
stripped := strings.TrimPrefix(key, "nd_")
canonicalKey := ndKeyToCanonical(key)
canonicalKey := strings.ReplaceAll(stripped, "_", ".")
displayNDKey := "ND_" + strings.ToUpper(stripped)
canonicalName := canonicalOptionName(canonicalKey)
displayCanonical := toPascalCase(canonicalKey)
if viper.InConfig(canonicalKey) {
logFatal(fmt.Sprintf(
"Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+
"The 'ND_' prefix is only needed for environment variables, not config file keys.",
displayNDKey, cmp.Or(canonicalName, toPascalCase(canonicalKey)),
displayNDKey, displayCanonical,
))
return
}
viper.Set(canonicalKey, viper.Get(key))
// Unknown keys get no advice here, logUnknownOptions reports them instead
if canonicalName != "" {
_, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+
"The 'ND_' prefix is only needed for environment variables.\n",
displayNDKey, canonicalName,
)
}
_, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+
"The 'ND_' prefix is only needed for environment variables.\n",
displayNDKey, displayCanonical,
)
}
}
// mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after
// the config has been read by viper, but before unmarshalling it into the Config struct.
func mapDeprecatedOption(legacyName, newName string) {
// viper.Set outranks the config file, so an explicit replacement must win over the legacy value
if viper.IsSet(legacyName) && !explicitlySet(newName) {
if viper.IsSet(legacyName) {
viper.Set(newName, viper.Get(legacyName))
}
}
// explicitlySet reports whether the user provided the option, ignoring defaults,
// which viper.IsSet counts as set. The ND_ spelling is also accepted in the config
// file, and remapEnvVarKeysFromConfig has already moved it out of InConfig's reach.
func explicitlySet(name string) bool {
envVar := envVarName(name)
return viper.InConfig(name) || os.Getenv(envVar) != "" || viper.InConfig(strings.ToLower(envVar))
}
func envVarName(option string) string {
if option == "" {
return ""
}
return "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
}
func logUnknownOptions() {
for _, key := range unknownConfigKeys() {
msg := fmt.Sprintf("Option '%s' is not recognized and will be ignored", key)
if matches := suggestOptions(key); len(matches) > 0 {
msg += fmt.Sprintf(". Did you mean '%s'?", strings.Join(matches, "' or '"))
}
log.Warn(msg)
}
}
// suggestOptions returns the known options sharing the last segment with key,
// catching options written outside their section.
func suggestOptions(key string) []string {
key = strings.ToLower(key)
leaf := leafKey(key)
canonical, _ := configKeys()
var matches []string
for known, name := range canonical {
// Removed options are known only so they get their own warning, never suggest them
if known != key && leafKey(known) == leaf && !slices.Contains(removedOptions, name) {
matches = append(matches, name)
}
}
slices.Sort(matches)
return matches
}
func leafKey(key string) string {
return key[strings.LastIndex(key, ".")+1:]
}
// unknownConfigKeys returns config file keys that don't match any known option, so
// typos and options written outside their section don't fail silently.
func unknownConfigKeys() []string {
// INI files keep the original [default] section alongside the merged one
skipDefault := strings.EqualFold(filepath.Ext(viper.ConfigFileUsed()), ".ini")
var unknown []string
for _, key := range viper.AllKeys() {
if !viper.InConfig(key) || canonicalOptionName(key) != "" {
continue
}
if skipDefault && strings.HasPrefix(key, "default.") {
continue
}
// Only ND_-prefixed keys that remapEnvVarKeysFromConfig could resolve are valid
if strings.HasPrefix(key, "nd_") && canonicalOptionName(ndKeyToCanonical(key)) != "" {
continue
}
unknown = append(unknown, key)
}
slices.Sort(unknown)
return asWrittenInConfigFile(unknown)
}
func ndKeyToCanonical(key string) string {
return strings.ReplaceAll(strings.TrimPrefix(key, "nd_"), "_", ".")
}
// canonicalOptionName returns the documented spelling of a known option key, or ""
// if it matches no option. Subkeys of free-form maps have no fixed spelling.
func canonicalOptionName(key string) string {
keys, prefixes := configKeys()
if name, ok := keys[key]; ok {
return name
}
if slices.ContainsFunc(prefixes, func(p string) bool { return strings.HasPrefix(key, p) }) {
return toPascalCase(key)
}
return ""
}
// asWrittenInConfigFile restores the casing the keys have in the config file, as
// viper lowercases every key it loads.
func asWrittenInConfigFile(keys []string) []string {
if len(keys) == 0 {
return nil
}
data, err := os.ReadFile(viper.ConfigFileUsed())
if err != nil {
return keys
}
casing := map[string]string{}
for _, match := range configFileKeyRx.FindAllStringSubmatch(string(data), -1) {
for segment := range strings.SplitSeq(match[1], ".") {
lower := strings.ToLower(segment)
casing[lower] = cmp.Or(casing[lower], segment)
}
}
return slice.Map(keys, func(key string) string {
segments := strings.Split(key, ".")
for i, s := range segments {
segments[i] = cmp.Or(casing[s], s)
}
return strings.Join(segments, ".")
})
}
// Matches keys and section headers in all supported config formats.
var configFileKeyRx = regexp.MustCompile(`(?m)^\s*\[?\s*"?([\w.]+)"?\s*[]=:]`)
// configKeys maps every accepted option name, lowercased, to its canonical spelling,
// plus the prefixes of free-form map options (Tags, DevLogLevels).
var configKeys = sync.OnceValues(func() (map[string]string, []string) {
keys := map[string]string{}
var prefixes []string
var collect func(t reflect.Type, prefix string)
collect = func(t reflect.Type, prefix string) {
for field := range t.Fields() {
// `conf:"-"` marks values computed during Load, not settable in the config
if !field.IsExported() || field.Tag.Get("conf") == "-" {
continue
}
name := prefix + field.Name
if field.Type.Kind() == reflect.Struct && !reflect.PointerTo(field.Type).Implements(textUnmarshalerType) {
collect(field.Type, name+".")
continue
}
lower := strings.ToLower(name)
keys[lower] = name
if field.Type.Kind() == reflect.Map {
prefixes = append(prefixes, lower+".")
}
}
}
collect(reflect.TypeFor[configOptions](), "")
for _, o := range deprecatedOptions {
keys[strings.ToLower(o.name)] = o.name
}
for _, o := range removedOptions {
keys[strings.ToLower(o)] = o
}
return keys, prefixes
})
var textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
// section into the root level.
@@ -845,20 +605,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 {
@@ -884,7 +635,7 @@ func validateScanSchedule() error {
}
func validateBackupSchedule() error {
if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
Server.Backup.Schedule = ""
return nil
}
@@ -981,6 +732,7 @@ func setViperDefaults() {
viper.SetDefault("uiwelcomemessage", "")
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
viper.SetDefault("enabletranscodingconfig", false)
viper.SetDefault("enabletranscodingcancellation", false)
viper.SetDefault("transcodingcachesize", "100MB")
viper.SetDefault("imagecachesize", "100MB")
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
@@ -988,7 +740,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)
@@ -1001,7 +753,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", "")
@@ -1013,7 +764,7 @@ func setViperDefaults() {
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
viper.SetDefault("artistimagefolder", "")
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
viper.SetDefault("lyricspriority", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded")
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
viper.SetDefault("enablegravatar", false)
viper.SetDefault("enablefavourites", true)
viper.SetDefault("enablestarrating", true)
@@ -1029,17 +780,15 @@ 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("enablesharing", false)
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", "")
@@ -1057,11 +806,9 @@ func setViperDefaults() {
viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait)
viper.SetDefault("scanner.scanonstartup", true)
viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner)
viper.SetDefault("scanner.artistsplitexceptions", []string{})
viper.SetDefault("scanner.genreseparators", "")
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("scanner.followsymlinks", true)
viper.SetDefault("scanner.ignoredotfolders", true)
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.appendalbumversion", true)
@@ -1070,9 +817,6 @@ func setViperDefaults() {
viper.SetDefault("subsonic.enableaveragerating", true)
viper.SetDefault("subsonic.legacyclients", "DSub")
viper.SetDefault("subsonic.minimalclients", "SubMusic")
viper.SetDefault("transcoding.maxconcurrent", 0)
viper.SetDefault("transcoding.maxconcurrentperuser", 0)
viper.SetDefault("transcoding.enablecancellation", false)
viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
@@ -1085,8 +829,6 @@ func setViperDefaults() {
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
viper.SetDefault("jellyfin.enabled", false)
viper.SetDefault("jellyfin.servername", "")
viper.SetDefault("enablescrobblehistory", true)
viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("backup.path", "")
@@ -1116,19 +858,9 @@ func setViperDefaults() {
viper.SetDefault("devuishowconfig", true)
viper.SetDefault("devneweventstream", true)
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.
viper.SetDefault("jellyfin.maxconcurrentstreams", max(2, MaxOpenConns()/2))
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("devartworkthrottlebuffered", true)
// Half the CPU count (min 2), so local resolution scales with the host but stays under the
// SQLite pool (MaxOpenConns) — leaving connections for the scanner, scrobbles and the UI.
viper.SetDefault("devartworkworkerconcurrency", max(2, runtime.NumCPU()/2))
// External RPS gates outbound calls to third-party services (per service); it is bounded by
// their tolerance, not the host, so it stays a small constant regardless of CPU count.
viper.SetDefault("devartworkexternalmaxrps", 2)
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
viper.SetDefault("devexternalscanner", true)
@@ -1139,6 +871,7 @@ func setViperDefaults() {
viper.SetDefault("devenablepluginsinsights", true)
viper.SetDefault("devplugincompilationtimeout", time.Minute)
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
viper.SetDefault("devoptimizedb", true)
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
viper.SetDefault("devenablemediafileprobe", true)
}
@@ -1195,14 +928,3 @@ func getConfigFile(cfgFile string) string {
}
return ""
}
// MaxOpenConns is the size of the shared SQLite connection pool, used by every subsystem (scanner,
// Subsonic, Jellyfin, native API, UI).
//
// It bounds concurrent *readers*: SQLite serializes writers on a single database-wide write lock, so
// more connections buy no write parallelism. A connection is held while blocked on disk I/O or on a
// slow HTTP client, neither of which is CPU-bound — the CPU-bound knob is DevScannerThreads — so the
// count is only loosely related to core count, and the floor is what matters on small machines.
func MaxOpenConns() int {
return max(4, runtime.NumCPU())
}
+29 -234
View File
@@ -1,17 +1,12 @@
package conf_test
import (
"bytes"
"fmt"
"os"
"path/filepath"
"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"
"github.com/spf13/viper"
@@ -63,19 +58,6 @@ var _ = Describe("Configuration", func() {
})
})
Describe("scheduled DB analysis", func() {
It("is enabled by default", func() {
conf.Load(true)
Expect(conf.Server.EnableScheduledDBAnalyze).To(BeTrue())
})
It("can be disabled", func() {
viper.Set("enablescheduleddbanalyze", false)
conf.Load(true)
Expect(conf.Server.EnableScheduledDBAnalyze).To(BeFalse())
})
})
Describe("ValidateURL", func() {
It("accepts a valid http URL", func() {
fn := conf.ValidateURL("TestOption", "http://example.com/path")
@@ -183,123 +165,6 @@ var _ = Describe("Configuration", func() {
})
})
Describe("unknownConfigKeys", func() {
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
})
It("reports misplaced and misspelled options, as spelled in the config file", func() {
conf.InitConfig(filepath.Join("testdata", "cfg_unknown_keys.toml"), false)
conf.Load(true)
Expect(conf.UnknownConfigKeys()).To(ConsistOf(
"ArtistSplitExceptions", "EnableDownlods", "Whatever.Foo",
))
})
DescribeTable("recovers the original casing in all supported formats",
func(file string) {
conf.InitConfig(filepath.Join("testdata", file), false)
conf.Load(true)
Expect(conf.UnknownConfigKeys()).To(ConsistOf("NotAnOption"))
},
Entry("TOML", "cfg_unknown_casing.toml"),
Entry("YAML", "cfg_unknown_casing.yaml"),
Entry("JSON", "cfg_unknown_casing.json"),
Entry("INI", "cfg_unknown_casing.ini"),
)
It("does not report valid, deprecated or free-form keys", func() {
conf.InitConfig(filepath.Join("testdata", "cfg.toml"), false)
conf.Load(true)
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
})
It("does not report the [default] section of INI files", func() {
conf.InitConfig(filepath.Join("testdata", "cfg.ini"), false)
conf.Load(true)
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
})
DescribeTable("SuggestOptions",
func(key string, expected []string) {
Expect(conf.SuggestOptions(key)).To(Equal(expected))
},
Entry("suggests the section of a misplaced option", "artistsplitexceptions",
[]string{"Scanner.ArtistSplitExceptions"}),
Entry("suggests the section of a misplaced nested option", "backup.fuzzythreshold",
[]string{"Matcher.FuzzyThreshold"}),
Entry("suggests every section defining the option", "schedule",
[]string{"Backup.Schedule", "Scanner.Schedule"}),
Entry("suggests nothing for a typo", "enabledownlods", nil),
)
It("does not report ND_-prefixed keys, as they are remapped", func() {
conf.InitConfig(filepath.Join("testdata", "cfg_nd_keys.toml"), false)
conf.Load(true)
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
})
It("reports ND_-prefixed keys that remap to no known option", func() {
conf.InitConfig(filepath.Join("testdata", "cfg_nd_bogus.toml"), false)
conf.Load(true)
Expect(conf.UnknownConfigKeys()).To(ConsistOf("ND_TOTALLY_BOGUS_OPTION"))
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
})
It("migrates every deprecated option that has a replacement", func() {
conf.InitConfig(filepath.Join("testdata", "cfg_deprecated_search.toml"), false)
conf.Load(true)
Expect(conf.Server.Search.FullString).To(BeTrue())
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
})
It("warns about each unrecognized option at startup", func() {
var logBuf bytes.Buffer
log.SetOutput(&logBuf)
DeferCleanup(func() { log.SetOutput(GinkgoWriter) })
conf.InitConfig(filepath.Join("testdata", "cfg_warning_output.toml"), false)
conf.Load(true)
Expect(logBuf.String()).To(ContainSubstring(
"Option 'ArtistSplitExceptions' is not recognized and will be ignored. " +
"Did you mean 'Scanner.ArtistSplitExceptions'?"))
Expect(logBuf.String()).To(ContainSubstring(
"Option 'EnableDownlods' is not recognized and will be ignored"))
Expect(logBuf.String()).ToNot(ContainSubstring("ArtistJoiner"))
})
Context("with runtime-computed and removed options in the config", func() {
BeforeEach(func() {
conf.InitConfig(filepath.Join("testdata", "cfg_runtime_fields.toml"), false)
conf.Load(true)
})
It("reports values computed during Load, which the config cannot set", func() {
Expect(conf.UnknownConfigKeys()).To(ContainElements("ConfigFile", "LastFM.Languages"))
})
It("never suggests a removed option", func() {
Expect(conf.SuggestOptions("id")).To(BeEmpty())
})
It("keeps an explicit replacement over the deprecated value", func() {
Expect(conf.Server.Search.FullString).To(BeFalse())
})
})
})
Describe("logFatal", func() {
var invalidPath string
BeforeEach(func() {
@@ -321,12 +186,27 @@ var _ = Describe("Configuration", func() {
}).To(PanicWith(ContainSubstring("Error reading config file")))
})
It("is called when DataFolder is not writable", func() {
viper.SetDefault("datafolder", invalidPath)
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error creating data path")))
})
It("is called when CacheFolder is not writable", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("cachefolder", invalidPath)
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error creating cache path")))
})
It("is called when LogFile path is not writable", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt"))
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
}).To(PanicWith(ContainSubstring("Error opening log file")))
})
It("is called when BaseURL is invalid", func() {
@@ -339,10 +219,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"),
@@ -353,39 +242,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("EnforceNonRootUser", func() {
It("defaults to false", func() {
conf.Load(true)
@@ -455,73 +319,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())
})
})
-77
View File
@@ -1,77 +0,0 @@
package conf
import (
"cmp"
"fmt"
"os"
)
// Dir wraps a directory path and creates the directory on demand. Dir is a
// plain value type — safe to copy, compare, and print via reflection-based
// formatters (pretty.Sprintf("%# v", ...)) without any concurrency hazards.
// Directory creation is delegated to os.MkdirAll on every Path() call;
// MkdirAll is idempotent, so repeated calls cost one stat syscall when the
// directory already exists.
type Dir struct {
path string
perm os.FileMode
}
// NewDir creates a new Dir with the given path and default permissions (os.ModePerm).
func NewDir(path string) Dir {
return Dir{path: path, perm: os.ModePerm}
}
// NewDirWithPerm creates a new Dir with the given path and permissions.
// A perm of 0 is treated as "default" and resolves to os.ModePerm at
// directory-creation time; pass an explicit non-zero mode to constrain the
// permissions.
func NewDirWithPerm(path string, perm os.FileMode) Dir {
return Dir{path: path, perm: perm}
}
// String returns the raw path without creating the directory. Satisfies fmt.Stringer.
func (d Dir) String() string {
return d.path
}
// Path ensures the directory exists and returns its path. Safe to call
// repeatedly; an empty path is returned as-is with no error.
func (d Dir) Path() (string, error) {
if d.path == "" {
return "", nil
}
if err := os.MkdirAll(d.path, cmp.Or(d.perm, os.ModePerm)); err != nil {
return d.path, fmt.Errorf("creating directory %q: %w", d.path, err)
}
return d.path, nil
}
// MustPath calls Path() and calls logFatal on error.
func (d Dir) MustPath() string {
path, err := d.Path()
if err != nil {
logFatal("creating directory:", err)
}
return path
}
// GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf)
// prints the path string instead of the internal struct fields.
func (d Dir) GoString() string {
return fmt.Sprintf("%q", d.path)
}
// MarshalText returns the raw path bytes. No side effects.
func (d Dir) MarshalText() ([]byte, error) {
return []byte(d.path), nil
}
// UnmarshalText sets the path from bytes. No side effects.
func (d *Dir) UnmarshalText(text []byte) error {
d.path = string(text)
if d.perm == 0 {
d.perm = os.ModePerm
}
return nil
}
-164
View File
@@ -1,164 +0,0 @@
package conf_test
import (
"os"
"sync"
"github.com/kr/pretty"
"github.com/navidrome/navidrome/conf"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Dir", func() {
Describe("NewDir", func() {
It("creates a Dir with the given path without side effects", func() {
d := conf.NewDir("/some/path")
Expect(d.String()).To(Equal("/some/path"))
})
})
Describe("String", func() {
It("returns the raw path without creating the directory", func() {
d := conf.NewDir("/nonexistent/path/that/should/not/be/created")
Expect(d.String()).To(Equal("/nonexistent/path/that/should/not/be/created"))
})
})
Describe("Path", func() {
It("creates the directory and returns the path on first call", func() {
dir := GinkgoT().TempDir()
target := dir + "/subdir/nested"
d := conf.NewDir(target)
path, err := d.Path()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(target))
Expect(target).To(BeADirectory())
})
It("is idempotent on subsequent calls", func() {
dir := GinkgoT().TempDir()
target := dir + "/idempotent"
d := conf.NewDir(target)
path1, err1 := d.Path()
path2, err2 := d.Path()
Expect(err1).ToNot(HaveOccurred())
Expect(err2).ToNot(HaveOccurred())
Expect(path1).To(Equal(path2))
Expect(target).To(BeADirectory())
})
It("returns an error when directory cannot be created", func() {
f := GinkgoT().TempDir()
blocker := f + "/blocker"
By("creating a file that blocks directory creation")
Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed())
invalid := blocker + "/subdir"
d := conf.NewDir(invalid)
_, pathErr := d.Path()
Expect(pathErr).To(HaveOccurred())
})
It("returns empty path and no error for empty path", func() {
d := conf.NewDir("")
path, err := d.Path()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(BeEmpty())
})
})
Describe("MustPath", func() {
It("returns the path when directory is created successfully", func() {
dir := GinkgoT().TempDir()
target := dir + "/mustpath"
d := conf.NewDir(target)
path := d.MustPath()
Expect(path).To(Equal(target))
Expect(target).To(BeADirectory())
})
It("calls logFatal on error", func() {
var fatalMsg []any
restore := conf.SetLogFatal(func(args ...any) {
fatalMsg = args
panic("logFatal called")
})
DeferCleanup(restore)
f := GinkgoT().TempDir() + "/blocker"
Expect(os.WriteFile(f, []byte("x"), 0600)).To(Succeed())
invalid := f + "/subdir"
d := conf.NewDir(invalid)
Expect(func() { d.MustPath() }).To(Panic())
Expect(fatalMsg).ToNot(BeEmpty())
})
})
Describe("MarshalText", func() {
It("returns the raw path bytes without side effects", func() {
d := conf.NewDir("/marshal/path")
b, err := d.MarshalText()
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).To(Equal("/marshal/path"))
})
})
Describe("UnmarshalText", func() {
It("sets the path from bytes without side effects", func() {
d := conf.NewDir("")
err := d.UnmarshalText([]byte("/unmarshal/path"))
Expect(err).ToNot(HaveOccurred())
Expect(d.String()).To(Equal("/unmarshal/path"))
})
It("allows round-trip marshal/unmarshal", func() {
d1 := conf.NewDir("/round/trip")
b, err := d1.MarshalText()
Expect(err).ToNot(HaveOccurred())
var d2 conf.Dir
err = d2.UnmarshalText(b)
Expect(err).ToNot(HaveOccurred())
Expect(d2.String()).To(Equal(d1.String()))
})
})
Describe("GoString", func() {
// Regression: pretty.Sprintf("%# v", ...) is used by the
// configuration dump. It must render Dir as a quoted path via
// GoString, not dump the internal struct fields.
It("renders Dir as a quoted path under pretty.Sprintf", func() {
type host struct {
DataFolder conf.Dir
}
h := host{DataFolder: conf.NewDir("./data")}
out := pretty.Sprintf("%# v", h)
Expect(out).To(ContainSubstring(`DataFolder: "./data"`))
Expect(out).ToNot(ContainSubstring("perm:"))
Expect(out).ToNot(ContainSubstring("path:"))
})
It("is safe to copy and use concurrently", func() {
// Regression for the Windows "sync: unlock of unlocked mutex"
// crash that was caused by copying a Dir embedding sync.Once.
// Dir is a plain value type now, but keep the concurrent stress
// test to lock in the property.
dir := GinkgoT().TempDir()
d := conf.NewDir(dir + "/race")
var wg sync.WaitGroup
for range 10 {
wg.Go(func() {
copy1 := d
_ = pretty.Sprintf("%# v", copy1)
_, _ = copy1.Path()
})
}
wg.Wait()
})
})
})
+1 -5
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
@@ -32,7 +32,3 @@ func SetLogFatal(f func(...any)) func() {
logFatal = f
return func() { logFatal = old }
}
var UnknownConfigKeys = unknownConfigKeys
var SuggestOptions = suggestOptions
-2
View File
@@ -1,2 +0,0 @@
MusicFolder = "/toml/music"
SearchFullString = true
-3
View File
@@ -1,3 +0,0 @@
MusicFolder = "/toml/music"
ND_TOTALLY_BOGUS_OPTION = true
ND_SCANNER_SCHEDULE = "@every 1h"
-10
View File
@@ -1,10 +0,0 @@
MusicFolder = "/toml/music"
SearchFullString = true
ConfigFile = "/somewhere/else"
ID = "oops"
[Search]
FullString = false
[LastFM]
Languages = ["pt"]
-3
View File
@@ -1,3 +0,0 @@
[default]
MusicFolder = /ini/music
NotAnOption = true
-4
View File
@@ -1,4 +0,0 @@
{
"MusicFolder": "/json/music",
"NotAnOption": true
}
-2
View File
@@ -1,2 +0,0 @@
MusicFolder = "/toml/music"
NotAnOption = true
-2
View File
@@ -1,2 +0,0 @@
MusicFolder: /yaml/music
NotAnOption: true
-18
View File
@@ -1,18 +0,0 @@
MusicFolder = "/toml/music"
# Valid option, but written at the root level instead of under Scanner
ArtistSplitExceptions = ["AC/DC", "Tyler, the creator"]
# Misspelled option
EnableDownlods = true
# Unknown section
[Whatever]
Foo = "bar"
# Valid options, must not be reported
[Scanner]
ArtistJoiner = " • "
[Tags.custom]
aliases = ["toml", "test"]
-7
View File
@@ -1,7 +0,0 @@
MusicFolder = "/toml/music"
LogLevel = "warn"
ArtistSplitExceptions = ["AC/DC"]
EnableDownlods = true
[Scanner]
ArtistJoiner = " • "
-12
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"
-12
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"
+6 -36
View File
@@ -14,35 +14,18 @@ const (
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
InitialSetupFlagKey = "InitialSetup"
FullScanAfterMigrationFlagKey = "FullScanAfterMigration"
// PlaylistsImportPendingFlagKey marks that playlist import was deferred because
// no admin user existed yet; the next scan with an admin imports them.
PlaylistsImportPendingFlagKey = "PlaylistsImportPending"
LastScanErrorKey = "LastScanError"
LastScanTypeKey = "LastScanType"
LastScanStartTimeKey = "LastScanStartTime"
LastDBAnalyzeAtKey = "LastDBAnalyzeAt"
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 = "ArtConfFingerprint"
UIAuthorizationHeader = "X-ND-Authorization"
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
JWTSecretKey = "JWTSecret"
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"
OptimizeDBSchedule = "@every 24h"
// 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
@@ -58,11 +41,6 @@ const (
URLPathSubsonicAPI = "/rest"
URLPathPublic = "/share"
URLPathPublicImages = URLPathPublic + "/img"
URLPathJellyfinAPI = "/jellyfin"
// 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.
JellyfinServerIDKey = "JellyfinServerID"
// DefaultUILoginBackgroundURL uses Navidrome curated background images collection,
// available at https://unsplash.com/collections/20072696/navidrome
@@ -73,7 +51,6 @@ const (
DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline
DefaultMaxSidebarPlaylists = 100
DefaultAuthWindowLength = 20 * time.Second
RequestThrottleBacklogLimit = 100
RequestThrottleBacklogTimeout = time.Minute
@@ -89,9 +66,6 @@ const (
I18nFolder = "i18n"
ScanIgnoreFile = ".ndignore"
ArtworkFolder = "artwork"
// HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed
// upload folders beside it so Prune's sweep never reaches them.
HashedArtworkFolder = "hashed"
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
@@ -109,15 +83,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
@@ -183,30 +153,30 @@ var (
Name: "mp3 audio",
TargetFormat: "mp3",
DefaultBitRate: 192,
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -",
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
},
{
Name: "opus audio",
TargetFormat: "opus",
DefaultBitRate: 128,
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
},
{
Name: "aac audio",
TargetFormat: "aac",
DefaultBitRate: 256,
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
},
{
Name: "flac audio",
TargetFormat: "flac",
DefaultBitRate: 0,
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -",
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
},
}
)
var HTTPUserAgent = "Navidrome/" + Version + " - https://github.com/navidrome"
var HTTPUserAgent = "Navidrome" + "/" + Version
var (
VariousArtists = "Various Artists"
+1 -1
View File
@@ -2,7 +2,7 @@
name=$RC_SVCNAME
command="/opt/navidrome/${RC_SVCNAME}"
command_args="--datafolder /opt/navidrome"
command_args="-datafolder /opt/navidrome"
command_user="${RC_SVCNAME}"
pidfile="/var/run/${RC_SVCNAME}.pid"
output_log="/opt/navidrome/${RC_SVCNAME}.log"
+36 -154
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)
@@ -168,42 +124,6 @@ func (a *Agents) AgentName() string {
return "agents"
}
// ArtistImageAgent pairs an enabled agent's name with its ArtistImageRetriever capability.
type ArtistImageAgent struct {
Name string
Retriever ArtistImageRetriever
}
// AlbumImageAgent pairs an enabled agent's name with its AlbumImageRetriever capability.
type AlbumImageAgent struct {
Name string
Retriever AlbumImageRetriever
}
// ArtistImageAgents returns the enabled agents implementing ArtistImageRetriever,
// in conf.Server.Agents order (same order the aggregate dispatch uses).
func (a *Agents) ArtistImageAgents() []ArtistImageAgent {
var result []ArtistImageAgent
for _, ea := range a.getEnabledAgentNames() {
if retriever, ok := a.getAgent(ea).(ArtistImageRetriever); ok {
result = append(result, ArtistImageAgent{Name: ea.name, Retriever: retriever})
}
}
return result
}
// AlbumImageAgents returns the enabled agents implementing AlbumImageRetriever,
// in conf.Server.Agents order (same order the aggregate dispatch uses).
func (a *Agents) AlbumImageAgents() []AlbumImageAgent {
var result []AlbumImageAgent
for _, ea := range a.getEnabledAgentNames() {
if retriever, ok := a.getAgent(ea).(AlbumImageRetriever); ok {
result = append(result, AlbumImageAgent{Name: ea.name, Retriever: retriever})
}
}
return result
}
func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
switch id {
case consts.UnknownArtistID:
@@ -215,7 +135,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 +152,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 +169,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 +188,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 +201,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 +210,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 +224,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 +245,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 +259,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 +273,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 +284,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 +295,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 +313,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 +331,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)
+4 -215
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{{
@@ -499,70 +362,11 @@ var _ = Describe("Agents", func() {
})
})
})
Describe("Image retriever enumeration", func() {
var ag *Agents
var artistImg, artistImg2 *testImageAgent
var albumImg, albumImg2 *testAlbumImageAgent
BeforeEach(func() {
artistImg = &testImageAgent{Name: "artistImg"}
artistImg2 = &testImageAgent{Name: "artistImg2"}
albumImg = &testAlbumImageAgent{name: "albumImg"}
albumImg2 = &testAlbumImageAgent{name: "albumImg2"}
Register("artistImg", func(model.DataStore) Interface { return artistImg })
Register("artistImg2", func(model.DataStore) Interface { return artistImg2 })
Register("albumImg", func(model.DataStore) Interface { return albumImg })
Register("albumImg2", func(model.DataStore) Interface { return albumImg2 })
Register("noImages", func(model.DataStore) Interface { return &emptyAgent{} })
})
Describe("ArtistImageAgents", func() {
It("returns only ArtistImageRetriever agents, named, in configured order", func() {
conf.Server.Agents = "artistImg,noImages,artistImg2"
ag = createAgents(ds, nil)
result := ag.ArtistImageAgents()
Expect(result).To(HaveLen(2))
Expect(result[0].Name).To(Equal("artistImg"))
Expect(result[0].Retriever).To(BeIdenticalTo(artistImg))
Expect(result[1].Name).To(Equal("artistImg2"))
Expect(result[1].Retriever).To(BeIdenticalTo(artistImg2))
})
It("is empty when external services are disabled", func() {
conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false
ag = createAgents(ds, nil)
Expect(ag.ArtistImageAgents()).To(BeEmpty())
})
})
Describe("AlbumImageAgents", func() {
It("returns only AlbumImageRetriever agents, named, in configured order", func() {
conf.Server.Agents = "albumImg,noImages,albumImg2"
ag = createAgents(ds, nil)
result := ag.AlbumImageAgents()
Expect(result).To(HaveLen(2))
Expect(result[0].Name).To(Equal("albumImg"))
Expect(result[0].Retriever).To(BeIdenticalTo(albumImg))
Expect(result[1].Name).To(Equal("albumImg2"))
Expect(result[1].Retriever).To(BeIdenticalTo(albumImg2))
})
It("is empty when external services are disabled", func() {
conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false
ag = createAgents(ds, nil)
Expect(ag.AlbumImageAgents()).To(BeEmpty())
})
})
})
})
type mockAgent struct {
Args []any
Err error
Calls int
Args []any
Err error
}
func (a *mockAgent) AgentName() string {
@@ -587,7 +391,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
}
@@ -694,17 +497,3 @@ func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid strin
t.Args = []any{id, name, mbid}
return t.Images, t.Err
}
type testAlbumImageAgent struct {
name string
Images []ExternalImage
Err error
Args []any
}
func (t *testAlbumImageAgent) AgentName() string { return t.name }
func (t *testAlbumImageAgent) GetAlbumImages(_ context.Context, name, artist, mbid string) ([]ExternalImage, error) {
t.Args = []any{name, artist, mbid}
return t.Images, t.Err
}
+12 -63
View File
@@ -3,11 +3,7 @@ package agents
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/gohugoio/hashstructure"
"github.com/navidrome/navidrome/model"
)
@@ -37,67 +33,20 @@ type ExternalImage struct {
}
type Song struct {
ID string
Name string
MBID string
ISRC string
Artists []Artist
Album string
AlbumMBID string
Duration uint32 // Duration in milliseconds, 0 means unknown
ID string
Name string
MBID string
ISRC string
Artist string
ArtistMBID string
Album string
AlbumMBID string
Duration uint32 // Duration in milliseconds, 0 means unknown
}
// Equals reports strict whole-value equality, used to dedup identical input songs. It hashes
// rather than comparing with ==, which the Artists slice makes illegal.
func (s Song) Equals(other Song) bool {
h1, _ := hashstructure.Hash(s, nil)
h2, _ := hashstructure.Hash(other, nil)
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 {
-42
View File
@@ -1,42 +0,0 @@
package agents_test
import (
"errors"
"fmt"
"time"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("RetryLaterError", func() {
It("matches the ErrRetryLater sentinel via errors.Is", func() {
err := &agents.RetryLaterError{RetryIn: 30 * time.Second}
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
})
It("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("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("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())
})
})
+7 -46
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"
@@ -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(ctx).Get(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(ctx).GetRandom(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() {
-96
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")
})
})
-27
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())
})
})
+27 -50
View File
@@ -3,7 +3,6 @@ package core
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"os"
@@ -14,7 +13,6 @@ import (
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
@@ -22,7 +20,7 @@ import (
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
ZipShare(ctx context.Context, s *model.Share, w io.Writer) error
ZipShare(ctx context.Context, id string, w io.Writer) error
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
}
@@ -41,13 +39,7 @@ func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitra
}
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)
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_artist_id": id})
}
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error {
@@ -68,15 +60,7 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
for _, mf := range album {
file := a.albumFilename(mf, format, isMultiDisc)
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
// Stop iterating: continuing would just rack up more
// rejections from the limiter. Close finalises whatever
// tracks were already written; the rejected one is not
// present in the archive (addFileToZip aborts before
// writing its entry header).
_ = z.Close()
return addErr
}
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
}
}
err = z.Close()
@@ -107,14 +91,16 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
}
// ZipShare takes an already-loaded share: Share.Load records a visit, so
// loading it again here would count every download twice.
func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer) error {
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
s, err := a.shares.Load(ctx, id)
if err != nil {
return err
}
if !s.Downloadable {
return model.ErrNotAuthorized
}
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
return a.zipMediaFiles(ctx, 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 {
@@ -134,12 +120,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
zippedMfs := make(model.MediaFiles, len(mfs))
for idx, mf := range mfs {
file := a.playlistFilename(mf, format, idx)
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
// Abort the whole archive: continuing would silently emit
// empty zip entries since the headers are already written.
_ = z.Close()
return addErr
}
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
mf.Path = file
zippedMfs[idx] = mf
}
@@ -181,27 +162,6 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
path := mf.AbsolutePath()
// Open the source before writing the zip entry header so a rejection
// (limiter, missing file, etc.) does not leave an empty entry in the
// archive.
var r io.ReadCloser
var err error
if format != "raw" && format != "" {
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
} else {
r, err = os.Open(path)
}
if err != nil {
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
return err
}
defer func() {
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
}
}()
w, err := z.CreateHeader(&zip.FileHeader{
Name: filename,
Modified: mf.UpdatedAt,
@@ -212,6 +172,23 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
return err
}
var r io.ReadCloser
if format != "raw" && format != "" {
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
} else {
r, err = os.Open(path)
}
if err != nil {
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
return err
}
defer func() {
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
}
}()
_, err = io.Copy(w, r)
if err != nil {
log.Error(ctx, "Error zipping file", "file", path, err)
+4 -37
View File
@@ -11,7 +11,6 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
@@ -70,11 +69,8 @@ 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", mock.Anything).Return(mfRepo)
@@ -93,32 +89,6 @@ var _ = Describe("Archiver", func() {
})
})
Context("when the transcode limiter rejects a file", func() {
It("aborts the archive instead of continuing with empty entries", func() {
mfs := model.MediaFiles{
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
}
mfRepo := &mockMediaFileRepository{}
mfRepo.On("GetAll", []model.QueryOptions{{
Filters: squirrel.Eq{"album_id": "1"},
Sort: "album",
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
Return(nil, stream.ErrTooManyTranscodes).Once()
out := new(bytes.Buffer)
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
Expect(err).To(MatchError(stream.ErrTooManyTranscodes))
// NewStream should only have been called once: the loop must bail
// out on the rejection instead of trying every remaining track.
ms.AssertNumberOfCalls(GinkgoT(), "NewStream", 1)
})
})
Context("ZipShare", func() {
It("zips a share correctly", func() {
mfs := model.MediaFiles{
@@ -134,16 +104,13 @@ var _ = Describe("Archiver", func() {
Tracks: mfs,
}
sh.On("Load", mock.Anything, "1").Return(share, nil)
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)
err := arch.ZipShare(context.Background(), share, out)
err := arch.ZipShare(context.Background(), "1", out)
Expect(err).To(BeNil())
// Share.Load records a visit; re-loading here would double-count
// every download.
sh.AssertNotCalled(GinkgoT(), "Load", mock.Anything, mock.Anything)
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).To(BeNil())
-131
View File
@@ -1,131 +0,0 @@
package artwork
import (
"context"
"errors"
"io"
"net/url"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str"
)
// externalName mirrors the normalization the aggregate provider applies, so agent searches match.
func externalName(name string) string {
if conf.Server.DevPreserveUnicodeInExternalCalls {
return name
}
return str.Clear(name)
}
// bestImageURL returns the largest fetchable image URL. Only one is returned and its failure ends
// the agent's turn, so an unfetchable candidate must never win: url.Parse alone accepts anything.
func bestImageURL(imgs []agents.ExternalImage) *url.URL {
var best *url.URL
var bestSize int
for i := range imgs {
if imgs[i].URL == "" {
continue
}
u, err := url.Parse(imgs[i].URL)
if err != nil || !u.IsAbs() || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
continue
}
if best == nil || imgs[i].Size > bestSize {
best, bestSize = u, imgs[i].Size
}
}
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
// 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) {
// 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
}
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) {
imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID)
if err != nil {
return nil, "", err
}
u := bestImageURL(imgs)
if u == nil {
return nil, "", agents.ErrNotFound
}
return fromURL(ctx, u)
})
recordAgent(ctx, a.Name, reader, path, err)
if reader != nil {
return reader, a.Name, nil
}
if isTransientExternal(err) {
extErr = longerRetry(extErr, err)
log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err)
}
}
return nil, "", extErr
}
// fetchAlbumImage is the album counterpart of fetchArtistImage.
func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (io.ReadCloser, string, error) {
name, artist := externalName(al.Name), externalName(al.AlbumArtist)
imageAgents := ag.AlbumImageAgents()
if len(imageAgents) == 0 {
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped,
Detail: "no enabled agent provides album images"})
return nil, "", nil
}
var extErr error
for _, a := range imageAgents {
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID)
if err != nil {
return nil, "", err
}
u := bestImageURL(imgs)
if u == nil {
return nil, "", agents.ErrNotFound
}
return fromURL(ctx, u)
})
recordAgent(ctx, a.Name, reader, path, err)
if reader != nil {
return reader, a.Name, nil
}
if isTransientExternal(err) {
extErr = longerRetry(extErr, err)
log.Debug(ctx, "Artwork: External album-image lookup failed", "agent", a.Name, "album", al.Name, err)
}
}
return nil, "", extErr
}
-323
View File
@@ -1,323 +0,0 @@
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"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/str"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// fakeImageAgent is a built-in agent stub implementing both image retrievers; it
// records call counts so per-agent ordering and short-circuiting can be asserted.
type fakeImageAgent struct {
name string
imgs []agents.ExternalImage
err error
artistCalls int
albumCalls int
gotArtistName string
gotAlbumName string
// block, when set, holds every lookup until closed, standing in for a slow/rate-limited agent.
block chan struct{}
// mu guards the call counters: the worker resolves several items concurrently.
mu sync.Mutex
}
func (f *fakeImageAgent) AgentName() string { return f.name }
func (f *fakeImageAgent) GetArtistImages(_ context.Context, _, name, _ string) ([]agents.ExternalImage, error) {
if f.block != nil {
<-f.block
}
f.mu.Lock()
f.artistCalls++
f.gotArtistName = name
f.mu.Unlock()
return f.imgs, f.err
}
func (f *fakeImageAgent) GetAlbumImages(_ context.Context, name, _, _ string) ([]agents.ExternalImage, error) {
f.albumCalls++
f.gotAlbumName = name
return f.imgs, f.err
}
// imageAgents registers the fakes as built-in agents and enables them in order. The fakes
// ignore the DataStore, so reusing the process-wide GetAgents singleton across tests is safe.
func imageAgents(fakes ...*fakeImageAgent) *agents.Agents {
names := make([]string, 0, len(fakes))
for _, f := range fakes {
fake := f
agents.Register(fake.name, func(model.DataStore) agents.Interface { return fake })
names = append(names, fake.name)
}
conf.Server.Agents = strings.Join(names, ",")
return agents.GetAgents(&tests.MockDataStore{}, nil)
}
var _ = Describe("agent images", func() {
var (
ctx context.Context
srv *httptest.Server
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = context.Background()
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("image-bytes"))
}))
DeferCleanup(srv.Close)
})
img := func(path string, size int) agents.ExternalImage {
return agents.ExternalImage{URL: srv.URL + path, Size: size}
}
Describe("bestImageURL", func() {
It("picks the largest-Size URL and skips empty ones", func() {
u := bestImageURL([]agents.ExternalImage{
{URL: "http://x/small", Size: 10},
{URL: "", Size: 9999},
{URL: "http://x/big", Size: 100},
})
Expect(u).ToNot(BeNil())
Expect(u.String()).To(Equal("http://x/big"))
})
It("skips a malformed largest URL and falls back to a valid smaller one", func() {
u := bestImageURL([]agents.ExternalImage{
{URL: "http://x/valid", Size: 10},
{URL: "http://x/%zz", Size: 100}, // invalid percent-escape, largest
})
Expect(u).ToNot(BeNil())
Expect(u.String()).To(Equal("http://x/valid"))
})
It("returns nil when there is no non-empty URL", func() {
Expect(bestImageURL(nil)).To(BeNil())
Expect(bestImageURL([]agents.ExternalImage{{URL: "", Size: 5}})).To(BeNil())
})
// Plugins hand these over as free-form strings, and url.Parse accepts them all. An
// unfetchable candidate that wins here ends the agent's turn before its valid images run.
DescribeTable("skips a candidate that cannot be fetched",
func(badURL string) {
u := bestImageURL([]agents.ExternalImage{
{URL: badURL, Size: 100}, // largest, and first
{URL: "https://cdn.example.com/ok.jpg", Size: 10},
})
Expect(u).ToNot(BeNil())
Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg"))
},
Entry("a relative path", "images/big.jpg"),
Entry("a root-relative path", "/images/big.jpg"),
Entry("a scheme we cannot fetch", "ftp://host/big.jpg"),
Entry("a scheme-relative URL", "//host/big.jpg"),
Entry("a URL with no host", "http:///big.jpg"),
)
// Size is often 0 for every candidate, and only a strictly larger one replaces the first,
// so an unfetchable entry in first position would otherwise stick.
It("skips an unfetchable first candidate when every Size is zero", func() {
u := bestImageURL([]agents.ExternalImage{
{URL: "images/rel.jpg"},
{URL: "https://cdn.example.com/ok.jpg"},
})
Expect(u).ToNot(BeNil())
Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg"))
})
It("returns nil when no candidate is fetchable", func() {
Expect(bestImageURL([]agents.ExternalImage{
{URL: "images/a.jpg", Size: 10},
{URL: "ftp://host/b.jpg", Size: 20},
})).To(BeNil())
})
})
Describe("fetchArtistImage", func() {
It("returns the first agent's image and its name", 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"})
Expect(r).ToNot(BeNil())
defer r.Close()
Expect(name).To(Equal("agentA"))
Expect(err).ToNot(HaveOccurred())
})
It("skips the external lookup for synthetic artists", func() {
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
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"})
Expect(r).To(BeNil())
Expect(name).To(BeEmpty())
Expect(err).ToNot(HaveOccurred())
}
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"}
ag := imageAgents(a)
_, _, _ = fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "ACDC"})
Expect(a.gotArtistName).To(Equal(str.Clear("ACDC")))
})
It("falls through to a later agent, and its success beats the earlier error", func() {
a := &fakeImageAgent{name: "agentA", err: errBreakerOpen}
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"})
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(a.artistCalls).To(Equal(1))
Expect(b.artistCalls).To(Equal(1))
})
It("reports a clean miss when every agent finds nothing", func() {
a := &fakeImageAgent{name: "agentA"} // no images, no error -> not found
b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound}
ag := imageAgents(a, b)
r, name, err := 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")
})
It("reports an error when one agent fails transiently and the rest find nothing", func() {
a := &fakeImageAgent{name: "agentA", err: agents.ErrNotFound}
b := &fakeImageAgent{name: "agentB", err: context.DeadlineExceeded}
ag := imageAgents(a, b)
r, _, err := 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")
})
})
Describe("fetchAlbumImage", func() {
It("returns the winning agent's image and name", 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"})
Expect(r).ToNot(BeNil())
defer r.Close()
Expect(name).To(Equal("agentA"))
Expect(err).ToNot(HaveOccurred())
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() {
a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded}
ag := imageAgents(a)
r, _, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"})
Expect(r).To(BeNil())
Expect(err).To(HaveOccurred())
})
})
Describe("gate naming", func() {
It("invokes the gate once per agent, keyed by agent name", func() {
a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded}
b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 1)}}
ag := imageAgents(a, b)
var gatedNames []string
gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
gatedNames = append(gatedNames, name)
return f()
}
r, _, _ := fetchArtistImage(ctx, ag, gate, model.Artist{ID: "ar1"})
Expect(r).ToNot(BeNil())
defer r.Close()
Expect(gatedNames).To(Equal([]string{"agentA", "agentB"}))
})
})
})
+77 -377
View File
@@ -1,434 +1,134 @@
package artwork
import (
"bytes"
"context"
"errors"
"fmt"
_ "image/gif"
"io"
"os"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/utils/cache"
_ "golang.org/x/image/webp"
)
var ErrUnavailable = errors.New("artwork unavailable")
// errStaleSource means the backing file's mtime no longer matches RefMtime, so the stored hash may be stale.
var errStaleSource = errors.New("artwork: source file changed since resolution")
// Image is one servable artwork response.
type Image struct {
io.ReadCloser
Hash string // pixel identity; "" for placeholders
ETag string // representation validator; "" means Hash applies (full-size original)
LastUpdated time.Time
Placeholder bool
}
// representationTag varies with dimensions and encode settings, so a config change invalidates
// a revalidating client's cache even though the pixel hash is unchanged.
func representationTag(hash string, size int, square bool) string {
return fmt.Sprintf("%s.%d.%v.%s", hash, size, square, formatQualityTag())
}
type Artwork interface {
// Get returns ErrUnavailable when there is nothing to serve and model.ErrNotFound when
// the id resolves to nothing, so the caller can pick placeholder vs 404.
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error)
// GetOrPlaceholder accepts an artwork token or a raw entity id, falling back to the
// kind's placeholder image (never resized, Placeholder=true).
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error)
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error)
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error)
}
func NewArtwork(ds model.DataStore, cache cache.FileCache, store *ImageStore, ffm ffmpeg.FFmpeg) Artwork {
return &service{ds: ds, cache: cache, store: store, ffmpeg: ffm}
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork {
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
}
// entityExists reports whether the entity an artwork id points at is still there: state rows
// outlive a deleted entity until the next prune, so a servable row is not evidence of its owner.
func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID) bool {
var found bool
var err error
switch artID.Kind {
case model.KindArtistArtwork:
found, err = ds.Artist(ctx).Exists(artID.ID)
case model.KindAlbumArtwork:
found, err = ds.Album(ctx).Exists(artID.ID)
case model.KindMediaFileArtwork:
found, err = ds.MediaFile(ctx).Exists(artID.ID)
case model.KindPlaylistArtwork:
found, err = ds.Playlist(ctx).Exists(artID.ID)
case model.KindRadioArtwork:
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(ctx).Exists(albumID)
default:
return false
}
return err == nil && found
type artwork struct {
ds model.DataStore
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
provider external.Provider
}
type service struct {
ds model.DataStore
cache cache.FileCache
store *ImageStore
ffmpeg ffmpeg.FFmpeg
type artworkReader interface {
cache.Item
LastUpdated() time.Time
Reader(ctx context.Context) (io.ReadCloser, string, error)
}
func (s *service) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) {
artID, err := s.parseArtworkID(ctx, id)
var img *Image
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
artID, err := a.getArtworkId(ctx, id)
if err == nil {
img, err = s.Get(ctx, artID, size, square)
reader, lastUpdate, err = a.Get(ctx, artID, size, square)
}
// Only a resolvable entity with no art gets a placeholder; an unknown id must stay
// ErrNotFound so callers can still answer 404 / Subsonic error 70.
if errors.Is(err, ErrUnavailable) {
return placeholderImage(artID.Kind), nil
if artID.Kind == model.KindArtistArtwork {
reader, _ = resources.FS().Open(consts.PlaceholderArtistArt)
} else {
reader, _ = resources.FS().Open(consts.PlaceholderAlbumArt)
}
return reader, consts.ServerStart, nil
}
return img, err
return reader, lastUpdate, err
}
func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
if artID.ID == "" {
return nil, ErrUnavailable
}
if size < 0 {
size = 0 // a negative size means full-size, not a giant (OOM) resize rectangle
}
switch artID.Kind {
case model.KindDiscArtwork:
return s.serveDisc(ctx, artID, size, square)
case model.KindMediaFileArtwork:
return s.serveMediaFile(ctx, artID, size, square)
default:
return s.serveEntity(ctx, artID, size, square)
}
}
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary)
switch {
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.
return nil, ErrUnavailable
default:
return s.serveHash(ctx, artID, ia, size, square)
}
}
// serveSource is the one place bytes become an Image. hash is the pixel identity ("" for disc art)
// and doubles as the full-size validator, so an ETag is only needed when resized or hash is "".
func (s *service) serveSource(ctx context.Context, key, hash string, lastUpdate time.Time,
size int, square bool, open func() (io.ReadCloser, error),
) (*Image, error) {
if size == 0 && !square {
rc, err := open()
if err != nil {
return nil, err
}
if rc == nil {
return nil, ErrUnavailable
}
img := &Image{ReadCloser: rc, Hash: hash, LastUpdated: lastUpdate}
if hash == "" {
img.ETag = representationTag(key, size, square)
}
return img, nil
}
stream, err := s.cache.Get(ctx, &resizedItem{
hash: key, size: size, square: square, ffmpeg: s.ffmpeg, open: open,
})
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
artReader, err := a.getArtworkReader(ctx, artID, size, square)
if err != nil {
return nil, err
return nil, time.Time{}, err
}
return &Image{ReadCloser: stream, Hash: hash, ETag: representationTag(key, size, square), LastUpdated: lastUpdate}, nil
}
// serveHash serves the bytes of a found state row. A mismatch/open error is dangling, but a
// cancelled request is not: it must not enqueue a re-resolution.
func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *model.ItemArtwork, size int, square bool) (*Image, error) {
// Only this path can hand back a deleted entity's bytes; the others load their entity anyway.
if !entityExists(ctx, s.ds, artID) {
return nil, ErrUnavailable
}
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
r, err := a.cache.Get(ctx, artReader)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return s.dangling(ctx, artID)
if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) {
log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err)
}
return nil, err
return nil, time.Time{}, err
}
img, err := s.serveSource(ctx, ia.Hash, ia.Hash, ia.UpdatedAt, size, square,
func() (io.ReadCloser, error) { return openOriginal(ia, art.Mime, s.store) })
if err != nil {
if errors.Is(err, context.Canceled) {
return nil, err
}
log.Warn(ctx, "Artwork: Could not serve image", "artID", artID, "size", size, err)
return s.dangling(ctx, artID)
}
return img, nil
return r, artReader.LastUpdated(), nil
}
// openOriginal enforces the mtime invariant: bytes are never served under a hash they no longer match.
func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.ReadCloser, error) {
if isFileBacked(ia.Source) {
f, err := os.Open(ia.SourcePath)
if err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return nil, err
}
if ia.RefMtime != 0 && info.ModTime().UnixNano() != ia.RefMtime {
f.Close()
log.Debug("Artwork: Backing file changed since resolution", "path", ia.SourcePath,
"hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano())
return nil, errStaleSource
}
return f, nil
}
// Store-backed bytes still carry the source's mtime, to detect edits to embedded art.
if ia.SourcePath != "" && ia.RefMtime != 0 {
info, err := os.Stat(ia.SourcePath)
if err != nil {
return nil, err
}
if info.ModTime().UnixNano() != ia.RefMtime {
log.Debug("Artwork: Source file changed since resolution", "path", ia.SourcePath,
"hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano())
return nil, errStaleSource
}
}
return store.Open(ia.Hash, mime)
}
// provisional serves local bytes for an entity with no state row, enqueuing the worker but
// never writing a state row itself.
func (s *service) provisional(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
item := model.ArtworkQueueItem{ItemKind: artID.Kind.Prefix(), ItemID: artID.ID, ImageType: model.ImageTypePrimary}
res, err := newLocalResolver(s.ds, s.ffmpeg).resolve(ctx, item)
if err != nil {
return nil, err
}
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
log.Debug(ctx, "Artwork: Provisional read-through, no state row yet", "artID", artID,
"source", res.source, "hit", res.reader != nil)
return s.serveResolution(ctx, res, size, square)
}
// serveResolution turns a local resolution's bytes into a servable Image (byte-hash only, no decode).
func (s *service) serveResolution(ctx context.Context, res resolution, size int, square bool) (*Image, error) {
if res.reader == nil {
return nil, ErrUnavailable
}
defer res.reader.Close()
data, err := readCapped(res.reader)
if err != nil {
return nil, ErrUnavailable
}
hash, err := hashImage(bytes.NewReader(data))
if err != nil {
return nil, ErrUnavailable
}
// Keyed by the byte-hash, so the entry lines up with the worker's eventual store entry.
return s.serveSource(ctx, hash, hash, unixMtime(res.refMtime), size, square,
func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil })
}
func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
// 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(ctx).Get(artID.ID)
if err != nil {
return nil, err
}
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
}
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)
case err == nil:
// absent row: fall through
case errors.Is(err, model.ErrNotFound):
// no row: fall through
default:
return nil, err
}
noRow := errors.Is(err, model.ErrNotFound)
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
if err != nil {
return nil, err
}
if noRow && conf.Server.EnableMediaFileCoverArt && mf.HasCoverArt {
return s.provisionalEmbedded(ctx, artID, *mf, size, square)
}
// Mirror MediaFile.CoverArtID: a track defers to its disc art, which falls back to the album.
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
}
// provisionalEmbedded serves a track's embedded art immediately, leaving the state row to the worker.
func (s *service) provisionalEmbedded(ctx context.Context, artID model.ArtworkID, mf model.MediaFile, size int, square bool) (*Image, error) {
lib, err := loadLibraryView(ctx, s.ds, mf.LibraryID)
if err != nil {
return nil, err
}
res, ok := resolveEmbedded(ctx, lib, s.ffmpeg, mf.Path)
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
if !ok {
// Eligible but unextractable: fall back the way CoverArtID does, not to a placeholder.
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
}
return s.serveResolution(ctx, res, size, square)
}
// serveDisc reads disc art through with no state row and no enqueue, falling back to the album cover.
func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
dr, err := newDiscArtworkReader(ctx, s.ds, artID)
if err != nil {
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
}
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)
if err != nil {
if errors.Is(err, context.Canceled) {
return nil, err
}
return s.Get(ctx, albumArtID, size, square)
}
return img, nil
}
// dangling enqueues a re-resolution and reports unavailable, leaving the state row untouched.
func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, error) {
log.Debug(ctx, "Artwork: State row points at bytes we cannot serve, re-resolving", "artID", artID)
s.enqueue(ctx, artID, model.ArtworkPriorityScan)
return nil, ErrUnavailable
}
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
ItemKind: artID.Kind.Prefix(),
ItemID: artID.ID,
ImageType: model.ImageTypePrimary,
Priority: priority,
})
if err != nil {
log.Warn(ctx, "Artwork: Could not enqueue re-resolution", "artID", artID, err)
}
}
func placeholderImage(kind model.Kind) *Image {
path := consts.PlaceholderAlbumArt
if kind == model.KindArtistArtwork {
path = consts.PlaceholderArtistArt
}
r, _ := resources.FS().Open(path)
return &Image{ReadCloser: r, Placeholder: true}
}
type coverArtIDGetter interface {
type coverArtGetter interface {
CoverArtID() model.ArtworkID
}
// parseArtworkID accepts an artwork token or a raw entity id, resolving the latter to its CoverArtID.
func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkID, error) {
func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) {
if id == "" {
return model.ArtworkID{}, ErrUnavailable
}
if artID, err := model.ParseArtworkID(id); err == nil {
artID, err := model.ParseArtworkID(id)
if err == nil {
return artID, nil
}
entity, err := model.GetEntityByID(ctx, s.ds, id)
log.Trace(ctx, "ArtworkID invalid. Trying to figure out kind based on the ID", "id", id)
entity, err := model.GetEntityByID(ctx, a.ds, id)
if err != nil {
return model.ArtworkID{}, err
}
if e, ok := entity.(coverArtIDGetter); ok {
return e.CoverArtID(), nil
if e, ok := entity.(coverArtGetter); ok {
artID = e.CoverArtID()
}
return model.ArtworkID{}, model.ErrNotFound
switch e := entity.(type) {
case *model.Artist:
log.Trace(ctx, "ID is for an Artist", "id", id, "name", e.Name, "artist", e.Name)
case *model.Album:
log.Trace(ctx, "ID is for an Album", "id", id, "name", e.Name, "artist", e.AlbumArtist)
case *model.MediaFile:
log.Trace(ctx, "ID is for a MediaFile", "id", id, "title", e.Title, "album", e.Album)
case *model.Playlist:
log.Trace(ctx, "ID is for a Playlist", "id", id, "name", e.Name)
}
return artID, nil
}
// 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)
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) {
var artReader artworkReader
var err error
if size > 0 || square {
artReader, err = resizedFromOriginal(ctx, a, artID, size, square)
} else {
switch artID.Kind {
case model.KindArtistArtwork:
artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider)
case model.KindAlbumArtwork:
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
case model.KindMediaFileArtwork:
artReader, err = newMediafileArtworkReader(ctx, a, artID)
case model.KindPlaylistArtwork:
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
case model.KindDiscArtwork:
artReader, err = newDiscArtworkReader(ctx, a, artID)
case model.KindRadioArtwork:
artReader, err = newRadioArtworkReader(ctx, a, artID)
default:
return nil, ErrUnavailable
}
}
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{}
}
return time.Unix(0, mtime) // RefMtime is unix-nanoseconds
return artReader, err
}
+628
View File
@@ -0,0 +1,628 @@
package artwork
import (
"context"
"errors"
"image"
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"time"
_ "github.com/gen2brain/webp"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Artwork", func() {
var aw *artwork
var ds model.DataStore
var ffmpeg *tests.MockFFmpeg
var folderRepo *fakeFolderRepo
ctx := log.NewContext(context.TODO())
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album
var arMultipleCovers model.Artist
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.ImageCacheSize = "0" // Disable cache
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
folderRepo = &fakeFolderRepo{}
libRepo := &tests.MockLibraryRepo{}
repoRoot, _ := os.Getwd()
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
ds = &tests.MockDataStore{
MockedTranscoding: &tests.MockTranscodingRepo{},
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
// Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB.
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}}
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
alMultipleCovers = model.Album{
ID: "666",
Name: "All options",
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
FolderIDs: []string{"f1"},
AlbumArtistID: "777",
}
mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"}
mfAnotherWithEmbed = model.MediaFile{ID: "23", Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true, AlbumID: "666"}
mfWithoutEmbed = model.MediaFile{ID: "44", Path: "tests/fixtures/test.ogg", AlbumID: "444"}
mfCorruptedCover = model.MediaFile{ID: "45", Path: "tests/fixtures/test.ogg", HasCoverArt: true, AlbumID: "444"}
cache := GetImageCache()
ffmpeg = tests.NewMockFFmpeg("content from ffmpeg")
aw = NewArtwork(ds, cache, ffmpeg, nil).(*artwork)
})
Describe("albumArtworkReader", func() {
Context("ID not found", func() {
It("returns ErrNotFound if album is not in the DB", func() {
_, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT-FOUND"), nil)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Context("Embed images", func() {
BeforeEach(func() {
folderRepo.result = nil
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alOnlyEmbed,
alEmbedNotFound,
})
})
It("returns embed cover", func() {
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3"))
})
It("returns ErrUnavailable if embed path is not available", func() {
ffmpeg.Error = errors.New("not available")
aw, err := newAlbumArtworkReader(ctx, aw, alEmbedNotFound.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, _, err = aw.Reader(ctx)
Expect(err).To(MatchError(ErrUnavailable))
})
})
Context("External images", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alOnlyExternal,
alExternalNotFound,
})
})
It("returns external cover", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"front.png"},
}}
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png"))
})
It("returns ErrUnavailable if external file is not available", func() {
folderRepo.result = []model.Folder{}
aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, _, err = aw.Reader(ctx)
Expect(err).To(MatchError(ErrUnavailable))
})
})
Context("Multiple covers", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg", "front.png", "artist.png"},
}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alMultipleCovers,
})
})
DescribeTable("CoverArtPriority",
func(priority string, expected string) {
conf.Server.CoverArtPriority = priority
aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(expected))
},
Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"),
Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"),
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"),
)
})
Context("LastUpdated", func() {
// Regression test for #5377: LastUpdated feeds the HTTP Last-Modified header.
// It must return max(album.UpdatedAt, ImagesUpdatedAt) so browsers revalidate
// cached cover art when only the image file changes.
now := time.Now().Truncate(time.Second)
DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt",
func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) {
album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt}
folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album})
ar, err := newAlbumArtworkReader(ctx, aw, album.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
Expect(ar.LastUpdated()).To(Equal(expected))
},
Entry("album newer than images", now, now.Add(-1*time.Hour), now),
Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)),
Entry("equal timestamps", now, now, now),
)
})
})
Describe("discArtworkReader", func() {
Context("LastUpdated", func() {
// Regression test for #5377: same bug as albumArtworkReader — disc covers
// must also revalidate when the image file changes, not only when media files do.
now := time.Now().Truncate(time.Second)
DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt",
func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) {
album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt}
folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "al1", DiscNumber: 1, Path: "tests/fixtures/test.mp3"},
})
artID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("al1", 1), nil)
dr, err := newDiscArtworkReader(ctx, aw, artID)
Expect(err).ToNot(HaveOccurred())
Expect(dr.LastUpdated()).To(Equal(expected))
},
Entry("album newer than images", now, now.Add(-1*time.Hour), now),
Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)),
Entry("equal timestamps", now, now, now),
)
})
})
Describe("artistArtworkReader", func() {
Context("Multiple covers", func() {
BeforeEach(func() {
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
folderRepo.result = []model.Folder{{
LibraryPath: testFileLibPath(repoRoot),
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
}}
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
arMultipleCovers,
})
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alMultipleCovers,
})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
mfAnotherWithEmbed,
})
})
DescribeTable("ArtistArtPriority",
func(priority string, expected string) {
conf.Server.ArtistArtPriority = priority
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(filepath.ToSlash(path)).To(HaveSuffix(expected))
},
Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"),
Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"),
)
})
})
Describe("mediafileArtworkReader", func() {
Context("ID not found", func() {
It("returns ErrNotFound if mediafile is not in the DB", func() {
_, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND"))
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Context("Embed images", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"front.png"},
}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alOnlyEmbed,
alOnlyExternal,
alSingleDisc,
})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
mfWithEmbed,
mfWithoutEmbed,
mfCorruptedCover,
})
})
It("returns embed cover", func() {
aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID())
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal("tests/fixtures/test.mp3"))
})
It("returns embed cover if successfully extracted by ffmpeg", func() {
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
Expect(err).ToNot(HaveOccurred())
r, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
data, _ := io.ReadAll(r)
Expect(data).ToNot(BeEmpty())
Expect(path).To(Equal("tests/fixtures/test.ogg"))
})
It("returns album cover if cannot read embed artwork", func() {
// Force fromTag to fail
mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg"
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed())
// Simulate ffmpeg error
ffmpeg.Error = errors.New("not available")
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal("al-444_0"))
})
It("returns album cover if media file has no cover art", func() {
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithoutEmbed.ID))
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal("al-444_0"))
})
It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() {
mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2}
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfWithDisc)).To(Succeed())
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithDisc.ID))
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
// Should fall back to disc art, which itself falls back to album art
Expect(path).To(Equal("dc-444:2_0"))
})
It("falls back to album cover art for single-disc albums even with a disc number", func() {
mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1}
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfOnSingleDisc)).To(Succeed())
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfOnSingleDisc.ID))
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
// Single-disc album should skip disc art and go straight to album art
Expect(path).To(Equal("al-888_0"))
})
})
})
Describe("playlistArtworkReader", func() {
Describe("findPlaylistSidecarPath", func() {
It("discovers sidecar image next to playlist file", func() {
tmpDir := GinkgoT().TempDir()
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
Expect(result).To(Equal(imgPath))
})
It("returns empty string when no sidecar image exists", func() {
tmpDir := GinkgoT().TempDir()
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
Expect(result).To(BeEmpty())
})
It("returns empty string when playlist has no path", func() {
result := findPlaylistSidecarPath(GinkgoT().Context(), "")
Expect(result).To(BeEmpty())
})
It("finds sidecar with different case base name", func() {
tmpDir := GinkgoT().TempDir()
plsPath := filepath.Join(tmpDir, "myplaylist.m3u")
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
Expect(result).To(Equal(imgPath))
})
})
Describe("fromPlaylistExternalImage", func() {
It("opens local path from ExternalImageURL", func() {
tmpDir := GinkgoT().TempDir()
imgPath := filepath.Join(tmpDir, "cover.jpg")
Expect(os.WriteFile(imgPath, []byte("external image data"), 0600)).To(Succeed())
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: imgPath},
}
r, path, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
Expect(path).To(Equal(imgPath))
data, _ := io.ReadAll(r)
Expect(string(data)).To(Equal("external image data"))
r.Close()
})
It("returns nil when ExternalImageURL is empty", func() {
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: ""},
}
r, path, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).ToNot(HaveOccurred())
Expect(r).To(BeNil())
Expect(path).To(BeEmpty())
})
It("returns error when local file does not exist", func() {
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: "/non/existent/path/cover.jpg"},
}
r, _, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).To(HaveOccurred())
Expect(r).To(BeNil())
})
It("skips HTTP URL when EnableM3UExternalAlbumArt is false", func() {
conf.Server.EnableM3UExternalAlbumArt = false
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: "https://example.com/cover.jpg"},
}
r, path, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).ToNot(HaveOccurred())
Expect(r).To(BeNil())
Expect(path).To(BeEmpty())
})
It("still opens local path when EnableM3UExternalAlbumArt is false", func() {
conf.Server.EnableM3UExternalAlbumArt = false
tmpDir := GinkgoT().TempDir()
imgPath := filepath.Join(tmpDir, "cover.jpg")
Expect(os.WriteFile(imgPath, []byte("local image"), 0600)).To(Succeed())
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: imgPath},
}
r, path, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
Expect(path).To(Equal(imgPath))
r.Close()
})
})
})
Describe("resizedArtworkReader", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg", "front.png"},
}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alMultipleCovers,
})
})
When("Square is false", func() {
It("returns PNG if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("png"))
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
})
It("returns JPEG if original image is not a PNG", func() {
conf.Server.CoverArtPriority = "cover.jpg"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(format).To(Equal("jpeg"))
Expect(err).ToNot(HaveOccurred())
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
})
})
When("When square is true", func() {
var alCover model.Album
DescribeTable("resize",
func(srcFormat string, expectedFormat string, landscape bool, size int) {
coverFileName := "cover." + srcFormat
dirName := createImage(srcFormat, landscape, size)
alCover = model.Album{
ID: "444",
Name: "Only external",
FolderIDs: []string{"tmp"},
}
folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}}
rootLibRepo := &tests.MockLibraryRepo{}
rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}})
ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alCover,
})
conf.Server.CoverArtPriority = coverFileName
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal(expectedFormat))
Expect(img.Bounds().Size().X).To(Equal(size))
Expect(img.Bounds().Size().Y).To(Equal(size))
},
Entry("portrait png image", "png", "png", false, 200),
Entry("landscape png image", "png", "png", true, 200),
Entry("portrait jpg image", "jpg", "png", false, 200),
Entry("landscape jpg image", "jpg", "png", true, 200),
)
})
When("EnableWebPEncoding is true and square is false", func() {
BeforeEach(func() {
conf.Server.EnableWebPEncoding = true
})
It("returns WebP even if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("webp"))
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
})
It("returns WebP if original image is not a PNG", func() {
conf.Server.CoverArtPriority = "cover.jpg"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(format).To(Equal("webp"))
Expect(err).ToNot(HaveOccurred())
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
})
})
When("EnableWebPEncoding is false and square is false", func() {
BeforeEach(func() {
conf.Server.EnableWebPEncoding = false
})
It("returns PNG if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("png"))
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
})
It("returns JPEG if original image is a JPG", func() {
conf.Server.CoverArtPriority = "cover.jpg"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("jpeg"))
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
})
})
When("EnableWebPEncoding is false and square is true", func() {
var alCover model.Album
BeforeEach(func() {
conf.Server.EnableWebPEncoding = false
})
It("returns PNG for square mode", func() {
dirName := createImage("png", false, 200)
alCover = model.Album{
ID: "444",
Name: "Only external",
FolderIDs: []string{"tmp"},
}
folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}}
rootLibRepo := &tests.MockLibraryRepo{}
rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}})
ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover})
conf.Server.CoverArtPriority = "cover.png"
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), 200, true)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("png"))
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
})
})
When("Requested size is larger than original", func() {
It("clamps size to original dimensions", func() {
conf.Server.CoverArtPriority = "front.png"
// front.png is 16x16, requesting 99999 should return at original size
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, false)
Expect(err).ToNot(HaveOccurred())
img, _, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
// Should be clamped to original size (16), not 99999
Expect(img.Bounds().Size().X).To(Equal(16))
Expect(img.Bounds().Size().Y).To(Equal(16))
})
It("clamps square size to original dimensions", func() {
conf.Server.CoverArtPriority = "front.png"
// front.png is 16x16, requesting 99999 with square should return 16x16 square
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, true)
Expect(err).ToNot(HaveOccurred())
img, _, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
// Should be clamped to original size (16), not 99999
Expect(img.Bounds().Size().X).To(Equal(16))
Expect(img.Bounds().Size().Y).To(Equal(16))
})
})
})
})
func createImage(format string, landscape bool, size int) string {
var img image.Image
if landscape {
img = image.NewRGBA(image.Rect(0, 0, size, size/2))
} else {
img = image.NewRGBA(image.Rect(0, 0, size/2, size))
}
tmpDir := GinkgoT().TempDir()
f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
defer f.Close()
switch format {
case "png":
_ = png.Encode(f, img)
case "jpg":
_ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
}
return tmpDir
}
+1 -47
View File
@@ -11,26 +11,13 @@ import (
"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/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/goleak"
)
func TestArtwork(t *testing.T) {
// Runs unconditionally: the two leaks below are pre-existing and out of this
// package's control, so they're ignored by exact top-function instead.
defer goleak.VerifyNone(t,
goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"),
// notify's own init() starts a singleton tree the moment it's imported (via
// core/storage/local or plugins); recursive on darwin, nonrecursive on linux.
goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"),
goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"),
goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"),
)
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
@@ -38,6 +25,7 @@ func TestArtwork(t *testing.T) {
}
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
// ReadTags is not used by albumArtworkReader, so it is left as a stub.
type osDirFS struct{ fs.FS }
func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil }
@@ -81,37 +69,3 @@ func (s *osDirStorage) FS() (storage.MusicFS, error) {
}
return osDirFS{os.DirFS(s.root)}, nil
}
// fakeFolderRepo covers the three FolderRepository methods the resolvers reach for. The zero value
// answers as an unremarkable library does; the fields drive the album-root lookup and its failures.
type fakeFolderRepo struct {
model.FolderRepository
result []model.Folder
err error
parentResult *model.Folder
getErr error
getCallCount int
// hasOtherAudio is returned by HasAudioOutsideFolders (the album-root check).
// False means the parent qualifies as an album root.
hasOtherAudio bool
otherAudioErr error
}
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
return f.result, f.err
}
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
return f.hasOtherAudio, f.otherAudioErr
}
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) {
f.getCallCount++
if f.getErr != nil {
return nil, f.getErr
}
if f.parentResult != nil {
return f.parentResult, nil
}
return nil, model.ErrNotFound
}
+30 -483
View File
@@ -1,510 +1,57 @@
package artwork
package artwork_test
import (
"bytes"
"context"
"image"
"io"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/cache"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Artwork", func() {
var (
ctx context.Context
ds *tests.MockDataStore
artRepo *tests.MockArtworkRepo
queueRepo *tests.MockArtworkQueueRepo
albumRepo *tests.MockAlbumRepo
mfRepo *tests.MockMediaFileRepo
folderRepo *fakeFolderRepo
libRepo *tests.MockLibraryRepo
ffm *tests.MockFFmpeg
store *ImageStore
imgCache cache.FileCache
svc Artwork
repoRoot string
coverBytes []byte
seedEntity func(kind, id string)
)
primaryKey := func(kind, id string) string { return kind + "|" + id + "|" + model.ImageTypePrimary }
seedFoundStore := func(kind, id string, imgBytes []byte) string {
hash, err := hashImage(bytes.NewReader(imgBytes))
Expect(err).ToNot(HaveOccurred())
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).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
}
// Without its owning entity, a state row is not served at all.
seedEntity = func(kind, id string) {
GinkgoHelper()
switch kind {
case "al":
Expect(albumRepo.Put(&model.Album{ID: id, Name: "Album"})).To(Succeed())
case "mf":
Expect(mfRepo.Put(&model.MediaFile{ID: id})).To(Succeed())
}
}
readAll := func(img *Image) []byte {
GinkgoHelper()
defer img.Close()
data, err := io.ReadAll(img)
Expect(err).ToNot(HaveOccurred())
return data
}
var aw artwork.Artwork
var ds model.DataStore
var ffmpeg *tests.MockFFmpeg
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = context.Background()
var err error
repoRoot, err = os.Getwd()
Expect(err).ToNot(HaveOccurred())
coverBytes, err = os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
Expect(err).ToNot(HaveOccurred())
conf.Server.ImageCacheSize = "0" // Disable cache
cache := artwork.GetImageCache()
ffmpeg = tests.NewMockFFmpeg("content from ffmpeg")
aw = artwork.NewArtwork(ds, cache, ffmpeg, nil)
})
conf.Server.EnableWebPEncoding = false
conf.Server.CoverArtQuality = 75
conf.Server.CoverArtPriority = "cover.*"
conf.Server.DiscArtPriority = "cover.*"
conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir())
Context("GetOrPlaceholder", func() {
Context("Empty ID", func() {
It("returns placeholder if album is not in the DB", func() {
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0, false)
Expect(err).ToNot(HaveOccurred())
artRepo = tests.CreateMockArtworkRepo()
queueRepo = tests.CreateMockArtworkQueueRepo()
albumRepo = tests.CreateMockAlbumRepo()
mfRepo = tests.CreateMockMediaFileRepo()
folderRepo = &fakeFolderRepo{}
libRepo = &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
ds = &tests.MockDataStore{
MockedArtwork: artRepo,
MockedArtworkQueue: queueRepo,
MockedAlbum: albumRepo,
MockedMediaFile: mfRepo,
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
ffm = tests.NewMockFFmpeg("")
store = NewImageStore(GinkgoT().TempDir())
imgCache = cache.NewFileCache("ServingTest", "100MB", "images", 0,
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
return arg.(artworkReader).Reader(ctx)
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
Expect(err).ToNot(HaveOccurred())
phBytes, err := io.ReadAll(ph)
Expect(err).ToNot(HaveOccurred())
result, err := io.ReadAll(r)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(phBytes))
})
Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue())
svc = NewArtwork(ds, imgCache, store, ffm)
})
Describe("found state", func() {
It("serves a store-backed found image sized (cache miss resizes, second call is a cache hit)", func() {
seedFoundStore("al", "al1", coverBytes)
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false)
Expect(err).ToNot(HaveOccurred())
// A resized response versions its ETag with the encode settings, not the pixel hash.
Expect(img.ETag).To(Equal(representationTag(img.Hash, 100, false)))
Expect(img.ETag).ToNot(Equal(img.Hash))
resized := readAll(img)
cfg, _, err := image.DecodeConfig(bytes.NewReader(resized))
Expect(err).ToNot(HaveOccurred())
Expect(cfg.Width).To(Equal(100))
// Deleting the store file proves the warm entry serves without touching the original.
hash, _ := hashImage(bytes.NewReader(coverBytes))
Expect(os.Remove(store.path(hash, "image/jpeg"))).To(Succeed())
Eventually(func(g Gomega) {
img2, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(readAll(img2)).To(Equal(resized))
}).Should(Succeed())
})
It("treats a negative size as a full-size request, not a giant resize", func() {
seedFoundStore("al", "alneg", coverBytes)
img, err := svc.Get(ctx, model.MustParseArtworkID("al-alneg"), -2000000000, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes), "original bytes, no resize (would OOM)")
})
It("streams a file-backed found image at full size", func() {
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
mtime := fileMtime(imgPath)
Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "al2")
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
})).To(Succeed())
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
})
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(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "al3")
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
})).To(Succeed())
_, 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(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
})
It("enforces the mtime rule on the sized (loader) path too", func() {
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "al3b")
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
})).To(Succeed())
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3b"), 100, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al3b")].Priority).To(Equal(model.ArtworkPriorityScan))
})
// State rows outlive a deleted entity until the next prune.
It("refuses to serve a found row whose entity is gone", func() {
hash := seedFoundStore("al", "alzz", coverBytes)
Expect(hash).ToNot(BeEmpty())
albumRepo.SetData(model.Albums{}) // the album is deleted; its artwork row survives
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alzz"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
})
It("never re-enqueues an absent state on view, however old", func() {
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
})).To(Succeed())
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data).To(BeEmpty())
})
})
Describe("provisional read-through", func() {
It("serves local folder art, enqueues a Bump, and writes no state row", func() {
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "al5", Name: "Album", FolderIDs: []string{"f1"}}})
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al5"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("returns ErrUnavailable and enqueues a Bump when nothing local resolves", func() {
folderRepo.result = nil
albumRepo.SetData(model.Albums{{ID: "al6", Name: "Album"}})
_, 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(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("media file", func() {
It("serves a track's own found art", func() {
seedFoundStore("mf", "mf1", coverBytes)
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
})
It("ignores a resolved mf row and delegates to the album when per-track art is disabled", func() {
conf.Server.EnableMediaFileCoverArt = false
seedFoundStore("mf", "mf7", []byte("stale embedded track art"))
seedFoundStore("al", "albz", coverBytes)
mfRepo.SetData(model.MediaFiles{{ID: "mf7", AlbumID: "albz"}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf7"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes), "album art, not the persisted embedded art")
})
It("delegates to the album when the track's state is absent", func() {
seedFoundStore("al", "albm", coverBytes)
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)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
_, mfEnq := queueRepo.Data[primaryKey("mf", "mf2")]
Expect(mfEnq).To(BeFalse())
})
It("delegates to the album (no enqueue) when the track is not embedded-eligible", func() {
conf.Server.EnableMediaFileCoverArt = true
seedFoundStore("al", "albn", coverBytes)
mfRepo.SetData(model.MediaFiles{{ID: "mf3", AlbumID: "albn", HasCoverArt: false}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf3"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
_, mfEnq := queueRepo.Data[primaryKey("mf", "mf3")]
Expect(mfEnq).To(BeFalse())
})
It("extracts embedded art provisionally and enqueues the track when eligible", func() {
conf.Server.EnableMediaFileCoverArt = true
mfRepo.SetData(model.MediaFiles{{
ID: "mf4", AlbumID: "albo", HasCoverArt: true,
Path: "tests/fixtures/artist/an-album/test.mp3", LibraryID: 0,
}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf4"), 0, false)
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(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("delegates a multi-disc track to its disc art, not straight to the album", func() {
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "aldd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "One", 2: "Two"}}})
seedFoundStore("al", "aldd", []byte("album-art-distinct")) // album's own found art differs
mfRepo.SetData(model.MediaFiles{{ID: "mf5", AlbumID: "aldd", DiscNumber: 1, HasCoverArt: false}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf5"), 0, false)
Expect(err).ToNot(HaveOccurred())
// The disc-folder image, not the album's own art: proof it routed through serveDisc.
Expect(readAll(img)).To(Equal(coverBytes))
})
It("falls back to the album when an eligible track's embedded art will not extract", func() {
conf.Server.EnableMediaFileCoverArt = true
// HasCoverArt is set, but the file is not audio, so nothing extracts.
mfRepo.SetData(model.MediaFiles{{
ID: "mfbad", AlbumID: "albad", LibraryID: 0, HasCoverArt: true,
Path: "tests/fixtures/artist/an-album/front.png",
}})
albumRepo.SetData(model.Albums{{ID: "albad", Name: "Album"}})
seedFoundStore("al", "albad", coverBytes)
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mfbad"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes), "a placeholder here would be worse than the album cover")
})
It("routes a single-disc track through disc resolution too", func() {
// DiscArtPriority applies to single-disc albums too, over the album's own found art.
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "alsd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}})
seedFoundStore("al", "alsd", []byte("album-art-distinct"))
mfRepo.SetData(model.MediaFiles{{ID: "mf6", AlbumID: "alsd", DiscNumber: 1, HasCoverArt: false}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf6"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
})
})
Describe("disc", func() {
It("serves a local disc-folder image", func() {
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "aldc", Name: "Album", FolderIDs: []string{"f1"}}})
img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc", 1), nil), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
})
// The resize cache keys on id + album mtime, not on the bytes, so dropping the source
// between the two requests is what shows a warm hit never touches the filesystem.
It("serves a sized disc image from cache without re-reading the source", func() {
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "aldc3", Name: "Album", FolderIDs: []string{"f1"}}})
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc3", 1), nil)
first, err := svc.Get(ctx, discID, 64, false)
Expect(err).ToNot(HaveOccurred())
warmed := readAll(first)
Expect(warmed).ToNot(BeEmpty())
folderRepo.result = nil
second, err := svc.Get(ctx, discID, 64, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(second)).To(Equal(warmed), "a warm sized request must not touch the source")
})
// A disc image can change without the album row changing, so the key folds in ImagesUpdatedAt.
It("invalidates the cached image when the folder's images change", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"},
ImagesUpdatedAt: time.Now().Add(-time.Hour),
}}
albumRepo.SetData(model.Albums{{ID: "aldc4", Name: "Album", FolderIDs: []string{"f1"}}})
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc4", 1), nil)
first, err := svc.Get(ctx, discID, 64, false)
Expect(err).ToNot(HaveOccurred())
firstKey := first.ETag
readAll(first)
folderRepo.result[0].ImagesUpdatedAt = time.Now()
second, err := svc.Get(ctx, discID, 64, false)
Expect(err).ToNot(HaveOccurred())
readAll(second)
Expect(second.ETag).ToNot(Equal(firstKey), "a replaced image must not keep the old cache entry")
})
// Disc art has no content hash, so without an explicit validator every ETag would be empty.
It("gives a full-size disc image a validator that tracks the source", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"},
ImagesUpdatedAt: time.Now().Add(-time.Hour),
}}
albumRepo.SetData(model.Albums{{ID: "aldc5", Name: "Album", FolderIDs: []string{"f1"}}})
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc5", 1), nil)
first, err := svc.Get(ctx, discID, 0, false)
Expect(err).ToNot(HaveOccurred())
readAll(first)
Expect(first.ETag).ToNot(BeEmpty())
folderRepo.result[0].ImagesUpdatedAt = time.Now()
second, err := svc.Get(ctx, discID, 0, false)
Expect(err).ToNot(HaveOccurred())
readAll(second)
Expect(second.ETag).ToNot(Equal(first.ETag), "a replaced image must not revalidate as unchanged")
})
It("falls back to album art when no disc image matches", func() {
folderRepo.result = nil
albumRepo.SetData(model.Albums{{ID: "aldc2", Name: "Album"}})
seedFoundStore("al", "aldc2", coverBytes)
img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc2", 1), nil), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
})
})
Describe("GetOrPlaceholder", func() {
It("accepts a raw entity id and serves its cover art", func() {
albumRepo.SetData(model.Albums{{ID: "rawal", Name: "Album"}})
seedFoundStore("al", "rawal", coverBytes)
img, err := svc.GetOrPlaceholder(ctx, "rawal", 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Placeholder).To(BeFalse())
Expect(readAll(img)).To(Equal(coverBytes))
})
It("falls back to the album placeholder ignoring size and square", func() {
img, err := svc.GetOrPlaceholder(ctx, "", 300, true)
Expect(err).ToNot(HaveOccurred())
Expect(img.Placeholder).To(BeTrue())
Expect(img.Hash).To(BeEmpty())
Expect(img.LastUpdated).To(BeZero())
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
Expect(err).ToNot(HaveOccurred())
phBytes, _ := io.ReadAll(ph)
Expect(readAll(img)).To(Equal(phBytes))
})
It("falls back to the artist placeholder for an absent artist", func() {
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Placeholder).To(BeTrue())
ph, err := resources.FS().Open(consts.PlaceholderArtistArt)
Expect(err).ToNot(HaveOccurred())
phBytes, _ := io.ReadAll(ph)
Expect(readAll(img)).To(Equal(phBytes))
})
// "No art" and "no such entity" are different answers: clients 404 only on the latter.
It("reports not-found rather than a placeholder for an id with no entity", func() {
_, err := svc.GetOrPlaceholder(ctx, "al-nosuchalbum", 0, false)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = svc.GetOrPlaceholder(ctx, "nosuchrawid", 0, false)
Expect(err).To(MatchError(model.ErrNotFound))
Context("Get", func() {
Context("Empty ID", func() {
It("returns an ErrUnavailable error", func() {
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false)
Expect(err).To(MatchError(artwork.ErrUnavailable))
})
})
})
})
func fileMtime(path string) int64 {
GinkgoHelper()
info, err := os.Stat(path)
Expect(err).ToNot(HaveOccurred())
return info.ModTime().UnixNano()
}
var _ = Describe("EntityExists", func() {
var ctx context.Context
var ds *tests.MockDataStore
BeforeEach(func() {
ctx = context.Background()
albumRepo := tests.CreateMockAlbumRepo()
albumRepo.SetData(model.Albums{{ID: "al1"}})
artistRepo := tests.CreateMockArtistRepo()
artistRepo.SetData(model.Artists{{ID: "ar1"}})
radioRepo := tests.CreateMockedRadioRepo()
Expect(radioRepo.Put(&model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
})
DescribeTable("reports whether the owning entity is still there",
func(id string, expected bool) {
Expect(entityExists(ctx, ds, model.MustParseArtworkID(id))).To(Equal(expected))
},
Entry("existing album", "al-al1", true),
Entry("deleted album", "al-gone", false),
Entry("existing artist", "ar-ar1", true),
Entry("deleted artist", "ar-gone", false),
Entry("existing radio", "ra-ra1", true),
Entry("deleted radio", "ra-gone", false),
// Disc art has no entity of its own; it stands or falls with its album.
Entry("disc of an existing album", "dc-al1:1", true),
Entry("disc of a deleted album", "dc-gone:1", false),
Entry("malformed disc id", "dc-nodiscnum", false),
)
})
+189
View File
@@ -0,0 +1,189 @@
package artwork
import (
"context"
"fmt"
"image/jpeg"
"io"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/cache"
)
// setupE2EBenchmark creates an artwork instance with a real album cover image on disk,
// backed by either a real file cache or disabled cache depending on cacheSize.
// Note: This benchmarks artwork.Get() directly (not the full HTTP handler), which covers
// the critical path (source selection, decode, resize, encode, cache). This is a deliberate
// spec deviation — the full HTTP round-trip benchmark requires significant infrastructure
// (DB, scanner, fake filesystem) and can be added later if HTTP overhead proves significant.
//
// Depends on fakeFolderRepo defined in reader_artist_test.go (same package, compiled together).
func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID, func()) {
b.Helper()
cleanup := configtest.SetupConfig()
b.Cleanup(cleanup)
tmpDir, err := os.MkdirTemp("", "artwork-bench-*")
if err != nil {
b.Fatal(err)
}
// Create a realistic cover image on disk
coverPath := filepath.Join(tmpDir, "cover.jpg")
coverImg := generateGradientImage(1000, 1000)
f, err := os.Create(coverPath)
if err != nil {
b.Fatal(err)
}
if err := jpeg.Encode(f, coverImg, &jpeg.Options{Quality: 90}); err != nil {
f.Close()
b.Fatal(err)
}
f.Close()
// Configure cache
conf.Server.ImageCacheSize = cacheSize
conf.Server.CacheFolder = tmpDir
conf.Server.CoverArtQuality = 75
conf.Server.CoverArtPriority = "cover.*"
// Set up mock data store with album pointing to our cover.
// Set UpdatedAt so CoverArtID().LastUpdate is consistent across calls.
album := model.Album{
ID: "bench-album-1",
Name: "Benchmark Album",
FolderIDs: []string{"f1"},
UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
}
folderRepo := &fakeFolderRepo{
result: []model.Folder{{
Path: tmpDir,
ImageFiles: []string{"cover.jpg"},
}},
}
ds := &tests.MockDataStore{
MockedTranscoding: &tests.MockTranscodingRepo{},
MockedFolder: folderRepo,
}
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{album})
artID := album.CoverArtID()
imgCache := cache.NewFileCache("BenchImage", cacheSize, "bench-images", 0,
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
r, _, err := arg.(artworkReader).Reader(ctx)
return r, err
})
// Wait for cache init if enabled
if cacheSize != "0" {
for !imgCache.Available(context.Background()) && !imgCache.Disabled(context.Background()) {
runtime.Gosched() // Yield to allow background init goroutine to run
}
}
ffmpeg := tests.NewMockFFmpeg("fallback content")
aw := NewArtwork(ds, imgCache, ffmpeg, nil)
cleanupAll := func() {
os.RemoveAll(tmpDir)
}
return aw, artID, cleanupAll
}
func BenchmarkArtworkGetE2E(b *testing.B) {
cacheConfigs := []struct {
name string
cacheSize string
}{
{"no_cache", "0"},
{"with_cache", "100MB"},
}
sizes := []int{0, 300}
for _, cc := range cacheConfigs {
for _, size := range sizes {
b.Run(fmt.Sprintf("%s/size_%d", cc.name, size), func(b *testing.B) {
aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize)
defer cleanup()
// Warm the cache on first call if cache is enabled
if cc.cacheSize != "0" {
r, _, err := aw.Get(context.Background(), artID, size, size > 0)
if err != nil {
b.Fatal(err)
}
_, _ = io.ReadAll(r)
r.Close()
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
r, _, err := aw.Get(context.Background(), artID, size, size > 0)
if err != nil {
b.Fatal(err)
}
_, _ = io.ReadAll(r)
r.Close()
}
})
}
}
}
func BenchmarkArtworkGetE2EConcurrent(b *testing.B) {
cacheConfigs := []struct {
name string
cacheSize string
}{
{"no_cache", "0"},
{"with_cache", "100MB"},
}
concurrencyLevels := []int{10, 50}
for _, cc := range cacheConfigs {
for _, n := range concurrencyLevels {
b.Run(fmt.Sprintf("%s/goroutines_%d", cc.name, n), func(b *testing.B) {
aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize)
defer cleanup()
// Warm cache
if cc.cacheSize != "0" {
r, _, _ := aw.Get(context.Background(), artID, 300, true)
if r != nil {
_, _ = io.ReadAll(r)
r.Close()
}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
var wg sync.WaitGroup
wg.Add(n)
for g := 0; g < n; g++ {
go func() {
defer wg.Done()
r, _, err := aw.Get(context.Background(), artID, 300, true)
if err != nil {
b.Error(err)
return
}
_, _ = io.ReadAll(r)
r.Close()
}()
}
wg.Wait()
}
})
}
}
}
+2 -11
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"image"
"image/color"
"image/draw"
"image/jpeg"
"image/png"
"testing"
@@ -36,8 +35,8 @@ func generatePNG(t testing.TB, width, height int) []byte {
// generateGradientImage creates an RGBA image with a diagonal gradient pattern.
func generateGradientImage(width, height int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := range height {
for x := range width {
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
r := uint8((x * 255) / width)
g := uint8((y * 255) / height)
b := uint8(((x + y) * 255) / (width + height))
@@ -46,11 +45,3 @@ func generateGradientImage(width, height int) *image.RGBA {
}
return img
}
// gradientNRGBA mirrors generateGradientImage in the type makeThumbnail hands the encoders.
func gradientNRGBA(size int) *image.NRGBA {
src := generateGradientImage(size, size)
dst := image.NewNRGBA(src.Bounds())
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
return dst
}
-208
View File
@@ -1,208 +0,0 @@
// Package blurhash implements the blurhash encoding (https://github.com/woltapp/blurhash),
// parameterized to match Jellyfin so clients see equivalent hashes.
package blurhash
import (
"errors"
"image"
"image/draw"
"math"
"strings"
"sync"
xdraw "golang.org/x/image/draw"
)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
// maxInputSize: larger inputs are slower with no visible difference in the result.
const maxInputSize = 128
// components picks x/y component counts targeting ~16 near-square tiles.
func components(width, height int) (int, int) {
xf := math.Sqrt(16.0 * float64(width) / float64(height))
yf := xf * float64(height) / float64(width)
return min(int(xf)+1, 9), min(int(yf)+1, 9)
}
// Encode returns the blurhash of img, deriving the component counts from its aspect ratio.
func Encode(img image.Image) (string, error) {
if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
return "", errors.New("blurhash: empty image")
}
// Pre-downscale: its rounding can flip a component count, and the hash is a client cache key.
xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy())
src := pixelsOf(downscale(img))
w, h := src.w, src.h
cosX := make([][]float64, xComp)
for i := range cosX {
cosX[i] = make([]float64, w)
for x := range cosX[i] {
cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w))
}
}
cosY := make([][]float64, yComp)
for j := range cosY {
cosY[j] = make([]float64, h)
for y := range cosY[j] {
cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h))
}
}
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 {
p := x * 4
r, g, b := row[p], row[p+1], row[p+2]
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]
}
}
}
for idx := range factors {
norm := 2.0
if idx == 0 {
norm = 1.0
}
scale := norm / float64(w*h)
factors[idx][0] *= scale
factors[idx][1] *= scale
factors[idx][2] *= scale
}
var sb strings.Builder
sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1))
// Derived counts are at least 1x9, so there is always at least one AC factor.
ac := factors[1:]
actualMax := 0.0
for _, f := range ac {
actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2]))
}
quantMax := int(max(0, min(82, math.Floor(actualMax*166-0.5))))
maxVal := float64(quantMax+1) / 166
sb.WriteString(encode83(quantMax, 1))
dc := factors[0]
sb.WriteString(encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4))
for _, f := range ac {
sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2))
}
return sb.String(), nil
}
// pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At.
type pixels struct {
pix []uint8
stride int
w, h int
// straight marks non-premultiplied alpha, which the loop premultiplies to keep the hash
// identical to the one an equivalent *image.RGBA produces.
straight bool
}
// pixelsOf accepts the two types the artwork pipeline produces without copying, and converts
// anything else.
func pixelsOf(img image.Image) pixels {
b := img.Bounds()
switch src := img.(type) {
case *image.RGBA:
return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy()}
case *image.NRGBA:
return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy(), straight: true}
}
dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src)
return pixels{pix: dst.Pix, stride: dst.Stride, w: b.Dx(), h: b.Dy()}
}
func premultiply(r, g, b, a uint8) (uint8, uint8, uint8) {
if a == 255 {
return r, g, b
}
return uint8(uint32(r) * uint32(a) / 255), uint8(uint32(g) * uint32(a) / 255), uint8(uint32(b) * uint32(a) / 255)
}
var srgbToLinearTable = sync.OnceValue(func() *[256]float64 {
var t [256]float64
for i := range t {
t[i] = srgbToLinear(i)
}
return &t
})
func downscale(img image.Image) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxInputSize && h <= maxInputSize {
return img
}
scale := float64(maxInputSize) / float64(max(w, h))
dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))))
xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
return dst
}
func quantAC(v, maxVal float64) int {
return int(max(0, min(18, math.Floor(signPow(v/maxVal, 0.5)*9+9.5))))
}
func signPow(v, exp float64) float64 {
return math.Copysign(math.Pow(math.Abs(v), exp), v)
}
func srgbToLinear(v int) float64 {
f := float64(v) / 255
if f <= 0.04045 {
return f / 12.92
}
return math.Pow((f+0.055)/1.055, 2.4)
}
func linearToSRGB(v float64) int {
v = min(max(0, v), 1)
if v <= 0.0031308 {
return int(v*12.92*255 + 0.5)
}
return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5)
}
// encode83 encodes value as a fixed-width, big-endian base83 string of the given length.
func encode83(value, length int) string {
b := make([]byte, length)
for i := length - 1; i >= 0; i-- {
b[i] = alphabet[value%83]
value /= 83
}
return string(b)
}
@@ -1,17 +0,0 @@
package blurhash_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestBlurHash(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "BlurHash Suite")
}
-137
View File
@@ -1,137 +0,0 @@
package blurhash_test
import (
"image"
"image/color"
"strings"
"github.com/navidrome/navidrome/core/artwork/blurhash"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
func decode83(s string) int {
v := 0
for _, c := range s {
v = v*83 + strings.IndexRune(alphabet, c)
}
return v
}
func solidImage(w, h int, c color.NRGBA) image.Image {
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.SetNRGBA(x, y, c)
}
}
return img
}
func gradientImage(w, h int) image.Image {
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255})
}
}
return img
}
var _ = Describe("Encode input types", func() {
// The pipeline hands Encode an *image.NRGBA; reading it must stay equivalent to the
// premultiplied *image.RGBA it used to receive, or every hash silently shifts.
buildPair := func(alpha uint8) (*image.NRGBA, *image.RGBA) {
const size = 40
nrgba := image.NewNRGBA(image.Rect(0, 0, size, size))
rgba := image.NewRGBA(image.Rect(0, 0, size, size))
for y := range size {
for x := range size {
c := color.NRGBA{
R: uint8(255 * x / size), G: uint8(255 * y / size),
B: uint8((x + y) * 255 / (2 * size)), A: alpha,
}
nrgba.SetNRGBA(x, y, c)
rgba.Set(x, y, c) // image.RGBA.Set premultiplies
}
}
return nrgba, rgba
}
DescribeTable("gives an NRGBA the same hash as the premultiplied RGBA it replaces",
func(alpha uint8) {
nrgba, rgba := buildPair(alpha)
fromNRGBA, err := blurhash.Encode(nrgba)
Expect(err).ToNot(HaveOccurred())
fromRGBA, err := blurhash.Encode(rgba)
Expect(err).ToNot(HaveOccurred())
Expect(fromNRGBA).To(Equal(fromRGBA))
},
Entry("opaque", uint8(255)),
Entry("partly transparent", uint8(128)),
Entry("fully transparent, which premultiplication crushes to black", uint8(0)),
)
})
var _ = Describe("Encode", func() {
// The size flag encodes (xComp-1) + (yComp-1)*9.
DescribeTable("derives component counts from aspect ratio (Jellyfin formula)",
func(w, h, expectedX, expectedY int) {
hash, err := blurhash.Encode(gradientImage(w, h))
Expect(err).ToNot(HaveOccurred())
Expect(decode83(hash[:1])).To(Equal((expectedX - 1) + (expectedY-1)*9))
},
Entry("square album art", 60, 60, 5, 5),
Entry("smallest square", 1, 1, 5, 5),
Entry("landscape 16:9", 192, 108, 6, 4),
Entry("portrait 9:16", 108, 192, 4, 6),
Entry("extreme landscape capped at 9", 1000, 10, 9, 1),
Entry("extreme portrait capped at 9", 10, 1000, 1, 9),
)
It("rejects an empty image", func() {
_, err := blurhash.Encode(image.NewNRGBA(image.Rect(0, 0, 0, 0)))
Expect(err).To(HaveOccurred())
})
It("produces the spec-mandated length", func() {
// 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component; a square derives 5x5
h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255}))
Expect(err).ToNot(HaveOccurred())
Expect(h).To(HaveLen(4 + 2 + 2*(5*5-1)))
})
It("stores the average color in the DC component", func() {
h, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 200, G: 100, B: 50, A: 255}))
Expect(err).ToNot(HaveOccurred())
dc := decode83(h[2:6])
Expect(dc >> 16).To(BeNumerically("~", 200, 1))
Expect((dc >> 8) & 0xFF).To(BeNumerically("~", 100, 1))
Expect(dc & 0xFF).To(BeNumerically("~", 50, 1))
})
It("is deterministic", func() {
img := gradientImage(64, 64)
h1, err1 := blurhash.Encode(img)
h2, err2 := blurhash.Encode(img)
Expect(err1).ToNot(HaveOccurred())
Expect(err2).ToNot(HaveOccurred())
Expect(h1).To(Equal(h2))
})
It("produces different hashes for different images", func() {
h1, _ := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 255, A: 255}))
h2, _ := blurhash.Encode(gradientImage(16, 16))
Expect(h1).ToNot(Equal(h2))
})
It("downscales large images internally without changing the result materially", func() {
big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255}))
Expect(err).ToNot(HaveOccurred())
small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255}))
Expect(err).ToNot(HaveOccurred())
Expect(big[2:6]).To(Equal(small[2:6]))
})
})
+162
View File
@@ -0,0 +1,162 @@
package artwork
import (
"context"
"fmt"
"io"
"maps"
"slices"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/pl"
)
type CacheWarmer interface {
PreCache(artID model.ArtworkID)
}
// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background
// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original
// image size, as well as the size defined by the UICoverArtSize config option.
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
// If image cache is disabled, return a NOOP implementation
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
return &noopCacheWarmer{}
}
// If the file cache is disabled, return a NOOP implementation
if cache.Disabled(context.Background()) {
log.Debug("Image cache disabled. Cache warmer will not run")
return &noopCacheWarmer{}
}
a := &cacheWarmer{
artwork: artwork,
cache: cache,
buffer: make(map[model.ArtworkID]struct{}),
wakeSignal: make(chan struct{}, 1),
coverArtSize: conf.Server.UICoverArtSize,
}
// Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts
ctx := request.WithUser(context.TODO(), model.User{IsAdmin: true})
go a.run(ctx)
return a
}
type cacheWarmer struct {
artwork Artwork
buffer map[model.ArtworkID]struct{}
mutex sync.Mutex
cache cache.FileCache
wakeSignal chan struct{}
coverArtSize int
}
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
if a.cache.Disabled(context.Background()) {
return
}
a.mutex.Lock()
defer a.mutex.Unlock()
a.buffer[artID] = struct{}{}
a.sendWakeSignal()
}
func (a *cacheWarmer) sendWakeSignal() {
// Don't block if the previous signal was not read yet
select {
case a.wakeSignal <- struct{}{}:
default:
}
}
func (a *cacheWarmer) run(ctx context.Context) {
for {
a.waitSignal(ctx, 10*time.Second)
if ctx.Err() != nil {
break
}
if a.cache.Disabled(ctx) {
a.mutex.Lock()
pending := len(a.buffer)
a.buffer = make(map[model.ArtworkID]struct{})
a.mutex.Unlock()
if pending > 0 {
log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending)
}
return
}
// If cache not available, keep waiting
if !a.cache.Available(ctx) {
a.mutex.Lock()
bufferLen := len(a.buffer)
a.mutex.Unlock()
if bufferLen > 0 {
log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", bufferLen)
}
continue
}
a.mutex.Lock()
// If there's nothing to send, keep waiting
if len(a.buffer) == 0 {
a.mutex.Unlock()
continue
}
batch := slices.Collect(maps.Keys(a.buffer))
a.buffer = make(map[model.ArtworkID]struct{})
a.mutex.Unlock()
a.processBatch(ctx, batch)
}
}
func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) {
select {
case <-time.After(timeout):
case <-a.wakeSignal:
case <-ctx.Done():
}
}
func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) {
log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch))
input := pl.FromSlice(ctx, batch)
errs := pl.Sink(ctx, 4, input, a.doCacheImage)
for err := range errs {
log.Debug(ctx, "Error warming cache", err)
}
}
func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
size := a.coverArtSize
r, _, err := a.artwork.Get(ctx, id, size, true)
if err != nil {
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
}
_, err = io.Copy(io.Discard, r)
r.Close()
return err
}
func NoopCacheWarmer() CacheWarmer {
return &noopCacheWarmer{}
}
type noopCacheWarmer struct{}
func (a *noopCacheWarmer) PreCache(model.ArtworkID) {}
+245
View File
@@ -0,0 +1,245 @@
package artwork
import (
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("CacheWarmer", func() {
var (
fc *mockFileCache
aw *mockArtwork
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
fc = &mockFileCache{}
aw = &mockArtwork{}
})
Context("initialization", func() {
It("returns noop when cache is disabled", func() {
fc.SetDisabled(true)
cw := NewCacheWarmer(aw, fc)
_, ok := cw.(*noopCacheWarmer)
Expect(ok).To(BeTrue())
})
It("returns noop when ImageCacheSize is 0", func() {
conf.Server.ImageCacheSize = "0"
cw := NewCacheWarmer(aw, fc)
_, ok := cw.(*noopCacheWarmer)
Expect(ok).To(BeTrue())
})
It("returns noop when EnableArtworkPrecache is false", func() {
conf.Server.EnableArtworkPrecache = false
cw := NewCacheWarmer(aw, fc)
_, ok := cw.(*noopCacheWarmer)
Expect(ok).To(BeTrue())
})
It("returns real implementation when properly configured", func() {
conf.Server.ImageCacheSize = "100MB"
conf.Server.EnableArtworkPrecache = true
fc.SetDisabled(false)
cw := NewCacheWarmer(aw, fc)
_, ok := cw.(*cacheWarmer)
Expect(ok).To(BeTrue())
})
})
Context("buffer management", func() {
BeforeEach(func() {
conf.Server.ImageCacheSize = "100MB"
conf.Server.EnableArtworkPrecache = true
fc.SetDisabled(false)
})
It("drops buffered items when cache becomes disabled", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-test"))
fc.SetDisabled(true)
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
It("adds multiple items to buffer", func() {
fc.SetReady(false) // Make cache unavailable so items stay in buffer
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-1"))
cw.PreCache(model.MustParseArtworkID("al-2"))
cw.mutex.Lock()
defer cw.mutex.Unlock()
Expect(len(cw.buffer)).To(Equal(2))
})
It("deduplicates items in buffer", func() {
fc.SetReady(false) // Make cache unavailable so items stay in buffer
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-1"))
cw.PreCache(model.MustParseArtworkID("al-1"))
cw.mutex.Lock()
defer cw.mutex.Unlock()
Expect(len(cw.buffer)).To(Equal(1))
})
})
Context("error handling", func() {
BeforeEach(func() {
conf.Server.ImageCacheSize = "100MB"
conf.Server.EnableArtworkPrecache = true
fc.SetDisabled(false)
})
It("continues processing after artwork retrieval error", func() {
aw.err = errors.New("artwork error")
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-error"))
cw.PreCache(model.MustParseArtworkID("al-1"))
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
It("continues processing after cache error", func() {
fc.err = errors.New("cache error")
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-error"))
cw.PreCache(model.MustParseArtworkID("al-1"))
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
})
Context("background processing", func() {
BeforeEach(func() {
conf.Server.ImageCacheSize = "100MB"
conf.Server.EnableArtworkPrecache = true
fc.SetDisabled(false)
})
It("processes items in batches", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
for i := range 5 {
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
}
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
It("wakes up on new items", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
// Add first batch
cw.PreCache(model.MustParseArtworkID("al-1"))
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
// Add second batch
cw.PreCache(model.MustParseArtworkID("al-2"))
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
It("pre-caches UICoverArtSize", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-1"))
Eventually(func() []int {
return aw.getCachedSizes()
}).Should(ContainElements(conf.Server.UICoverArtSize))
})
})
})
type mockArtwork struct {
err error
mu sync.Mutex
cachedSizes []int
}
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
if m.err != nil {
return nil, time.Time{}, m.err
}
m.mu.Lock()
m.cachedSizes = append(m.cachedSizes, size)
m.mu.Unlock()
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
}
func (m *mockArtwork) getCachedSizes() []int {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]int, len(m.cachedSizes))
copy(result, m.cachedSizes)
return result
}
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
return m.Get(ctx, model.ArtworkID{}, size, square)
}
type mockFileCache struct {
disabled atomic.Bool
ready atomic.Bool
err error
}
func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) {
if f.err != nil {
return nil, f.err
}
return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil
}
func (f *mockFileCache) Available(ctx context.Context) bool {
return f.ready.Load() && !f.disabled.Load()
}
func (f *mockFileCache) Disabled(ctx context.Context) bool {
return f.disabled.Load()
}
func (f *mockFileCache) SetDisabled(v bool) {
f.disabled.Store(v)
f.ready.Store(true)
}
func (f *mockFileCache) SetReady(v bool) {
f.ready.Store(v)
}
-281
View File
@@ -1,281 +0,0 @@
package artwork
import (
"context"
"fmt"
"io"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
)
// discArtworkReader resolves disc-level artwork from a library's folder images
// and embedded tags. It is used by the serving path's provisional disc read-through.
type discArtworkReader struct {
album model.Album
discNumber int
imgFiles []string // library-relative, forward-slash, no leading slash
discFoldersRel map[string]bool // library-relative folder paths
isMultiFolder bool
firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs
lib libraryView
// Newest ImagesUpdatedAt across the album's and this disc's folders: an image can be
// replaced without the album row changing, so this is what makes a cache key notice it.
imagesUpdatedAt time.Time
}
// cacheTime is the disc image's validity stamp: any of these moving means the selection may
// have changed.
func (d *discArtworkReader) cacheTime() time.Time {
return utils.TimeNewest(d.album.UpdatedAt, d.album.ImportedAt, d.imagesUpdatedAt)
}
func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.ArtworkID) (*discArtworkReader, error) {
albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID)
if err != nil {
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
}
al, err := ds.Album(ctx).Get(albumID)
if err != nil {
return nil, err
}
_, imgFiles, albumImagesAt, err := loadAlbumFoldersPaths(ctx, ds, *al)
if err != nil {
return nil, err
}
var imagesUpdatedAt time.Time
if albumImagesAt != nil {
imagesUpdatedAt = *albumImagesAt
}
// Query mediafiles for this album + disc to find folder associations and first track
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Sort: "track_number",
Order: "ASC",
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
})
if err != nil {
return nil, err
}
lib, err := loadLibraryView(ctx, ds, al.LibraryID)
if err != nil {
return nil, err
}
// Build disc folder set and find first track. mf.Path is already library-relative.
var firstTrackRel string
for _, mf := range mfs {
if mf.Path != "" {
firstTrackRel = filepath.ToSlash(mf.Path)
break
}
}
folderIDs := slice.Unique(slice.Map(mfs, func(mf model.MediaFile) string { return mf.FolderID }))
// Resolve folder IDs to library-relative paths
discFoldersRel := make(map[string]bool)
if len(folderIDs) > 0 {
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"folder.id": folderIDs},
})
if err != nil {
return nil, err
}
for _, f := range folders {
rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/")
discFoldersRel[rel] = true
imagesUpdatedAt = utils.TimeNewest(imagesUpdatedAt, f.ImagesUpdatedAt)
}
}
return &discArtworkReader{
album: *al,
discNumber: discNumber,
imgFiles: imgFiles,
discFoldersRel: discFoldersRel,
isMultiFolder: len(al.FolderIDs) > 1,
firstTrackRel: firstTrackRel,
lib: lib,
imagesUpdatedAt: imagesUpdatedAt,
}, 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
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"
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))
}
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
}
}
return chain.exhausted(), nil
}
// fromDiscSubtitle returns a sourceFunc that matches image files whose stem
// (filename without extension) equals the disc subtitle (case-insensitive).
func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc {
return func() (io.ReadCloser, string, error) {
for _, file := range d.imgFiles {
stem := utils.BaseName(file)
if !strings.EqualFold(stem, subtitle) {
continue
}
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err)
continue
}
return f, file, nil
}
return nil, "", fmt.Errorf("disc %d: no image file matching subtitle %q", d.discNumber, subtitle)
}
}
// filepath.Match's '\' escape is excluded on purpose: treating it as a metachar
// would misalign the literal-prefix extraction in extractDiscNumber.
const globMetaChars = "*?["
// extractDiscNumber parses the disc number from a filename matched by a filepath.Match-style
// glob. Caller must lowercase both args and have already verified the match.
func extractDiscNumber(pattern, filename string) (int, bool) {
metaIdx := strings.IndexAny(pattern, globMetaChars)
if metaIdx < 0 {
return 0, false
}
prefix := pattern[:metaIdx]
if !strings.HasPrefix(filename, prefix) {
return 0, false
}
start := len(prefix)
end := start
for end < len(filename) && filename[end] >= '0' && filename[end] <= '9' {
end++
}
if end == start {
return 0, false
}
num, err := strconv.Atoi(filename[start:end])
if err != nil {
return 0, false
}
return num, true
}
// fromExternalFile matches image files against a (lowercase) glob pattern. A numbered
// filename whose number equals the target disc wins over any unnumbered candidate.
func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc {
isLiteral := !strings.ContainsAny(pattern, globMetaChars)
return func() (io.ReadCloser, string, error) {
var fallbacks []string
for _, file := range d.imgFiles {
name := strings.ToLower(path.Base(file))
match, err := filepath.Match(pattern, name)
if err != nil {
log.Warn(ctx, "Artwork: Error matching disc art file to pattern", "pattern", pattern, "file", file)
continue
}
if !match {
continue
}
if !isLiteral {
if num, hasNum := extractDiscNumber(pattern, name); hasNum {
if num != d.discNumber {
continue
}
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err)
continue
}
return f, file, nil
}
}
if d.isMultiFolder && !d.discFoldersRel[path.Dir(file)] {
continue
}
fallbacks = append(fallbacks, file)
}
for _, file := range fallbacks {
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err)
continue
}
return f, file, nil
}
return nil, "", fmt.Errorf("disc %d: pattern '%s' not matched by files", d.discNumber, pattern)
}
}
-127
View File
@@ -1,127 +0,0 @@
// Package dominant extracts an image's dominant colour, for use as a flat placeholder while the
// real artwork loads.
package dominant
import (
"fmt"
"image"
"math"
"sort"
)
const (
// 4 bits per channel: coarse enough that near-identical pixels land together, fine enough that
// distinct colours stay apart.
bits = 4
nBins = 1 << (3 * bits)
// Only the heaviest bins can win, and merging is O(n^2) over whatever survives.
maxBins = 64
// Oklab distance below which two bins are the same colour to the eye. Merging matters because a
// gradient splits across adjacent bins and would otherwise lose to a smaller flat region.
mergeDist = 0.10
)
type bin struct {
r, g, b float64
n float64
}
// Color returns the dominant colour as "#rrggbb", or "" when the image has no pixels. It reports
// presence, not salience: a mostly white sleeve returns white.
func Color(img image.Image) string {
var bins [nBins]bin
total := 0
eachPixel(img, func(r, g, b uint8) {
i := int(r>>(8-bits))<<(2*bits) | int(g>>(8-bits))<<bits | int(b>>(8-bits))
bins[i].r += float64(r)
bins[i].g += float64(g)
bins[i].b += float64(b)
bins[i].n++
total++
})
if total == 0 {
return ""
}
used := make([]bin, 0, 32)
for i := range bins {
if bins[i].n > 0 {
used = append(used, bins[i])
}
}
sort.Slice(used, func(i, j int) bool { return used[i].n > used[j].n })
if len(used) > maxBins {
used = used[:maxBins]
}
merged := make([]bin, 0, len(used))
for _, b := range used {
if i := nearest(merged, b); i >= 0 {
merged[i].r += b.r
merged[i].g += b.g
merged[i].b += b.b
merged[i].n += b.n
continue
}
merged = append(merged, b)
}
best := merged[0]
for _, m := range merged[1:] {
if m.n > best.n {
best = m
}
}
return fmt.Sprintf("#%02x%02x%02x",
uint8(best.r/best.n+0.5), uint8(best.g/best.n+0.5), uint8(best.b/best.n+0.5))
}
func nearest(merged []bin, b bin) int {
bl, ba, bb := oklab(b.r/b.n, b.g/b.n, b.b/b.n)
for i, m := range merged {
ml, ma, mb := oklab(m.r/m.n, m.g/m.n, m.b/m.n)
if math.Sqrt((bl-ml)*(bl-ml)+(ba-ma)*(ba-ma)+(bb-mb)*(bb-mb)) < mergeDist {
return i
}
}
return -1
}
// eachPixel walks the image, taking the NRGBA fast path the artwork pipeline always hits: both hash
// encoders already read the shared thumbnail in that form.
func eachPixel(img image.Image, fn func(r, g, b uint8)) {
if p, ok := img.(*image.NRGBA); ok {
for y := range p.Rect.Dy() {
row := p.Pix[y*p.Stride : y*p.Stride+p.Rect.Dx()*4]
for x := 0; x < len(row); x += 4 {
fn(row[x], row[x+1], row[x+2])
}
}
return
}
b := img.Bounds()
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
r, g, bl, _ := img.At(x, y).RGBA()
fn(uint8(r>>8), uint8(g>>8), uint8(bl>>8))
}
}
}
func srgbToLinear(v float64) float64 {
v /= 255
if v <= 0.04045 {
return v / 12.92
}
return math.Pow((v+0.055)/1.055, 2.4)
}
func oklab(r, g, b float64) (float64, float64, float64) {
lr, lg, lb := srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)
l := math.Cbrt(0.4122214708*lr + 0.5363325363*lg + 0.0514459929*lb)
m := math.Cbrt(0.2119034982*lr + 0.6806995451*lg + 0.1073969566*lb)
s := math.Cbrt(0.0883024619*lr + 0.2817188376*lg + 0.6299787005*lb)
return 0.2104542553*l + 0.7936177850*m - 0.0040720468*s,
1.9779984951*l - 2.4285922050*m + 0.4505937099*s,
0.0259040371*l + 0.7827717662*m - 0.8086757660*s
}
@@ -1,17 +0,0 @@
package dominant_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestDominant(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Dominant Suite")
}
-91
View File
@@ -1,91 +0,0 @@
package dominant_test
import (
"image"
"image/color"
"github.com/navidrome/navidrome/core/artwork/dominant"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// fill paints rect with c onto img.
func fill(img *image.NRGBA, r image.Rectangle, c color.NRGBA) {
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
img.SetNRGBA(x, y, c)
}
}
}
func newImg(w, h int, c color.NRGBA) *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, w, h))
fill(img, img.Bounds(), c)
return img
}
var _ = Describe("Color", func() {
It("returns a solid image's own colour", func() {
Expect(dominant.Color(newImg(20, 20, color.NRGBA{0x33, 0x66, 0x99, 255}))).To(Equal("#336699"))
})
It("returns empty for an image with no pixels", func() {
Expect(dominant.Color(image.NewNRGBA(image.Rect(0, 0, 0, 0)))).To(Equal(""))
})
// Presence, not salience: this is a placeholder, so the large field wins even though the small
// patch is the more interesting colour.
It("picks the largest area, not the most vivid one", func() {
img := newImg(20, 20, color.NRGBA{0xfa, 0xfa, 0xfa, 255})
fill(img, image.Rect(0, 0, 4, 4), color.NRGBA{0xff, 0x00, 0x00, 255})
Expect(dominant.Color(img)).To(Equal("#fafafa"))
})
It("reports a near-black cover as near-black", func() {
img := newImg(20, 20, color.NRGBA{0x05, 0x05, 0x05, 255})
fill(img, image.Rect(0, 0, 5, 5), color.NRGBA{0x00, 0xff, 0x00, 255})
Expect(dominant.Color(img)).To(Equal("#050505"))
})
// A gradient splits across many quantisation bins. Without merging, each slice is smaller than
// the flat block and the block would win despite covering far less of the image.
It("merges a gradient's bins so it beats a smaller flat block", func() {
img := image.NewNRGBA(image.Rect(0, 0, 40, 40))
for y := range 40 {
for x := range 40 {
// 30 columns of blue gradient == 75% of the image
if x < 30 {
img.SetNRGBA(x, y, color.NRGBA{0x10, 0x20, uint8(0xa0 + x), 255})
} else {
img.SetNRGBA(x, y, color.NRGBA{0xff, 0xcc, 0x00, 255})
}
}
}
got := dominant.Color(img)
Expect(got).To(HavePrefix("#1020"), "expected the blue gradient, got "+got)
})
It("is deterministic", func() {
img := image.NewNRGBA(image.Rect(0, 0, 30, 30))
for y := range 30 {
for x := range 30 {
img.SetNRGBA(x, y, color.NRGBA{uint8(x * 7), uint8(y * 5), uint8(x + y), 255})
}
}
first := dominant.Color(img)
for range 5 {
Expect(dominant.Color(img)).To(Equal(first))
}
})
It("handles images that are not NRGBA", func() {
src := newImg(10, 10, color.NRGBA{0x20, 0x40, 0x60, 255})
rgba := image.NewRGBA(src.Bounds())
for y := range 10 {
for x := range 10 {
rgba.Set(x, y, src.At(x, y))
}
}
Expect(dominant.Color(rgba)).To(Equal("#204060"))
})
})
-349
View File
@@ -1,349 +0,0 @@
package e2e
import (
"context"
"encoding/base64"
"errors"
"io"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/cache"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Covers the enqueue → drain → serve chain; per-source resolution rules live in the unit suites.
var _ = Describe("Acquisition → serve loop", func() {
var (
ctx context.Context
ds *tests.MockDataStore
artRepo *tests.MockArtworkRepo
queueRepo *tests.MockArtworkQueueRepo
albumRepo *tests.MockAlbumRepo
artistRepo *tests.MockArtistRepo
mfRepo *tests.MockMediaFileRepo
plRepo *tests.MockPlaylistRepo
radioRepo *tests.MockedRadioRepo
folderRepo *fakeFolderRepo
libRepo *tests.MockLibraryRepo
store *artwork.ImageStore
svc artwork.Artwork
worker *artwork.Worker
coverBytes []byte
)
itemFound := func(kind model.Kind, id string) func() bool {
return func() bool {
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(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(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityBump,
})).To(Succeed())
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = context.Background()
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
coverBytes = readFixture(coverFixture)
conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir())
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
conf.Server.CoverArtPriority = "cover.jpg"
conf.Server.ArtistArtPriority = "artist.png" // keeps artist resolution offline
conf.Server.EnableMediaFileCoverArt = true
conf.Server.DevArtworkWorkerConcurrency = 1
folderRepo = &fakeFolderRepo{}
libRepo = &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: repoRoot}})
artRepo = tests.CreateMockArtworkRepo()
queueRepo = tests.CreateMockArtworkQueueRepo()
albumRepo = tests.CreateMockAlbumRepo()
artistRepo = tests.CreateMockArtistRepo()
mfRepo = tests.CreateMockMediaFileRepo()
plRepo = tests.CreateMockPlaylistRepo()
radioRepo = tests.CreateMockedRadioRepo()
radioRepo.Data = map[string]*model.Radio{}
ds = &tests.MockDataStore{
MockedArtwork: artRepo,
MockedArtworkQueue: queueRepo,
MockedAlbum: albumRepo,
MockedArtist: artistRepo,
MockedMediaFile: mfRepo,
MockedPlaylist: plRepo,
MockedRadio: radioRepo,
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
ffm := tests.NewMockFFmpeg("")
store = artwork.NewImageStore(GinkgoT().TempDir())
// size=0 requests stream originals, so this reader is never called (serving_test covers resizing).
imgCache := cache.NewFileCache("ArtworkPipelineE2E", "100MB", "images", 0,
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())
svc = artwork.NewArtwork(ds, imgCache, store, ffm)
worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache)
})
seedFolderAlbum := func(albumID string) {
folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}}
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(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Hash).To(Equal(ia.Hash))
Expect(img.Placeholder).To(BeFalse())
Expect(readAll(img)).To(Equal(coverBytes))
})
It("acquires an artist's uploaded image and serves it", func() {
name := writeUpload(consts.EntityArtist, "artist-e2e.png", artistPngFixture)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: name}})
bump("ar", "ar1")
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("upload"))
img, err := svc.Get(ctx, model.MustParseArtworkID("ar-ar1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Hash).To(Equal(ia.Hash))
Expect(readAll(img)).To(Equal(readFixture(artistPngFixture)))
})
It("generates a playlist grid from its tracks' album art and serves it from the store", func() {
seedFolderAlbum("al1")
plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}})
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"al1"}}
bump("pl", "pl1")
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
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(ia.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(art.Mime).To(Equal("image/png"))
Expect(len(readAll(img))).To(BeNumerically(">", 0))
})
It("acquires a radio station's uploaded image and serves it", func() {
name := writeUpload(consts.EntityRadio, "radio-e2e.jpg", coverFixture)
radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name}
bump("ra", "ra1")
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("upload"))
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Hash).To(Equal(ia.Hash))
Expect(readAll(img)).To(Equal(coverBytes))
})
It("serves an unresolved track provisionally, then upgrades to the worker's state row", func() {
mfRepo.SetData(model.MediaFiles{{
ID: "mf1", AlbumID: "al1", HasCoverArt: true, LibraryID: 0, Path: mp3Fixture,
}})
provisional, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(provisional.Placeholder).To(BeFalse())
Expect(provisional.Hash).ToNot(BeEmpty())
provisionalBytes := readAll(provisional)
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
_, 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(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("embedded"))
Expect(ia.Hash).To(Equal(provisional.Hash))
resolved, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(resolved.Hash).To(Equal(ia.Hash))
Expect(readAll(resolved)).To(Equal(provisionalBytes))
})
It("stores dimensions, mime and a real blurhash alongside the acquired bytes", func() {
seedFolderAlbum("al1")
bump("al", "al1")
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
art, err := artRepo.GetImage(ia.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(art.Mime).To(Equal("image/jpeg"))
Expect(art.Width).To(BeNumerically(">", 0))
Expect(art.Height).To(BeNumerically(">", 0))
Expect(art.SizeBytes).To(BeNumerically("==", len(coverBytes)))
// Never a synthesized value: both hashes are encoded from the real pixels.
Expect(art.BlurHash).ToNot(BeEmpty())
Expect(art.ThumbHash).ToNot(BeEmpty())
raw, err := base64.StdEncoding.DecodeString(art.ThumbHash)
Expect(err).ToNot(HaveOccurred())
Expect(len(raw)).To(BeNumerically(">=", 5))
})
It("acquires GIF artwork, whose decoder only core/artwork's blank import registers", func() {
writeUploadedImage(consts.EntityRadio, "station.gif", gifFixture)
radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: "station.gif"}
bump("ra", "ra1")
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
art, err := artRepo.GetImage(ia.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(art.Mime).To(Equal("image/gif"))
Expect(art.Width).To(BeNumerically("==", 4))
})
It("deduplicates byte-identical art across entities onto one image row", func() {
folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0},
{ID: "al2", Name: "Same Cover", FolderIDs: []string{"f1"}, LibraryID: 0},
})
bump("al", "al1")
bump("al", "al2")
runWorkerUntil(ctx, worker, func() bool {
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
})
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
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))
})
It("stops serving a file-backed image once its source file changes underneath", func() {
name := writeUpload(consts.EntityRadio, "radio-stale.jpg", coverFixture)
radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name}
bump("ra", "ra1")
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
staleHash := ia.Hash
path := model.UploadedImagePath(consts.EntityRadio, name)
Expect(os.WriteFile(path, readFixture(artistPngFixture), 0o600)).To(Succeed())
newer := time.Now().Add(2 * time.Second)
Expect(os.Chtimes(path, newer, newer)).To(Succeed())
// The mtime no longer matches the state row, so the stale bytes are not served.
_, err = svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
Expect(err).To(MatchError(artwork.ErrUnavailable))
// That failed read enqueued a re-resolution.
runWorkerUntil(ctx, worker, func() bool {
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)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(readFixture(artistPngFixture)))
})
It("records an absent state for an entity with no art and reports it unavailable", func() {
albumRepo.SetData(model.Albums{{ID: "alx", Name: "Artless", LibraryID: 0}})
bump("al", "alx")
runWorkerUntil(ctx, worker, itemAbsent(model.KindAlbumArtwork, "alx"))
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alx"), 0, false)
Expect(err).To(MatchError(artwork.ErrUnavailable))
img, err := svc.GetOrPlaceholder(ctx, "al-alx", 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Placeholder).To(BeTrue())
})
})
func mustGet(img *artwork.Image, err error) *artwork.Image {
GinkgoHelper()
Expect(err).ToNot(HaveOccurred())
return img
}
// 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,
0x00, 0x2e, 0x86, 0xc1, 0xf4, 0xd0, 0x3f, 0x2c, 0x00, 0x00, 0x00, 0x00,
0x04, 0x00, 0x04, 0x00, 0x00, 0x02, 0x05, 0x44, 0x7c, 0x67, 0xb8, 0x05,
0x00, 0x3b,
}
+90 -208
View File
@@ -1,4 +1,4 @@
package e2e
package artworke2e_test
import (
"testing/fstest"
@@ -9,11 +9,14 @@ import (
. "github.com/onsi/gomega"
)
// The in-memory library FS cannot satisfy the os.Open(SourcePath) used to serve folder art, so
// folder scenarios assert on the worker's state row (Source + SourcePath) instead of the bytes.
const (
defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external"
defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded"
)
var _ = Describe("Album artwork resolution", func() {
BeforeEach(func() {
setupResolutionHarness()
setupHarness()
})
When("an album has a single folder with cover.jpg at the album root", func() {
@@ -25,22 +28,24 @@ var _ = Describe("Album artwork resolution", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": smallPNG("album-root"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
// https://github.com/navidrome/navidrome/issues/5376
// cover.* basenames tie across album-root and per-disc folders;
// compareImageFiles must prefer shallower paths.
// Bug 2 variant: cover.* basenames tie across album-root and per-disc folders;
// compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder
// files first.
When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg ← should not win
// │ └── cover.jpg ← currently wins (bug)
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg
@@ -50,28 +55,28 @@ var _ = Describe("Album artwork resolution", func() {
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
"Artist/Album/cover.jpg": smallPNG("album-root"),
"Artist/Album/CD1/cover.jpg": smallPNG("disc1"),
"Artist/Album/CD2/cover.jpg": smallPNG("disc2"),
"Artist/Album/cover.jpg": imageFile("album-root"),
"Artist/Album/CD1/cover.jpg": imageFile("disc1"),
"Artist/Album/CD2/cover.jpg": imageFile("disc2"),
})
scan()
al := firstAlbum()
Expect(al.FolderIDs).To(HaveLen(2),
"sanity check: the two disc subfolders should form one multi-disc album")
expectAlbumFolderCover(al, "Artist/Album/cover.jpg")
"sanity check: scanner should treat the two disc subfolders as one multi-disc album")
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
// https://github.com/navidrome/navidrome/issues/5376
// folder.jpg basenames tie across album-root and per-disc folders;
// compareImageFiles must prefer shallower paths.
// Bug 2: folder.jpg basenames tie across album-root and per-disc folders;
// the lexicographic full-path tiebreaker in compareImageFiles ranks
// "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg".
When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← should not win
// │ └── folder.jpg ← currently wins (bug)
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
@@ -81,55 +86,36 @@ var _ = Describe("Album artwork resolution", func() {
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
"Artist/Album/folder.jpg": smallPNG("album-root"),
"Artist/Album/CD1/folder.jpg": smallPNG("disc1"),
"Artist/Album/CD2/folder.jpg": smallPNG("disc2"),
"Artist/Album/folder.jpg": imageFile("album-root"),
"Artist/Album/CD1/folder.jpg": imageFile("disc1"),
"Artist/Album/CD2/folder.jpg": imageFile("disc2"),
})
scan()
expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
// https://github.com/navidrome/navidrome/issues/5376
// Single-subfolder albums must still consider the parent folder's images.
// Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder
// lookup whenever an album lives entirely under a single subfolder, so an
// album-root cover is never considered.
When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() {
// Artist/
// └── Album/
// ├── disc1/
// │ └── 01 - Track.mp3
// └── cover.jpg ← should win (parent-folder fallback)
// └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug)
It("uses the parent-folder cover for single-disc-subfolder albums", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": smallPNG("album-root"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
})
})
// https://github.com/navidrome/navidrome/issues/5456
When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() {
// Album/ (top-level folder, Path=".")
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── cover.jpg ← should win (album-root)
It("prefers the album-root cover.jpg", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
"Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
"Album/cover.jpg": smallPNG("album-root"),
"Album/CD1/folder.jpg": smallPNG("disc1"),
"Album/CD2/folder.jpg": smallPNG("disc2"),
})
scan()
expectAlbumFolderCover(firstAlbum(), "Album/cover.jpg")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
@@ -142,14 +128,14 @@ var _ = Describe("Album artwork resolution", func() {
conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
"Artist/Album/cover.jpg": smallPNG("external"),
"Artist/Album/cover.jpg": imageFile("external"),
})
scan()
// Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream.
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
ia := acquire(model.KindAlbumArtwork, firstAlbum().ID)
Expect(ia.Source).To(Equal("embedded"))
Expect(storedBytes(ia)).To(Equal(embeddedArtBytes))
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
})
})
@@ -165,9 +151,8 @@ var _ = Describe("Album artwork resolution", func() {
scan()
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
ia := acquire(model.KindAlbumArtwork, firstAlbum().ID)
Expect(ia.Source).To(Equal("embedded"))
Expect(storedBytes(ia)).To(Equal(embeddedArtBytes))
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
})
})
@@ -180,10 +165,12 @@ var _ = Describe("Album artwork resolution", func() {
conf.Server.CoverArtPriority = "cover.*, folder.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/Cover.JPG": smallPNG("case-insensitive"),
"Artist/Album/Cover.JPG": imageFile("case-insensitive"),
})
scan()
expectAlbumFolderCover(firstAlbum(), "Artist/Album/Cover.JPG")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive")))
})
})
@@ -197,25 +184,30 @@ var _ = Describe("Album artwork resolution", func() {
conf.Server.CoverArtPriority = "cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": smallPNG("primary"),
"Artist/Album/cover.1.jpg": smallPNG("secondary"),
"Artist/Album/cover.jpg": imageFile("primary"),
"Artist/Album/cover.1.jpg": imageFile("secondary"),
})
scan()
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
})
})
When("the album has no cover and CoverArtPriority lists only file patterns", func() {
// Artist/
// └── Album/
// └── 01 - Track.mp3 (no image files — settles absent)
It("settles absent", func() {
// └── 01 - Track.mp3 (no image files — returns ErrUnavailable)
It("returns ErrUnavailable", func() {
conf.Server.CoverArtPriority = "cover.*, folder.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
})
scan()
expectAlbumAbsent(firstAlbum())
al := firstAlbum()
_, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt))
Expect(err).To(HaveOccurred())
})
})
@@ -231,10 +223,12 @@ var _ = Describe("Album artwork resolution", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/folder.jpg": smallPNG("folder"),
"Artist/Album/folder.jpg": imageFile("folder"),
})
scan()
expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
})
})
@@ -247,10 +241,12 @@ var _ = Describe("Album artwork resolution", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/front.jpg": smallPNG("front"),
"Artist/Album/front.jpg": imageFile("front"),
})
scan()
expectAlbumFolderCover(firstAlbum(), "Artist/Album/front.jpg")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front")))
})
})
@@ -265,12 +261,14 @@ var _ = Describe("Album artwork resolution", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": smallPNG("cover"),
"Artist/Album/folder.jpg": smallPNG("folder"),
"Artist/Album/front.jpg": smallPNG("front"),
"Artist/Album/cover.jpg": imageFile("cover"),
"Artist/Album/folder.jpg": imageFile("folder"),
"Artist/Album/front.jpg": imageFile("front"),
})
scan()
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
})
})
@@ -284,11 +282,13 @@ var _ = Describe("Album artwork resolution", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/folder.jpg": smallPNG("folder"),
"Artist/Album/front.jpg": smallPNG("front"),
"Artist/Album/folder.jpg": imageFile("folder"),
"Artist/Album/front.jpg": imageFile("front"),
})
scan()
expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
})
})
@@ -303,12 +303,14 @@ var _ = Describe("Album artwork resolution", func() {
conf.Server.CoverArtPriority = "cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.2.jpg": smallPNG("second"),
"Artist/Album/cover.jpg": smallPNG("primary"),
"Artist/Album/cover.1.jpg": smallPNG("first"),
"Artist/Album/cover.2.jpg": imageFile("second"),
"Artist/Album/cover.jpg": imageFile("primary"),
"Artist/Album/cover.1.jpg": imageFile("first"),
})
scan()
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
})
})
@@ -321,134 +323,12 @@ var _ = Describe("Album artwork resolution", func() {
conf.Server.CoverArtPriority = "bogus.*, cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": smallPNG("cover"),
})
scan()
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
})
})
// Regression introduced in v0.62.0 (#5451 + #5457): the parent-folder
// fallback can pick up images from the ARTIST folder, serving the artist
// thumbnail as album art for any album without its own image files.
When("an album has no images and the artist folder has folder.jpg", func() {
// Artist/
// ├── folder.jpg ← artist thumbnail, must NOT become album art
// ├── Album A/
// │ └── 01 - Track.mp3 (no images)
// └── Album B/
// ├── 01 - Track.mp3
// └── cover.jpg
It("does not use the artist image as album art", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}),
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
"Artist/Album B/cover.jpg": smallPNG("album-b"),
"Artist/Album/cover.jpg": imageFile("cover"),
})
scan()
// Album B first: the acquire in expectAlbumAbsent would settle Album B too.
expectAlbumFolderCover(albumByName("Album B"), "Artist/Album B/cover.jpg")
expectAlbumAbsent(albumByName("Album A"))
})
})
When("a single-disc album is spread across sibling folders under the artist folder", func() {
// Artist/
// ├── folder.jpg ← artist thumbnail, must NOT become album art
// ├── Album A/
// │ └── 01 - Track.mp3 (album: "Album A")
// ├── Album A bonus/
// │ └── 02 - Track.mp3 (album: "Album A" — same album, second folder)
// └── Album B/
// ├── 01 - Track.mp3
// └── cover.jpg
It("does not use the artist image as album art for the spread album", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
"Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}),
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
"Artist/Album B/cover.jpg": smallPNG("album-b"),
})
scan()
alA := albumByName("Album A")
Expect(alA.FolderIDs).To(HaveLen(2),
"sanity check: the two sibling folders should form one spread album")
expectAlbumAbsent(alA)
})
})
// albumRootParent refuses the library root as an album root (parent.ParentID == "").
When("a multi-disc album sits directly at the library root with a cover.jpg beside it", func() {
// (library root)
// ├── cover.jpg ← must NOT be adopted
// ├── CD1/
// │ └── 01 - Track.mp3
// └── CD2/
// └── 01 - Track.mp3
It("does not adopt the library-root image as album art", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"cover.jpg": smallPNG("library-root"),
"CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"album": "Rootless", "disc": "1"}),
"CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"album": "Rootless", "disc": "2"}),
})
scan()
expectAlbumAbsent(firstAlbum())
})
})
// The shallower artist-folder cover.jpg would win the basename tie, but albumRootParent skips
// the parent folder for a single-folder album that has images of its own.
When("a single-folder album has its own cover.jpg and the artist folder has one too", func() {
// Artist/
// ├── cover.jpg ← shallower, but must NOT win
// └── Album/
// ├── 01 - Track.mp3
// └── cover.jpg ← should win
It("prefers the album's own cover over the shallower artist-folder cover", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/cover.jpg": smallPNG("artist-image"),
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/Album/cover.jpg": smallPNG("album-own"),
})
scan()
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
})
})
When("a spread album has its own front.jpg but the artist folder has cover.jpg", func() {
// Artist/
// ├── cover.jpg ← artist image; matches cover.* (first pattern),
// │ must NOT shadow the album's own front.jpg
// ├── Album A/
// │ ├── 01 - Track.mp3 (album: "Album A")
// │ └── front.jpg ← should win
// ├── Album A bonus/
// │ └── 02 - Track.mp3 (album: "Album A")
// └── Album B/
// └── 01 - Track.mp3 (other-album audio: rejects the artist folder as a root)
It("prefers the album's own art over the artist image", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/cover.jpg": smallPNG("artist-image"),
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
"Artist/Album A/front.jpg": smallPNG("album-a-front"),
"Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}),
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
})
scan()
alA := albumByName("Album A")
Expect(alA.FolderIDs).To(HaveLen(2),
"sanity check: the two sibling folders should form one spread album")
expectAlbumFolderCover(alA, "Artist/Album A/front.jpg")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
})
})
@@ -461,10 +341,12 @@ var _ = Describe("Album artwork resolution", func() {
conf.Server.CoverArtPriority = "embedded, cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": smallPNG("cover"),
"Artist/Album/cover.jpg": imageFile("cover"),
})
scan()
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
})
})
})
+29 -131
View File
@@ -1,4 +1,4 @@
package e2e
package artworke2e_test
import (
"os"
@@ -8,7 +8,6 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -17,11 +16,9 @@ import (
// Doc reference:
// https://www.navidrome.org/docs/usage/library/artwork/#artists
// Default ArtistArtPriority is "artist.*, album/artist.*, external".
// Library-folder images are file-backed (asserted on the worker state row); uploaded and
// image-folder images are real files on disk (asserted byte-for-byte).
var _ = Describe("Artist artwork resolution", func() {
BeforeEach(func() {
setupResolutionHarness()
setupHarness()
})
When("the artist folder contains an artist.jpg", func() {
@@ -33,10 +30,13 @@ var _ = Describe("Artist artwork resolution", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/artist.jpg": smallPNG("artist-folder"),
"Artist/artist.jpg": imageFile("artist-folder"),
})
scan()
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
})
})
@@ -49,10 +49,13 @@ var _ = Describe("Artist artwork resolution", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/Album/artist.jpg": smallPNG("album-artist"),
"Artist/Album/artist.jpg": imageFile("album-artist"),
})
scan()
expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg")
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
})
})
@@ -66,94 +69,14 @@ var _ = Describe("Artist artwork resolution", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/artist.jpg": smallPNG("artist-folder"),
"Artist/Album/artist.jpg": smallPNG("album-artist"),
"Artist/artist.jpg": imageFile("artist-folder"),
"Artist/Album/artist.jpg": imageFile("album-artist"),
})
scan()
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
})
})
When("ArtistArtPriority has no album/ fallback", func() {
// Artist/
// ├── artist.jpg ← must resolve via the artist folder itself
// └── Album/
// └── 01 - Track.mp3
It("still resolves the artist folder and returns artist.*", func() {
conf.Server.ArtistArtPriority = "artist.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/artist.jpg": smallPNG("artist-folder"),
})
scan()
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
})
})
When("the artist's only album has its tracks in disc subfolders", func() {
// Artist/
// ├── artist.jpg ← wins (artist.* before album/artist.*)
// └── Album/
// ├── artist.jpg
// ├── CD1/01 - Track.mp3
// └── CD2/02 - Track.mp3
It("prefers the artist-folder image over the album-folder one", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}),
"Artist/Album/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}),
"Artist/artist.jpg": smallPNG("artist-folder"),
"Artist/Album/artist.jpg": smallPNG("album-artist"),
})
scan()
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
})
})
When("one album has disc subfolders and another sits at artist level", func() {
// Artist/
// ├── artist.jpg ← wins
// ├── Album1/
// │ ├── artist.jpg
// │ ├── CD1/01 - Track.mp3
// │ └── CD2/02 - Track.mp3
// └── Album2/03 - Track.mp3
It("prefers the artist-folder image over the album-folder one", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}),
"Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}),
"Artist/Album2/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}),
"Artist/artist.jpg": smallPNG("artist-folder"),
"Artist/Album1/artist.jpg": smallPNG("album-artist"),
})
scan()
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
})
})
When("every album of the artist has its tracks in disc subfolders", func() {
// Artist/
// ├── artist.jpg ← wins
// ├── Album1/
// │ ├── artist.jpg
// │ ├── CD1/01 - Track.mp3
// │ └── CD2/02 - Track.mp3
// └── Album2/
// ├── CD1/03 - Track.mp3
// └── CD2/04 - Track.mp3
It("prefers the artist-folder image over the album-folder one", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}),
"Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}),
"Artist/Album2/CD1/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}),
"Artist/Album2/CD2/04 - Track.mp3": trackFile(4, "Track 4", map[string]any{"albumartist": "Artist", "album": "Album2"}),
"Artist/artist.jpg": smallPNG("artist-folder"),
"Artist/Album1/artist.jpg": smallPNG("album-artist"),
})
scan()
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
})
})
@@ -171,19 +94,18 @@ var _ = Describe("Artist artwork resolution", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/artist.jpg": smallPNG("artist-folder"),
"Artist/artist.jpg": imageFile("artist-folder"),
})
scan()
ar := soleArtist()
uploaded := ar.ID + "_upload.jpg"
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded"))
ar.UploadedImage = uploaded
Expect(rds.Artist(rctx).Put(&ar)).To(Succeed())
Expect(ds.Artist(ctx).Put(&ar)).To(Succeed())
ia := acquire(model.KindArtistArtwork, ar.ID)
Expect(ia.Source).To(Equal("upload"))
Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("artist-uploaded")))
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded")))
})
})
@@ -196,36 +118,13 @@ var _ = Describe("Artist artwork resolution", func() {
conf.Server.ArtistArtPriority = "album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/Album/artist.jpg": smallPNG("album-artist"),
"Artist/Album/artist.jpg": imageFile("album-artist"),
})
scan()
expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg")
})
})
// resolveArtist only samples albums where this artist is the SOLE album artist, so a
// collaboration or compilation never donates its images as the artist's own.
When("the artist's only album is credited to two album artists", func() {
// Artist/
// └── Collab Album/ (album artists: "Artist" + a collaborator)
// ├── 01 - Track.mp3
// └── artist.jpg ← must NOT become the artist image
It("ignores the album's images and settles absent", func() {
conf.Server.ArtistArtPriority = "album/artist.*"
// " / " is a default artists split separator, so this single tag yields two album artists.
setLayout(fstest.MapFS{
"Artist/Collab Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist / Collaborator"}),
"Artist/Collab Album/artist.jpg": smallPNG("collab-artist"),
})
scan()
Expect(firstAlbum().Participants[model.RoleAlbumArtist]).To(HaveLen(2),
"sanity check: the album must be credited to two album artists")
ar := soleArtist()
ia := acquire(model.KindArtistArtwork, ar.ID)
Expect(ia.Hash).To(BeEmpty())
Expect(serveErr(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).
To(MatchError(artwork.ErrUnavailable))
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
})
})
@@ -238,7 +137,7 @@ var _ = Describe("Artist artwork resolution", func() {
// └── 01 - Track.mp3 (no artist.* present in library)
It("returns the image from the configured artist image folder", func() {
imgFolder := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), pngBytes("image-folder"), 0o600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed())
conf.Server.ArtistImageFolder = imgFolder
conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*"
@@ -248,16 +147,15 @@ var _ = Describe("Artist artwork resolution", func() {
scan()
ar := soleArtist()
ia := acquire(model.KindArtistArtwork, ar.ID)
Expect(ia.Source).To(Equal("folder"))
Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("image-folder")))
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder")))
})
})
})
func soleArtist() model.Artist {
GinkgoHelper()
artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"artist.name": "Artist"},
})
Expect(err).ToNot(HaveOccurred())
+65 -122
View File
@@ -1,20 +1,17 @@
package e2e
package artworke2e_test
import (
"fmt"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Disc art is a serve-time read through the library FS (no worker state row), so per-disc images
// are asserted byte-for-byte, while album-root covers are asserted on the state row.
var _ = Describe("Disc artwork resolution", func() {
BeforeEach(func() {
setupResolutionHarness()
setupHarness()
})
When("the album is single-disc with a disc1.jpg in the only folder", func() {
@@ -26,25 +23,32 @@ var _ = Describe("Disc artwork resolution", func() {
conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/disc1.jpg": smallPNG("disc1-image"),
"Artist/Album/disc1.jpg": imageFile("disc1-image"),
})
scan()
expectDiscImage(firstAlbum(), 1, "disc1-image")
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image")))
})
})
When("the album has no per-disc image and no album cover", func() {
// Artist/
// └── Album/
// └── 01 - Track.mp3 (no disc or album art — nothing to serve)
It("reports the disc lookup as unavailable", func() {
// └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable)
It("returns ErrUnavailable for the disc lookup", func() {
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
conf.Server.CoverArtPriority = "cover.*, folder.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
})
scan()
Expect(serveErr(discArtID(firstAlbum(), 1))).To(MatchError(artwork.ErrUnavailable))
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
_, err := readArtworkOrErr(discID)
Expect(err).To(HaveOccurred())
})
})
@@ -58,10 +62,13 @@ var _ = Describe("Disc artwork resolution", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": smallPNG("album-cover"),
"Artist/Album/cover.jpg": imageFile("album-cover"),
})
scan()
expectDiscImage(firstAlbum(), 1, "album-cover")
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")))
})
})
@@ -75,11 +82,14 @@ var _ = Describe("Disc artwork resolution", func() {
conf.Server.DiscArtPriority = "disc*.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/disc1.jpg": smallPNG("disc-one"),
"Artist/Album/disc10.jpg": smallPNG("disc-ten"),
"Artist/Album/disc1.jpg": imageFile("disc-one"),
"Artist/Album/disc10.jpg": imageFile("disc-ten"),
})
scan()
expectDiscImage(firstAlbum(), 1, "disc-one")
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one")))
})
})
@@ -97,11 +107,14 @@ var _ = Describe("Disc artwork resolution", func() {
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"),
"Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/disc2.jpg": imageFile("disc-2"),
})
scan()
expectDiscImage(firstAlbum(), 2, "disc-2")
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2")))
})
})
@@ -122,11 +135,14 @@ var _ = Describe("Disc artwork resolution", func() {
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"),
"Artist/Album/CD2/cd2.png": smallPNG("cd-2"),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
})
scan()
expectDiscImage(firstAlbum(), 2, "cd-2")
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2")))
})
})
@@ -144,11 +160,14 @@ var _ = Describe("Disc artwork resolution", func() {
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/cover.jpg": smallPNG("disc1-cover"),
"Artist/Album/CD2/cover.jpg": smallPNG("disc2-cover"),
"Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"),
"Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"),
})
scan()
expectDiscImage(firstAlbum(), 1, "disc1-cover")
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover")))
})
})
@@ -168,15 +187,17 @@ var _ = Describe("Disc artwork resolution", func() {
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"),
"Artist/Album/CD2/cd2.png": smallPNG("cd-2"),
"Artist/Album/cover.jpg": smallPNG("album-cover"),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
"Artist/Album/cover.jpg": imageFile("album-cover"),
})
scan()
al := firstAlbum()
for _, n := range []int{1, 2} {
expectDiscImage(al, n, "album-cover")
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")),
"disc %d should use the album cover when DiscArtPriority is empty", n)
}
})
})
@@ -201,15 +222,17 @@ var _ = Describe("Disc artwork resolution", func() {
"Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}),
"Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}),
"Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}),
"Artist/Album/disc1/disc1.jpg": smallPNG("disc-1"),
"Artist/Album/disc2/cd2.png": smallPNG("cd-2"),
"Artist/Album/cover.jpg": smallPNG("album-root"),
"Artist/Album/disc1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/disc2/cd2.png": imageFile("cd-2"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
al := firstAlbum()
expectDiscImage(al, 1, "disc-1")
expectDiscImage(al, 2, "cd-2")
disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1")))
Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2")))
})
})
@@ -222,96 +245,13 @@ var _ = Describe("Disc artwork resolution", func() {
conf.Server.DiscArtPriority = "discsubtitle"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
"Artist/Album/Bonus Tracks.jpg": smallPNG("bonus-tracks"),
"Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"),
})
scan()
expectDiscImage(firstAlbum(), 1, "bonus-tracks")
})
})
// Reproduces https://github.com/navidrome/navidrome/issues/5456
// Deeply nested layout matching the reporter's actual structure.
When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() {
// Genre/Artist/Album/ ← album root with cover.jpg
// ├── cover.jpg ← album-level cover
// ├── Disc 01 (Subtitle)/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← disc 1 art
// ├── Disc 02 (Subtitle)/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── ... (12 discs)
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
conf.Server.CoverArtPriority = defaultCoverPriority
discNames := []string{
"Disc 01 (Birth of the Dead - The Studio Sides)",
"Disc 02 (Birth of the Dead - The Live Sides)",
"Disc 03 (The Grateful Dead)",
"Disc 04 (Anthem of the Sun)",
"Disc 05 (Aoxomoxoa)",
"Disc 06 (Live; Dead)",
"Disc 07 (Workingman's Dead)",
"Disc 08 (American Beauty)",
"Disc 09 (Grateful Dead)",
"Disc 10 (Europe '72)",
"Disc 11 (Europe '72)",
"Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))",
}
layout := fstest.MapFS{
"Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": smallPNG("album-root-cover"),
}
for i, name := range discNames {
discNum := i + 1
prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name)
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)})
layout[prefix+"folder.jpg"] = smallPNG(fmt.Sprintf("disc-%02d-folder", discNum))
}
setLayout(layout)
scan()
al := firstAlbum()
expectAlbumFolderCover(al, "(2001) The Golden Road/cover.jpg")
for i := range discNames {
discNum := i + 1
expectDiscImage(al, discNum, fmt.Sprintf("disc-%02d-folder", discNum))
}
})
})
// https://github.com/navidrome/navidrome/issues/5456
// Top-level album variant — album folder at library root (Path=".").
When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() {
// Album/ (top-level, Path=".")
// ├── cover.jpg ← album-level cover
// ├── Disc 01/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← disc 1 art
// ├── Disc 02/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── Disc 03/
// ├── 01 - Track.mp3
// └── folder.jpg
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
conf.Server.CoverArtPriority = defaultCoverPriority
layout := fstest.MapFS{
"Album/cover.jpg": smallPNG("album-root-cover"),
}
for i := 1; i <= 3; i++ {
prefix := fmt.Sprintf("Album/Disc %02d/", i)
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)})
layout[prefix+"folder.jpg"] = smallPNG(fmt.Sprintf("disc-%02d-folder", i))
}
setLayout(layout)
scan()
al := firstAlbum()
expectAlbumFolderCover(al, "Album/cover.jpg")
for i := 1; i <= 3; i++ {
expectDiscImage(al, i, fmt.Sprintf("disc-%02d-folder", i))
}
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks")))
})
})
@@ -324,10 +264,13 @@ var _ = Describe("Disc artwork resolution", func() {
conf.Server.DiscArtPriority = "discsubtitle, cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
"Artist/Album/cover.jpg": smallPNG("cover"),
"Artist/Album/cover.jpg": imageFile("cover"),
})
scan()
expectDiscImage(firstAlbum(), 1, "cover")
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("cover")))
})
})
})
Loaded 100 of 1126 files, more files were not shown because too many files have changed in this diff. Show more