Compare commits

..
Author SHA1 Message Date
Deluan b4b1830513 refactor: remove outdated comments regarding NATURALSORT collation behavior
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 11:35:36 -05:00
Deluan Quintão eca4c5acf0 Merge branch 'master' into custom-collation-function 2026-02-17 11:27:15 -05:00
Deluan e766a5d780 refactor: extend NATURALSORT collation to playlist and radio indexes
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 10:42:05 -05:00
Deluan 90d6cd5f47 refactor: update collation handling for natural sorting in SQL queries
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 10:37:08 -05:00
Deluan 24ab04581a fix format
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 09:45:34 -05:00
Deluan 8e647a0e41 chore(deps): bump golangci-lint to v2.10.0 and suppress new gosec false positives
Bump golangci-lint from v2.9.0 to v2.10.0, which includes a newer gosec
with additional taint-analysis rules (G117, G703, G704, G705) and a
stricter G101 check. Added inline //nolint:gosec comments to suppress
21 false positives across 19 files: struct fields flagged as secrets
(G117), w.Write calls flagged as XSS (G705), HTTP client calls flagged
as SSRF (G704), os.Stat/os.ReadFile/os.Remove flagged as path traversal
(G703), and a sort mapping flagged as hardcoded credentials (G101).

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 09:31:37 -05:00
Deluan QuintãoandCopilot 86c326bd4a fix doc
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-17 09:30:18 -05:00
Deluan 929e7193b4 refactor: use natural.Compare directly instead of wrapper 2026-02-17 09:00:23 -05:00
Deluan 9bcefea0ca refactor: use maruel/natural for NATURALSORT collation instead of custom impl
Replace the hand-rolled natural sort comparison with a thin wrapper
around github.com/maruel/natural.Compare, which is already a dependency.
The wrapper just lowercases both inputs for case-insensitive comparison.
2026-02-17 09:00:20 -05:00
Deluan b0cb40b029 feat: add custom NATURALSORT collation for natural number ordering
Register a custom SQLite collation function (NATURALSORT) that compares
strings using natural sort ordering, where embedded numeric sequences are
compared as numbers rather than lexicographically. This fixes the issue
where albums like "Bravo Hits 1-132" sort as 1, 10, 100... instead of
1, 2, 3... 10, 11...

Closes navidrome/navidrome#4891
2026-02-17 09:00:15 -05:00
1122 changed files with 18093 additions and 95624 deletions

No files matched your search

+12
View File
@@ -13,5 +13,17 @@ RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/shar
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends ffmpeg
# Install TagLib from cross-taglib releases
ARG CROSS_TAGLIB_VERSION="2.1.1-1"
ARG TARGETARCH
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
&& tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \
&& mv /usr/include/taglib/* /usr/include/ \
&& rmdir /usr/include/taglib \
&& rm /tmp/cross-taglib.tar.gz /usr/provenance.json
ENV CGO_CFLAGS_ALLOW="--define-prefix"
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
+3 -2
View File
@@ -4,10 +4,11 @@
"dockerfile": "Dockerfile",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
"VARIANT": "1.26",
"VARIANT": "1.25",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v24"
"NODE_VERSION": "v24",
"CROSS_TAGLIB_VERSION": "2.1.1-1"
}
},
"workspaceMount": "",
@@ -0,0 +1,23 @@
name: 'Download TagLib'
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
inputs:
version:
description: 'Version of TagLib to download'
required: true
platform:
description: 'Platform to download TagLib for'
default: 'linux-amd64'
runs:
using: 'composite'
steps:
- name: Download TagLib
shell: bash
run: |
mkdir -p /tmp/taglib
cd /tmp
FILE=taglib-${{ inputs.platform }}.tar.gz
wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE}
tar -xzf ${FILE} -C taglib
PKG_CONFIG_PREFIX=/tmp/taglib
echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV
echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV
+4 -5
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,13 +67,12 @@ runs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
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: |
+7 -6
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,7 +34,7 @@ jobs:
return core.error(`No matching pull request found`);
}
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
@@ -42,12 +43,12 @@ jobs:
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number});
const {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});
}
+41 -142
View File
@@ -14,6 +14,8 @@ concurrency:
cancel-in-progress: true
env:
CROSS_TAGLIB_VERSION: "2.1.1-2"
CGO_CFLAGS_ALLOW: "--define-prefix"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
jobs:
@@ -24,7 +26,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 +34,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 +42,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,11 +64,12 @@ jobs:
name: Lint Go code
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- name: Download TagLib
uses: ./.github/actions/download-taglib
with:
go-version-file: go.mod
version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
@@ -96,43 +99,25 @@ 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
- name: Download TagLib
uses: ./.github/actions/download-taglib
with:
go-version-file: go.mod
version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: Download dependencies
run: go mod download
- name: Test
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
run: |
pkg-config --define-prefix --cflags --libs taglib # for debugging
go test -shuffle=on -tags netgo -race ./... -v
- name: Test ndpgen
run: |
@@ -141,86 +126,13 @@ jobs:
go build -o ndpgen .
./ndpgen --help
go-windows:
name: Test Go code (Windows)
runs-on: windows-2022
env:
FFMPEG_VERSION: "7.1"
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
install: mingw-w64-x86_64-gcc
update: false
- name: Add mingw64 to PATH
shell: bash
run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
- name: Cache ffmpeg
id: ffmpeg-cache
uses: actions/cache@v6
with:
path: C:\ffmpeg
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
- name: Download ffmpeg
if: steps.ffmpeg-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
$asset = "ffmpeg-n${env:FFMPEG_VERSION}-latest-win64-gpl-${env:FFMPEG_VERSION}"
$url = "https://github.com/${env:FFMPEG_REPOSITORY}/releases/download/latest/$asset.zip"
Invoke-WebRequest -Uri $url -OutFile ffmpeg.zip
Expand-Archive ffmpeg.zip -DestinationPath C:\ffmpeg-extracted
New-Item -ItemType Directory -Force -Path C:\ffmpeg\bin | Out-Null
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffmpeg.exe" C:\ffmpeg\bin
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffprobe.exe" C:\ffmpeg\bin
- name: Add ffmpeg to PATH
shell: bash
run: echo "C:/ffmpeg/bin" >> $GITHUB_PATH
- name: Verify toolchain
shell: pwsh
run: |
go version
where.exe gcc
gcc --version
ffmpeg -version
ffprobe -version
- name: Download dependencies
shell: bash
run: go mod download
- name: Test
shell: bash
env:
CGO_ENABLED: "1"
run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
- name: Test ndpgen
shell: pwsh
run: |
cd plugins\cmd\ndpgen
go test -shuffle=on -v
go build -o ndpgen.exe .
.\ndpgen.exe --help
js:
name: Test JS code
runs-on: ubuntu-latest
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
@@ -251,7 +163,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
@@ -278,7 +190,7 @@ jobs:
build:
name: Build
needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
needs: [js, go, go-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 ]
@@ -297,7 +209,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
@@ -309,7 +221,7 @@ jobs:
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Build Binaries
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -320,24 +232,10 @@ jobs:
build-args: |
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"
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
@@ -346,7 +244,7 @@ jobs:
- name: Build and push image by digest
id: push-image
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -355,6 +253,7 @@ jobs:
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
outputs: |
type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }}
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
@@ -367,7 +266,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
@@ -386,10 +285,10 @@ 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
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*
@@ -420,10 +319,10 @@ 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
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*
@@ -439,7 +338,7 @@ jobs:
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Create manifest list and push to Docker Hub
uses: nick-fields/retry@v4
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
@@ -473,9 +372,9 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@v7
with:
path: ./binaries
pattern: navidrome-windows*
@@ -494,7 +393,7 @@ jobs:
du -h binaries/msi/*.msi
- name: Upload MSI files
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
@@ -507,12 +406,12 @@ 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
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@v7
with:
path: ./binaries
pattern: navidrome-*
@@ -525,9 +424,9 @@ jobs:
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
uses: goreleaser/goreleaser-action@v6
with:
version: '2.16.0'
version: '~> v2'
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -538,7 +437,7 @@ jobs:
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: packages
path: dist/navidrome_0*
@@ -561,13 +460,13 @@ jobs:
item: ${{ fromJson(needs.release.outputs.package_list) }}
steps:
- name: Download all-packages artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: packages
path: ./dist
- name: Upload all-packages artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}
+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 -3
View File
@@ -37,6 +37,4 @@ AGENTS.md
*.wasm
*.ndp
openspec/
.agents
go.work*
.worktrees/
go.work*
-21
View File
@@ -2,7 +2,6 @@ version: "2"
run:
build-tags:
- netgo
- sqlite_fts5
linters:
enable:
- asasalint
@@ -13,7 +12,6 @@ linters:
- dogsled
- durationcheck
- errorlint
- forbidigo
- gocritic
- gocyclo
- goprintffuncname
@@ -37,26 +35,10 @@ 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
exclusions:
rules:
- linters:
- gosec
path: _test\.go
text: "G703"
- path-except: 'db/migrations/'
linters:
- forbidigo
generated: lax
presets:
- comments
@@ -67,8 +49,6 @@ linters:
- third_party$
- builtin$
- examples$
- node_modules
- _gen\.go$
formatters:
exclusions:
generated: lax
@@ -76,4 +56,3 @@ formatters:
- third_party$
- builtin$
- examples$
- node_modules
+1 -1
View File
@@ -38,7 +38,7 @@ Before submitting a pull request, ensure that you go through the following:
### Commit Conventions
Each commit message must adhere to the following format:
```
<type>(scope): <description>
<type>(scope): <description> - <issue number>
[optional body]
```
+32 -69
View File
@@ -24,6 +24,26 @@ RUN cd /out && \
FROM scratch AS xx
COPY --from=xx-build /out/ /usr/bin/
########################################################################################################################
### Get TagLib
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
ARG TARGETPLATFORM
ARG CROSS_TAGLIB_VERSION=2.1.1-2
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
# wget in busybox can't follow redirects
RUN <<EOT
apk add --no-cache wget
PLATFORM=$(echo ${TARGETPLATFORM} | tr '/' '-')
FILE=taglib-${PLATFORM}.tar.gz
DOWNLOAD_URL=${CROSS_TAGLIB_RELEASES_URL}${FILE}
wget ${DOWNLOAD_URL}
mkdir /taglib
tar -xzf ${FILE} -C /taglib
EOT
########################################################################################################################
### Build Navidrome UI
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
@@ -42,50 +62,8 @@ FROM scratch AS ui-bundle
COPY --from=ui /build /build
########################################################################################################################
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine
COPY --from=xx / /
ARG TARGETPLATFORM
RUN apk add --no-cache clang lld file git
RUN xx-apk add --no-cache gcc musl-dev zlib-dev
RUN xx-verify --setup
WORKDIR /workspace
RUN --mount=type=bind,source=. \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod \
go mod download
ARG GIT_SHA
ARG GIT_TAG
RUN --mount=type=bind,source=. \
--mount=from=ui,source=/build,target=./ui/build,ro \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod <<EOT
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 \
-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; }
EOT
########################################################################################################################
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base
### Build Navidrome binary
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-trixie AS base
RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / /
WORKDIR /workspace
@@ -110,13 +88,15 @@ RUN --mount=type=bind,source=. \
--mount=from=ui,source=/build,target=./ui/build,ro \
--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
--mount=type=cache,target=/go/pkg/mod \
--mount=from=taglib-build,target=/taglib,src=/taglib,ro <<EOT
# Setup CGO cross-compilation environment
xx-go --wrap
export CGO_ENABLED=1
cat "$(go env GOENV)" 2>/dev/null || true
export CGO_CFLAGS_ALLOW="--define-prefix"
export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
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 +105,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 -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
@@ -158,16 +127,11 @@ 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
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
# Install ffmpeg and mpv
RUN apk add -U --no-cache ffmpeg mpv sqlite
# Copy navidrome binary (musl build for Docker, enables native libwebp)
COPY --from=build-alpine /out/navidrome /app/
# Copy navidrome binary
COPY --from=build /out/navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER=/music
@@ -178,7 +142,6 @@ RUN touch /.nddockerenv
EXPOSE ${ND_PORT}
WORKDIR /app
ENV PATH="/app:${PATH}"
ENTRYPOINT ["/app/navidrome"]
+23 -55
View File
@@ -1,15 +1,13 @@
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc)
comma:=,
GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
# Set global environment variables, required for most targets
export CGO_CFLAGS_ALLOW=--define-prefix
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 +18,9 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
GOLANGCI_LINT_VERSION ?= v2.12.0
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.1.1-2
GOLANGCI_LINT_VERSION ?= v2.10.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
@@ -46,12 +46,12 @@ stop: ##@Development Stop development servers (UI and backend)
.PHONY: stop
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./...
go tool ginkgo watch -tags=netgo -notify ./...
.PHONY: watch
PKG ?= ./...
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
go test -tags $(GO_BUILD_TAGS) $(PKG)
go test -tags netgo $(PKG)
.PHONY: test
test-ndpgen: ##@Development Run tests for ndpgen plugin
@@ -62,7 +62,7 @@ testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests
.PHONY: testall
test-race: ##@Development Run Go tests with race detector
go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG)
go test -tags netgo -race -shuffle=on $(PKG)
.PHONY: test-race
test-js: ##@Development Run JS tests
@@ -75,8 +75,8 @@ test-i18n: ##@Development Validate all translations files
install-golangci-lint: ##@Development Install golangci-lint if not present
@INSTALL=false; \
if PATH=./bin:$$PATH which golangci-lint > /dev/null 2>&1; then \
CURRENT_VERSION=$$(PATH=./bin:$$PATH golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
if PATH=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \
CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
@@ -93,7 +93,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
.PHONY: install-golangci-lint
lint: install-golangci-lint ##@Development Lint Go code
PATH=./bin:$$PATH golangci-lint run --timeout 5m
PATH=$$PATH:./bin golangci-lint run --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code
@@ -108,16 +108,15 @@ format: ##@Development Format code
.PHONY: format
wire: check_go_env ##@Development Update Dependency Injection
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
go tool wire gen -tags=netgo ./...
.PHONY: wire
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
@@ -145,14 +144,14 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
.PHONY: setup-git
build: check_go_env buildjs ##@Build Build the project
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
.PHONY: build
buildall: deprecated build
.PHONY: buildall
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
.PHONY: debug-build
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
@@ -177,6 +176,7 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
--platform $(PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
--output "./binaries" --target binary .
.PHONY: docker-build
@@ -188,6 +188,7 @@ docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidro
--platform $(IMAGE_PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
--tag $(DOCKER_TAG) .
.PHONY: docker-image
@@ -200,8 +201,8 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows
@du -h binaries/msi/*.msi
.PHONY: docker-msi
docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag=<tag>
@if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag=<tag>"; exit 1; fi
run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag=<tag>
@if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag=<tag>"; exit 1; fi
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
if [ -f navidrome.toml ]; then \
@@ -212,7 +213,7 @@ docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run
fi; \
fi; \
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
.PHONY: docker-run
.PHONY: run-docker
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
@@ -231,39 +232,6 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
.PHONY: get-music
##########################################
#### Worktrees
WORKTREES_DIR := .worktrees
wt: check_go_env ##@Worktrees Create and setup a git worktree. Usage: make wt name=feature-name [go=1]
@if [ -z "${name}" ]; then echo "Usage: make wt name=<branch-name> [go=1]"; exit 1; fi
@mkdir -p $(WORKTREES_DIR)
@echo "Creating worktree for branch '${name}'..."
@git worktree add $(WORKTREES_DIR)/${name} -b ${name} 2>/dev/null || \
git worktree add $(WORKTREES_DIR)/${name} ${name}
@if [ -n "${go}" ]; then \
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name} --go-only; \
else \
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name}; \
fi
@echo "\nWorktree ready at $(WORKTREES_DIR)/${name}"
@echo " cd $(WORKTREES_DIR)/${name}"
.PHONY: wt
rm-wt: ##@Worktrees Remove a git worktree. Usage: make rm-wt name=feature-name
@if [ -z "${name}" ]; then echo "Usage: make rm-wt name=<branch-name>"; exit 1; fi
@if [ ! -d "$(WORKTREES_DIR)/${name}" ]; then echo "Worktree '${name}' not found in $(WORKTREES_DIR)/"; exit 1; fi
@echo "Removing worktree '${name}'..."
@git worktree remove --force $(WORKTREES_DIR)/${name}
@echo "Worktree '${name}' removed."
@echo "Note: branch '${name}' still exists. Delete it with: git branch -D ${name}"
.PHONY: rm-wt
ls-wt: ##@Worktrees List all active git worktrees
@git worktree list
.PHONY: ls-wt
##########################################
#### Miscellaneous
-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**
+1 -1
View File
@@ -1,7 +1,7 @@
package deezer
import (
"bytes"
bytes "bytes"
"context"
"encoding/json"
"errors"
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"sync"
"time"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/navidrome/navidrome/log"
)
@@ -84,8 +84,8 @@ func (c *client) getJWT(ctx context.Context) (string, error) {
}
// Calculate TTL with a 1-minute buffer for clock skew and network delays
expiresAt, ok := token.Expiration()
if !ok || expiresAt.IsZero() {
expiresAt := token.Expiration()
if expiresAt.IsZero() {
return "", errors.New("deezer: JWT token has no expiration time")
}
+2 -3
View File
@@ -9,7 +9,7 @@ import (
"sync"
"time"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/lestrrat-go/jwx/v2/jwt"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -179,8 +179,7 @@ var _ = Describe("JWT Authentication", func() {
Expect(err).To(BeNil())
// Verify token has no expiration
_, hasExp := testToken.Expiration()
Expect(hasExp).To(BeFalse())
Expect(testToken.Expiration().IsZero()).To(BeTrue())
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
Expect(err).To(BeNil())
+5 -26
View File
@@ -1,12 +1,10 @@
package deezer
import (
"cmp"
"context"
"errors"
"fmt"
"net/http"
"slices"
"strings"
"github.com/navidrome/navidrome/conf"
@@ -97,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) {
-60
View File
@@ -34,66 +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))
})
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("GetArtistBiography - Language Fallback", func() {
var agent *deezerAgent
var httpClient *langAwareHttpClient
+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,
+12 -31
View File
@@ -20,7 +20,6 @@ import (
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
@@ -44,34 +43,10 @@ func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
}
func (e extractor) Version() string {
bi, ok := debug.ReadBuildInfo()
if ok {
for _, dep := range bi.Deps {
if dep.Path == "go.senan.xyz/taglib" {
if dep.Replace != nil {
return dep.Replace.Version
}
return dep.Version
}
}
}
return "unknown"
return "go-taglib (TagLib 2.1.1 WASM)"
}
func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err error) {
// Recover from panics in the WASM runtime that can occur during any taglib
// operation (opening, reading tags, or reading properties). This catches crashes
// from malformed files or WASM runtime issues (e.g., wazero mmap failures on
// hardened systems with MemoryDenyWriteExecute=true).
debug.SetPanicOnFault(true)
defer func() {
if r := recover(); r != nil {
log.Error("gotaglib: WASM runtime panic reading file. Skipping", "filePath", filePath, "panic", r)
debug.PrintStack()
err = fmt.Errorf("WASM runtime panic: %v", r)
}
}()
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
f, close, err := e.openFile(filePath)
if err != nil {
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
@@ -90,7 +65,6 @@ func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err er
Channels: int(props.Channels),
SampleRate: int(props.SampleRate),
BitDepth: int(props.BitsPerSample),
Codec: props.Codec,
}
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
@@ -125,6 +99,16 @@ func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err er
// openFile opens the file at filePath using the extractor's filesystem.
// It returns a TagLib File handle and a cleanup function to close resources.
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
// Recover from panics in the WASM runtime (e.g., wazero failing to mmap executable memory
// on hardened systems like NixOS with MemoryDenyWriteExecute=true)
debug.SetPanicOnFault(true)
defer func() {
if r := recover(); r != nil {
log.Error("WASM runtime panic: This may be caused by a hardened system that blocks executable memory mapping.", "file", filePath, "panic", r)
err = fmt.Errorf("WASM runtime panic (hardened system?): %v", r)
}
}()
// Open the file from the filesystem
file, err := e.fs.Open(filePath)
if err != nil {
@@ -295,7 +279,4 @@ func init() {
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
return &extractor{fsys}
})
conf.AddHook(func() {
log.Debug("go-taglib version", "version", extractor{}.Version())
})
}
-13
View File
@@ -5,7 +5,6 @@ import (
"os"
"strings"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -128,17 +127,6 @@ var _ = Describe("Extractor", func() {
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
// Still as of TagLib v2.2.1, TagLib only maps values in ID3, MP4, and ASF tags
// to `originaldate`.
if strings.HasSuffix(file, ".mp3") || strings.HasSuffix(file, ".wav") || strings.HasSuffix(file, ".aiff") || strings.HasSuffix(file, ".m4a") || strings.HasSuffix(file, ".wma") {
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
}
// MP3Tag sets `ORIGYEAR` in several formats for which it has no built-in mapping
// for original release dates.
Expect(m.Tags).To(Or(
HaveKeyWithValue("origyear", []string{"1998-07-28"}),
HaveKeyWithValue("----:com.apple.itunes:origyear", []string{"1998-07-28"}),
))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(Or(
@@ -214,7 +202,6 @@ var _ = Describe("Extractor", func() {
// Only run permission tests if we are not root
RegularUserContext("when run without root privileges", func() {
BeforeEach(func() {
tests.SkipOnWindows("uses Unix file permission bits")
// Use root fs for absolute paths in temp directory
e = &extractor{fs: os.DirFS("/")}
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
+4 -7
View File
@@ -231,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
@@ -415,10 +416,6 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
return err == nil && sk != ""
}
func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
return nil
}
func init() {
conf.AddHook(func() {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
+5 -5
View File
@@ -309,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"))
+1 -14
View File
@@ -77,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)
}
@@ -104,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
-218
View File
@@ -1,218 +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"))
})
})
})
-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
}
+13 -30
View File
@@ -141,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
@@ -216,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,
}
@@ -225,10 +212,6 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin
return songs, nil
}
func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
return nil
}
func init() {
conf.AddHook(func() {
if conf.Server.ListenBrainz.Enabled {
+51 -74
View File
@@ -249,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,
},
}))
})
@@ -276,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() {
@@ -419,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,
},
}))
})
@@ -451,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,
},
}))
})
+116
View File
@@ -0,0 +1,116 @@
package spotify
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/navidrome/navidrome/log"
)
const apiBaseUrl = "https://api.spotify.com/v1/"
var (
ErrNotFound = errors.New("spotify: not found")
)
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
func newClient(id, secret string, hc httpDoer) *client {
return &client{id, secret, hc}
}
type client struct {
id string
secret string
hc httpDoer
}
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
token, err := c.authorize(ctx)
if err != nil {
return nil, err
}
params := url.Values{}
params.Add("type", "artist")
params.Add("q", name)
params.Add("offset", "0")
params.Add("limit", strconv.Itoa(limit))
req, _ := http.NewRequestWithContext(ctx, "GET", apiBaseUrl+"search", nil)
req.URL.RawQuery = params.Encode()
req.Header.Add("Authorization", "Bearer "+token)
var results SearchResults
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
if len(results.Artists.Items) == 0 {
return nil, ErrNotFound
}
return results.Artists.Items, err
}
func (c *client) authorize(ctx context.Context) (string, error) {
payload := url.Values{}
payload.Add("grant_type", "client_credentials")
encodePayload := payload.Encode()
req, _ := http.NewRequestWithContext(ctx, "POST", "https://accounts.spotify.com/api/token", strings.NewReader(encodePayload))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(encodePayload)))
auth := c.id + ":" + c.secret
req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))
response := map[string]any{}
err := c.makeRequest(req, &response)
if err != nil {
return "", err
}
if v, ok := response["access_token"]; ok {
return v.(string), nil
}
log.Error(ctx, "Invalid spotify response", "resp", response)
return "", errors.New("invalid response")
}
func (c *client) makeRequest(req *http.Request, response any) error {
log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != 200 {
return c.parseError(data)
}
return json.Unmarshal(data, response)
}
func (c *client) parseError(data []byte) error {
var e Error
err := json.Unmarshal(data, &e)
if err != nil {
return err
}
return fmt.Errorf("spotify error(%s): %s", e.Code, e.Message)
}
+131
View File
@@ -0,0 +1,131 @@
package spotify
import (
"bytes"
"context"
"io"
"net/http"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("client", func() {
var httpClient *fakeHttpClient
var client *client
BeforeEach(func() {
httpClient = &fakeHttpClient{}
client = newClient("SPOTIFY_ID", "SPOTIFY_SECRET", httpClient)
})
Describe("ArtistImages", func() {
It("returns artist images from a successful request", func() {
f, _ := os.Open("tests/fixtures/spotify.search.artist.json")
httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
})
artists, err := client.searchArtists(context.TODO(), "U2", 10)
Expect(err).To(BeNil())
Expect(artists).To(HaveLen(20))
Expect(artists[0].Popularity).To(Equal(82))
images := artists[0].Images
Expect(images).To(HaveLen(3))
Expect(images[0].Width).To(Equal(640))
Expect(images[1].Width).To(Equal(320))
Expect(images[2].Width).To(Equal(160))
})
It("fails if artist was not found", func() {
httpClient.mock("https://api.spotify.com/v1/search", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{
"artists" : {
"href" : "https://api.spotify.com/v1/search?query=dasdasdas%2Cdna&type=artist&offset=0&limit=20",
"items" : [ ], "limit" : 20, "next" : null, "offset" : 0, "previous" : null, "total" : 0
}}`)),
})
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
})
_, err := client.searchArtists(context.TODO(), "U2", 10)
Expect(err).To(MatchError(ErrNotFound))
})
It("fails if not able to authorize", func() {
f, _ := os.Open("tests/fixtures/spotify.search.artist.json")
httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
StatusCode: 400,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
})
_, err := client.searchArtists(context.TODO(), "U2", 10)
Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
})
})
Describe("authorize", func() {
It("returns an access_token on successful authorization", func() {
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
})
token, err := client.authorize(context.TODO())
Expect(err).To(BeNil())
Expect(token).To(Equal("NEW_ACCESS_TOKEN"))
auth := httpClient.lastRequest.Header.Get("Authorization")
Expect(auth).To(Equal("Basic U1BPVElGWV9JRDpTUE9USUZZX1NFQ1JFVA=="))
})
It("fails on unsuccessful authorization", func() {
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
StatusCode: 400,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
})
_, err := client.authorize(context.TODO())
Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
})
It("fails on invalid JSON response", func() {
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{NOT_VALID}`)),
})
_, err := client.authorize(context.TODO())
Expect(err).To(MatchError("invalid character 'N' looking for beginning of object key string"))
})
})
})
type fakeHttpClient struct {
responses map[string]*http.Response
lastRequest *http.Request
}
func (c *fakeHttpClient) mock(url string, response http.Response) {
if c.responses == nil {
c.responses = make(map[string]*http.Response)
}
c.responses[url] = &response
}
func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
c.lastRequest = req
u := req.URL
u.RawQuery = ""
if resp, ok := c.responses[u.String()]; ok {
return resp, nil
}
panic("URL not mocked: " + u.String())
}
+30
View File
@@ -0,0 +1,30 @@
package spotify
type SearchResults struct {
Artists ArtistsResult `json:"artists"`
}
type ArtistsResult struct {
HRef string `json:"href"`
Items []Artist `json:"items"`
}
type Artist struct {
Genres []string `json:"genres"`
HRef string `json:"href"`
ID string `json:"id"`
Popularity int `json:"popularity"`
Images []Image `json:"images"`
Name string `json:"name"`
}
type Image struct {
URL string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
}
type Error struct {
Code string `json:"error"`
Message string `json:"error_description"`
}
+48
View File
@@ -0,0 +1,48 @@
package spotify
import (
"encoding/json"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Responses", func() {
Describe("Search type=artist", func() {
It("parses the artist search result correctly ", func() {
var resp SearchResults
body, _ := os.ReadFile("tests/fixtures/spotify.search.artist.json")
err := json.Unmarshal(body, &resp)
Expect(err).To(BeNil())
Expect(resp.Artists.Items).To(HaveLen(20))
u2 := resp.Artists.Items[0]
Expect(u2.Name).To(Equal("U2"))
Expect(u2.Genres).To(ContainElements("irish rock", "permanent wave", "rock"))
Expect(u2.ID).To(Equal("51Blml2LZPmy7TTiAg47vQ"))
Expect(u2.HRef).To(Equal("https://api.spotify.com/v1/artists/51Blml2LZPmy7TTiAg47vQ"))
Expect(u2.Images[0].URL).To(Equal("https://i.scdn.co/image/e22d5c0c8139b8439440a69854ed66efae91112d"))
Expect(u2.Images[0].Width).To(Equal(640))
Expect(u2.Images[0].Height).To(Equal(640))
Expect(u2.Images[1].URL).To(Equal("https://i.scdn.co/image/40d6c5c14355cfc127b70da221233315497ec91d"))
Expect(u2.Images[1].Width).To(Equal(320))
Expect(u2.Images[1].Height).To(Equal(320))
Expect(u2.Images[2].URL).To(Equal("https://i.scdn.co/image/7293d6752ae8a64e34adee5086858e408185b534"))
Expect(u2.Images[2].Width).To(Equal(160))
Expect(u2.Images[2].Height).To(Equal(160))
})
})
Describe("Error", func() {
It("parses the error response correctly", func() {
var errorResp Error
body := []byte(`{"error":"invalid_client","error_description":"Invalid client"}`)
err := json.Unmarshal(body, &errorResp)
Expect(err).To(BeNil())
Expect(errorResp.Code).To(Equal("invalid_client"))
Expect(errorResp.Message).To(Equal("Invalid client"))
})
})
})
+96
View File
@@ -0,0 +1,96 @@
package spotify
import (
"context"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"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/cache"
"github.com/xrash/smetrics"
)
const spotifyAgentName = "spotify"
type spotifyAgent struct {
ds model.DataStore
id string
secret string
client *client
}
func spotifyConstructor(ds model.DataStore) agents.Interface {
if conf.Server.Spotify.ID == "" || conf.Server.Spotify.Secret == "" {
return nil
}
l := &spotifyAgent{
ds: ds,
id: conf.Server.Spotify.ID,
secret: conf.Server.Spotify.Secret,
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = newClient(l.id, l.secret, chc)
return l
}
func (s *spotifyAgent) AgentName() string {
return spotifyAgentName
}
func (s *spotifyAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
a, err := s.searchArtist(ctx, name)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Artist not found in Spotify", "artist", name)
} else {
log.Error(ctx, "Error calling Spotify", "artist", name, err)
}
return nil, err
}
var res []agents.ExternalImage
for _, img := range a.Images {
res = append(res, agents.ExternalImage{
URL: img.URL,
Size: img.Width,
})
}
return res, nil
}
func (s *spotifyAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
artists, err := s.client.searchArtists(ctx, name, 40)
if err != nil || len(artists) == 0 {
return nil, model.ErrNotFound
}
name = strings.ToLower(name)
// Sort results, prioritizing artists with images, with similar names and with high popularity, in this order
sort.Slice(artists, func(i, j int) bool {
ai := fmt.Sprintf("%-5t-%03d-%04d", len(artists[i].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[i].Name), 1, 1, 2), 1000-artists[i].Popularity)
aj := fmt.Sprintf("%-5t-%03d-%04d", len(artists[j].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[j].Name), 1, 1, 2), 1000-artists[j].Popularity)
return ai < aj
})
// If the first one has the same name, that's the one
if strings.ToLower(artists[0].Name) != name {
return nil, model.ErrNotFound
}
return &artists[0], err
}
func init() {
conf.AddHook(func() {
agents.Register(spotifyAgentName, spotifyConstructor)
})
}
@@ -1,4 +1,4 @@
package sonic_test
package spotify
import (
"testing"
@@ -9,9 +9,9 @@ import (
. "github.com/onsi/gomega"
)
func TestSonic(t *testing.T) {
func TestSpotify(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Sonic Suite")
RunSpecs(t, "Spotify Test Suite")
}
+274
View File
@@ -0,0 +1,274 @@
package taglib
import (
"io/fs"
"os"
"time"
"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"
)
type testFileInfo struct {
fs.FileInfo
}
func (t testFileInfo) BirthTime() time.Time {
if ts := times.Get(t.FileInfo); ts.HasBirthTime() {
return ts.BirthTime()
}
return t.FileInfo.ModTime()
}
var _ = Describe("Extractor", func() {
toP := func(name, sortName, mbid string) model.Participant {
return model.Participant{
Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid},
}
}
roles := []struct {
model.Role
model.ParticipantList
}{
{model.RoleComposer, model.ParticipantList{
toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"),
toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"),
}},
{model.RoleLyricist, model.ParticipantList{
toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"),
toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"),
}},
{model.RoleArranger, model.ParticipantList{
toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"),
toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"),
}},
{model.RoleConductor, model.ParticipantList{
toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"),
toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"),
}},
{model.RoleDirector, model.ParticipantList{
toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"),
toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"),
}},
{model.RoleEngineer, model.ParticipantList{
toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"),
toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"),
}},
{model.RoleProducer, model.ParticipantList{
toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"),
toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"),
}},
{model.RoleRemixer, model.ParticipantList{
toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"),
toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"),
}},
{model.RoleDJMixer, model.ParticipantList{
toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"),
toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"),
}},
{model.RoleMixer, model.ParticipantList{
toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"),
toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"),
}},
}
var e *extractor
parseTestFile := func(path string) *model.MediaFile {
mds, err := e.Parse(path)
Expect(err).ToNot(HaveOccurred())
info, ok := mds[path]
Expect(ok).To(BeTrue())
fileInfo, err := os.Stat(path)
Expect(err).ToNot(HaveOccurred())
info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info)
mf := metadata.ToMediaFile(1, "folderID")
return &mf
}
BeforeEach(func() {
e = &extractor{}
})
Describe("ReplayGain", func() {
DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) {
mf := parseTestFile("tests/fixtures/" + file)
Expect(mf.RGTrackGain).To(Equal(trackGain))
Expect(mf.RGTrackPeak).To(Equal(trackPeak))
Expect(mf.RGAlbumGain).To(Equal(albumGain))
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
},
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
)
})
Describe("lyrics", func() {
makeLyrics := func(code, secondLine string) model.Lyrics {
return model.Lyrics{
DisplayArtist: "",
DisplayTitle: "",
Lang: code,
Line: []model.Line{
{Start: gg.P(int64(0)), Value: "This is"},
{Start: gg.P(int64(2500)), Value: secondLine},
},
Offset: nil,
Synced: true,
}
}
It("should fetch both synced and unsynced lyrics in mixed flac", func() {
mf := parseTestFile("tests/fixtures/mixed-lyrics.flac")
lyrics, err := mf.StructuredLyrics()
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(2))
Expect(lyrics[0].Synced).To(BeTrue())
Expect(lyrics[1].Synced).To(BeFalse())
})
It("should handle mp3 with uslt and sylt", func() {
mf := parseTestFile("tests/fixtures/test.mp3")
lyrics, err := mf.StructuredLyrics()
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(4))
engSylt := makeLyrics("eng", "English SYLT")
engUslt := makeLyrics("eng", "English")
unsSylt := makeLyrics("xxx", "unspecified SYLT")
unsUslt := makeLyrics("xxx", "unspecified")
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
})
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
mf := parseTestFile("tests/fixtures/" + file)
lyrics, err := mf.StructuredLyrics()
Expect(err).To(Not(HaveOccurred()))
Expect(lyrics).To(HaveLen(2))
unspec := makeLyrics("xxx", "unspecified")
eng := makeLyrics("xxx", "English")
if isId3 {
eng.Lang = "eng"
}
Expect(lyrics).To(Or(
Equal(model.LyricList{unspec, eng}),
Equal(model.LyricList{eng, unspec})))
},
Entry("flac", "test.flac", false),
Entry("m4a", "test.m4a", false),
Entry("ogg", "test.ogg", false),
Entry("wma", "test.wma", false),
Entry("wv", "test.wv", false),
Entry("wav", "test.wav", true),
Entry("aiff", "test.aiff", true),
)
})
Describe("Participants", func() {
DescribeTable("test tags consistent across formats", func(format string) {
mf := parseTestFile("tests/fixtures/test." + format)
for _, data := range roles {
role := data.Role
artists := data.ParticipantList
actual := mf.Participants[role]
Expect(actual).To(HaveLen(len(artists)))
for i := range artists {
actualArtist := actual[i]
expectedArtist := artists[i]
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName))
Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID))
}
}
if format != "m4a" {
performers := mf.Participants[model.RolePerformer]
Expect(performers).To(HaveLen(8))
rules := map[string][]string{
"pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"},
"pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""},
"pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"},
"pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"},
"pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"},
}
for name, rule := range rules {
mbid := rule[0]
for i := 1; i < len(rule); i++ {
found := false
for _, mapped := range performers {
if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] {
found = true
break
}
}
Expect(found).To(BeTrue(), "Could not find matching artist")
}
}
}
},
Entry("FLAC format", "flac"),
Entry("M4a format", "m4a"),
Entry("OGG format", "ogg"),
Entry("WV format", "wv"),
Entry("MP3 format", "mp3"),
Entry("WAV format", "wav"),
Entry("AIFF format", "aiff"),
)
It("should parse wma", func() {
mf := parseTestFile("tests/fixtures/test.wma")
for _, data := range roles {
role := data.Role
artists := data.ParticipantList
actual := mf.Participants[role]
// WMA has no Arranger role
if role == model.RoleArranger {
Expect(actual).To(HaveLen(0))
continue
}
Expect(actual).To(HaveLen(len(artists)), role.String())
// For some bizarre reason, the order is inverted. We also don't get
// sort names or MBIDs
for i := range artists {
idx := len(artists) - 1 - i
actualArtist := actual[i]
expectedArtist := artists[idx]
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
}
}
})
})
})
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package taglib
import "C"
func getFilename(s string) *C.char {
return C.CString(s)
}
+96
View File
@@ -0,0 +1,96 @@
//go:build windows
package taglib
// From https://github.com/orofarne/gowchar
/*
#include <wchar.h>
const size_t SIZEOF_WCHAR_T = sizeof(wchar_t);
void gowchar_set (wchar_t *arr, int pos, wchar_t val)
{
arr[pos] = val;
}
wchar_t gowchar_get (wchar_t *arr, int pos)
{
return arr[pos];
}
*/
import "C"
import (
"fmt"
"unicode/utf16"
"unicode/utf8"
)
var SIZEOF_WCHAR_T C.size_t = C.size_t(C.SIZEOF_WCHAR_T)
func getFilename(s string) *C.wchar_t {
wstr, _ := StringToWcharT(s)
return wstr
}
func StringToWcharT(s string) (*C.wchar_t, C.size_t) {
switch SIZEOF_WCHAR_T {
case 2:
return stringToWchar2(s) // Windows
case 4:
return stringToWchar4(s) // Unix
default:
panic(fmt.Sprintf("Invalid sizeof(wchar_t) = %v", SIZEOF_WCHAR_T))
}
panic("?!!")
}
// Windows
func stringToWchar2(s string) (*C.wchar_t, C.size_t) {
var slen int
s1 := s
for len(s1) > 0 {
r, size := utf8.DecodeRuneInString(s1)
if er, _ := utf16.EncodeRune(r); er == '\uFFFD' {
slen += 1
} else {
slen += 2
}
s1 = s1[size:]
}
slen++ // \0
res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
var i int
for len(s) > 0 {
r, size := utf8.DecodeRuneInString(s)
if r1, r2 := utf16.EncodeRune(r); r1 != '\uFFFD' {
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r1))
i++
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r2))
i++
} else {
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
i++
}
s = s[size:]
}
C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
return (*C.wchar_t)(res), C.size_t(slen)
}
// Unix
func stringToWchar4(s string) (*C.wchar_t, C.size_t) {
slen := utf8.RuneCountInString(s)
slen++ // \0
res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
var i int
for len(s) > 0 {
r, size := utf8.DecodeRuneInString(s)
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
s = s[size:]
i++
}
C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
return (*C.wchar_t)(res), C.size_t(slen)
}
+178
View File
@@ -0,0 +1,178 @@
package taglib
import (
"io/fs"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
)
type extractor struct {
baseDir string
}
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
results := make(map[string]metadata.Info)
for _, path := range files {
props, err := e.extractMetadata(path)
if err != nil {
continue
}
results[path] = *props
}
return results, nil
}
func (e extractor) Version() string {
return Version()
}
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
fullPath := filepath.Join(e.baseDir, filePath)
tags, err := Read(fullPath)
if err != nil {
log.Warn("extractor: Error reading metadata from file. Skipping", "filePath", fullPath, err)
return nil, err
}
// Parse audio properties
ap := metadata.AudioProperties{}
ap.BitRate = parseProp(tags, "__bitrate")
ap.Channels = parseProp(tags, "__channels")
ap.SampleRate = parseProp(tags, "__samplerate")
ap.BitDepth = parseProp(tags, "__bitspersample")
length := parseProp(tags, "__lengthinmilliseconds")
ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10)
// Extract basic tags
parseBasicTag(tags, "__title", "title")
parseBasicTag(tags, "__artist", "artist")
parseBasicTag(tags, "__album", "album")
parseBasicTag(tags, "__comment", "comment")
parseBasicTag(tags, "__genre", "genre")
parseBasicTag(tags, "__year", "year")
parseBasicTag(tags, "__track", "tracknumber")
// Parse track/disc totals
parseTuple := func(prop string) {
tagName := prop + "number"
tagTotal := prop + "total"
if value, ok := tags[tagName]; ok && len(value) > 0 {
parts := strings.Split(value[0], "/")
tags[tagName] = []string{parts[0]}
if len(parts) == 2 {
tags[tagTotal] = []string{parts[1]}
}
}
}
parseTuple("track")
parseTuple("disc")
// Adjust some ID3 tags
parseLyrics(tags)
parseTIPL(tags)
delete(tags, "tmcl") // TMCL is already parsed by TagLib
return &metadata.Info{
Tags: tags,
AudioProperties: ap,
HasPicture: tags["has_picture"] != nil && len(tags["has_picture"]) > 0 && tags["has_picture"][0] == "true",
}, nil
}
// parseLyrics make sure lyrics tags have language
func parseLyrics(tags map[string][]string) {
lyrics := tags["lyrics"]
if len(lyrics) > 0 {
tags["lyrics:xxx"] = lyrics
delete(tags, "lyrics")
}
}
// These are the only roles we support, based on Picard's tag map:
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
var tiplMapping = map[string]string{
"arranger": "arranger",
"engineer": "engineer",
"producer": "producer",
"mix": "mixer",
"DJ-mix": "djmixer",
}
// parseProp parses a property from the tags map and sets it to the target integer.
// It also deletes the property from the tags map after parsing.
func parseProp(tags map[string][]string, prop string) int {
if value, ok := tags[prop]; ok && len(value) > 0 {
v, _ := strconv.Atoi(value[0])
delete(tags, prop)
return v
}
return 0
}
// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map.
// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.),
// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag.
func parseBasicTag(tags map[string][]string, basicName string, tagName string) {
basicValue := tags[basicName]
if len(basicValue) == 0 {
return
}
delete(tags, basicName)
if len(tags[tagName]) == 0 {
tags[tagName] = basicValue
}
}
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
//
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
//
// and breaks it down into a map of roles and names, e.g.:
//
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
func parseTIPL(tags map[string][]string) {
tipl := tags["tipl"]
if len(tipl) == 0 {
return
}
addRole := func(currentRole string, currentValue []string) {
if currentRole != "" && len(currentValue) > 0 {
role := tiplMapping[currentRole]
tags[role] = append(tags[role], strings.Join(currentValue, " "))
}
}
var currentRole string
var currentValue []string
for _, part := range strings.Split(tipl[0], " ") {
if _, ok := tiplMapping[part]; ok {
addRole(currentRole, currentValue)
currentRole = part
currentValue = nil
continue
}
currentValue = append(currentValue, part)
}
addRole(currentRole, currentValue)
delete(tags, "tipl")
}
var _ local.Extractor = (*extractor)(nil)
func init() {
local.RegisterExtractor("legacy-taglib", func(_ fs.FS, baseDir string) local.Extractor {
// ignores fs, as taglib extractor only works with local files
return &extractor{baseDir}
})
conf.AddHook(func() {
log.Debug("TagLib version", "version", Version())
})
}
@@ -1,4 +1,4 @@
package dto
package taglib
import (
"testing"
@@ -9,9 +9,9 @@ import (
. "github.com/onsi/gomega"
)
func TestDto(t *testing.T) {
tests.Init(t, false)
func TestTagLib(t *testing.T) {
tests.Init(t, true)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Jellyfin DTO Suite")
RunSpecs(t, "TagLib Suite")
}
+295
View File
@@ -0,0 +1,295 @@
package taglib
import (
"io/fs"
"os"
"strings"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Extractor", func() {
var e *extractor
BeforeEach(func() {
e = &extractor{}
})
Describe("Parse", func() {
It("correctly parses metadata from all files in folder", func() {
mds, err := e.Parse(
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
// Test MP3
m := mds["tests/fixtures/test.mp3"]
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"}))
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.HasPicture).To(BeTrue())
Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s"))
Expect(m.AudioProperties.BitRate).To(Equal(192))
Expect(m.AudioProperties.Channels).To(Equal(2))
Expect(m.AudioProperties.SampleRate).To(Equal(44100))
Expect(m.Tags).To(Or(
HaveKeyWithValue("compilation", []string{"1"}),
HaveKeyWithValue("tcmp", []string{"1"})),
)
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"}))
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"}))
Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"}))
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}))
Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"}))
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
Expect(m.Tags).ToNot(HaveKey("lyrics"))
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English SYLT\n",
"[00:00.00]This is\n[00:02.50]English",
}), HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English",
"[00:00.00]This is\n[00:02.50]English SYLT\n",
})))
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
"[00:00.00]This is\n[00:02.50]unspecified",
}), HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
})))
// Test OGG
m = mds["tests/fixtures/test.ogg"]
Expect(err).To(BeNil())
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
// TagLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.HasPicture).To(BeTrue())
})
DescribeTable("Format-Specific tests",
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
file = "tests/fixtures/" + file
mds, err := e.Parse(file)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(1))
m := mds[file]
Expect(m.HasPicture).To(Equal(image))
Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
Expect(m.AudioProperties.Channels).To(Equal(channels))
Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
Expect(m.AudioProperties.BitDepth).To(Equal(bitdepth))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_track_gain", []string{trackGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}),
))
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"}))
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(Or(
HaveKeyWithValue("tracknumber", []string{"3"}),
HaveKeyWithValue("tracknumber", []string{"3/10"}),
))
if !strings.HasSuffix(file, "test.wma") {
// TODO Not sure why this is not working for WMA
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
}
Expect(m.Tags).To(Or(
HaveKeyWithValue("discnumber", []string{"1"}),
HaveKeyWithValue("discnumber", []string{"1/2"}),
))
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
// WMA does not have a "compilation" tag, but "wm/iscompilation"
Expect(m.Tags).To(Or(
HaveKeyWithValue("compilation", []string{"1"}),
HaveKeyWithValue("wm/iscompilation", []string{"1"})),
)
if id3Lyrics {
Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English",
}))
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
}))
} else {
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
"[00:00.00]This is\n[00:02.50]English",
}))
}
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
},
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true),
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true),
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true),
)
// Skip these tests when running as root
Context("Access Forbidden", func() {
var accessForbiddenFile string
var RegularUserContext = XContext
var isRegularUser = os.Getuid() != 0
if isRegularUser {
RegularUserContext = Context
}
// Only run permission tests if we are not root
RegularUserContext("when run without root privileges", func() {
BeforeEach(func() {
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
Expect(f.Close()).To(Succeed())
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
})
})
It("correctly handle unreadable file due to insufficient read permission", func() {
_, err := e.extractMetadata(accessForbiddenFile)
Expect(err).To(MatchError(os.ErrPermission))
})
It("skips the file if it cannot be read", func() {
files := []string{
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
accessForbiddenFile,
}
mds, err := e.Parse(files...)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
Expect(mds).ToNot(HaveKey(accessForbiddenFile))
})
})
})
})
Describe("Error Checking", func() {
It("returns a generic ErrPath if file does not exist", func() {
testFilePath := "tests/fixtures/NON_EXISTENT.ogg"
_, err := e.extractMetadata(testFilePath)
Expect(err).To(MatchError(fs.ErrNotExist))
})
It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() {
// File has an empty TDAT frame
md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"}))
})
})
Describe("parseTIPL", func() {
var tags map[string][]string
BeforeEach(func() {
tags = make(map[string][]string)
})
Context("when the TIPL string is populated", func() {
It("correctly parses roles and names", func() {
tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"}
parseTIPL(tags)
Expect(tags["arranger"]).To(ConsistOf("Andrew Powell"))
Expect(tags["engineer"]).To(ConsistOf("Chris Blair"))
Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe"))
})
It("handles multiple names for a single role", func() {
tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"}
parseTIPL(tags)
Expect(tags["producer"]).To(ConsistOf("Eric Woolfson"))
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
})
It("discards roles without names", func() {
tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"}
parseTIPL(tags)
Expect(tags).ToNot(HaveKey("producer"))
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
})
})
Context("when the TIPL string is empty", func() {
It("does nothing", func() {
tags["tipl"] = []string{""}
parseTIPL(tags)
Expect(tags).To(BeEmpty())
})
})
Context("when the TIPL is not present", func() {
It("does nothing", func() {
parseTIPL(tags)
Expect(tags).To(BeEmpty())
})
})
})
})
+299
View File
@@ -0,0 +1,299 @@
#include <stdlib.h>
#include <string.h>
#define TAGLIB_STATIC
#include <apeproperties.h>
#include <apetag.h>
#include <aifffile.h>
#include <asffile.h>
#include <dsffile.h>
#include <fileref.h>
#include <flacfile.h>
#include <id3v2tag.h>
#include <unsynchronizedlyricsframe.h>
#include <synchronizedlyricsframe.h>
#include <mp4file.h>
#include <mpegfile.h>
#include <opusfile.h>
#include <tpropertymap.h>
#include <vorbisfile.h>
#include <wavfile.h>
#include <wavfile.h>
#include <wavpackfile.h>
#include "taglib_wrapper.h"
char has_cover(const TagLib::FileRef f);
static char TAGLIB_VERSION[16];
char* taglib_version() {
snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION);
return (char *)TAGLIB_VERSION;
}
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
TagLib::FileRef f(filename, true, TagLib::AudioProperties::Fast);
if (f.isNull()) {
return TAGLIB_ERR_PARSE;
}
if (!f.audioProperties()) {
return TAGLIB_ERR_AUDIO_PROPS;
}
// Add audio properties to the tags
const TagLib::AudioProperties *props(f.audioProperties());
goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds());
goPutInt(id, (char *)"__bitrate", props->bitrate());
goPutInt(id, (char *)"__channels", props->channels());
goPutInt(id, (char *)"__samplerate", props->sampleRate());
// Extract bits per sample for supported formats
int bitsPerSample = 0;
if (const auto* apeProperties{ dynamic_cast<const TagLib::APE::Properties*>(props) })
bitsPerSample = apeProperties->bitsPerSample();
else if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) })
bitsPerSample = asfProperties->bitsPerSample();
else if (const auto* flacProperties{ dynamic_cast<const TagLib::FLAC::Properties*>(props) })
bitsPerSample = flacProperties->bitsPerSample();
else if (const auto* mp4Properties{ dynamic_cast<const TagLib::MP4::Properties*>(props) })
bitsPerSample = mp4Properties->bitsPerSample();
else if (const auto* wavePackProperties{ dynamic_cast<const TagLib::WavPack::Properties*>(props) })
bitsPerSample = wavePackProperties->bitsPerSample();
else if (const auto* aiffProperties{ dynamic_cast<const TagLib::RIFF::AIFF::Properties*>(props) })
bitsPerSample = aiffProperties->bitsPerSample();
else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(props) })
bitsPerSample = wavProperties->bitsPerSample();
else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(props) })
bitsPerSample = dsfProperties->bitsPerSample();
if (bitsPerSample > 0) {
goPutInt(id, (char *)"__bitspersample", bitsPerSample);
}
// Send all properties to the Go map
TagLib::PropertyMap tags = f.file()->properties();
// Make sure at least the basic properties are extracted
TagLib::Tag *basic = f.file()->tag();
if (!basic->isEmpty()) {
if (!basic->title().isEmpty()) {
tags.insert("__title", basic->title());
}
if (!basic->artist().isEmpty()) {
tags.insert("__artist", basic->artist());
}
if (!basic->album().isEmpty()) {
tags.insert("__album", basic->album());
}
if (!basic->comment().isEmpty()) {
tags.insert("__comment", basic->comment());
}
if (!basic->genre().isEmpty()) {
tags.insert("__genre", basic->genre());
}
if (basic->year() > 0) {
tags.insert("__year", TagLib::String::number(basic->year()));
}
if (basic->track() > 0) {
tags.insert("__track", TagLib::String::number(basic->track()));
}
}
TagLib::ID3v2::Tag *id3Tags = NULL;
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file()));
if (mp3File != NULL) {
id3Tags = mp3File->ID3v2Tag();
}
if (id3Tags == NULL) {
TagLib::RIFF::WAV::File *wavFile(dynamic_cast<TagLib::RIFF::WAV::File *>(f.file()));
if (wavFile != NULL && wavFile->hasID3v2Tag()) {
id3Tags = wavFile->ID3v2Tag();
}
}
if (id3Tags == NULL) {
TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file()));
if (aiffFile && aiffFile->hasID3v2Tag()) {
id3Tags = aiffFile->tag();
}
}
// Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems
// with many players, so they will not be parsed
if (id3Tags != NULL) {
const auto &frames = id3Tags->frameListMap();
for (const auto &kv: frames) {
if (kv.first == "USLT") {
for (const auto &tag: kv.second) {
TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(tag);
if (frame == NULL) continue;
tags.erase("LYRICS");
const auto bv = frame->language();
char language[4] = {'x', 'x', 'x', '\0'};
if (bv.size() == 3) {
strncpy(language, bv.data(), 3);
}
char *val = const_cast<char*>(frame->text().toCString(true));
goPutLyrics(id, language, val);
}
} else if (kv.first == "SYLT") {
for (const auto &tag: kv.second) {
TagLib::ID3v2::SynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::SynchronizedLyricsFrame *>(tag);
if (frame == NULL) continue;
const auto bv = frame->language();
char language[4] = {'x', 'x', 'x', '\0'};
if (bv.size() == 3) {
strncpy(language, bv.data(), 3);
}
const auto format = frame->timestampFormat();
if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) {
for (const auto &line: frame->synchedText()) {
char *text = const_cast<char*>(line.text.toCString(true));
goPutLyricLine(id, language, text, line.time);
}
} else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) {
const int sampleRate = props->sampleRate();
if (sampleRate != 0) {
for (const auto &line: frame->synchedText()) {
const int timeInMs = (line.time * 1000) / sampleRate;
char *text = const_cast<char*>(line.text.toCString(true));
goPutLyricLine(id, language, text, timeInMs);
}
}
}
}
} else if (kv.first == "TIPL"){
if (!kv.second.isEmpty()) {
tags.insert(kv.first, kv.second.front()->toString());
}
}
}
}
// M4A may have some iTunes specific tags not captured by the PropertyMap interface
TagLib::MP4::File *m4afile(dynamic_cast<TagLib::MP4::File *>(f.file()));
if (m4afile != NULL) {
const auto itemListMap = m4afile->tag()->itemMap();
for (const auto item: itemListMap) {
char *key = const_cast<char*>(item.first.toCString(true));
for (const auto value: item.second.toStringList()) {
char *val = const_cast<char*>(value.toCString(true));
goPutM4AStr(id, key, val);
}
}
}
// WMA/ASF files may have additional tags not captured by the PropertyMap interface
TagLib::ASF::File *asfFile(dynamic_cast<TagLib::ASF::File *>(f.file()));
if (asfFile != NULL) {
const TagLib::ASF::Tag *asfTags{asfFile->tag()};
const auto itemListMap = asfTags->attributeListMap();
for (const auto item : itemListMap) {
char *key = const_cast<char*>(item.first.toCString(true));
for (auto j = item.second.begin();
j != item.second.end(); ++j) {
char *val = const_cast<char*>(j->toString().toCString(true));
goPutStr(id, key, val);
}
}
}
// Send all collected tags to the Go map
for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end();
++i) {
char *key = const_cast<char*>(i->first.toCString(true));
for (TagLib::StringList::ConstIterator j = i->second.begin();
j != i->second.end(); ++j) {
char *val = const_cast<char*>((*j).toCString(true));
goPutStr(id, key, val);
}
}
// Cover art has to be handled separately
if (has_cover(f)) {
goPutStr(id, (char *)"has_picture", (char *)"true");
}
return 0;
}
// Detect if the file has cover art. Returns 1 if the file has cover art, 0 otherwise.
char has_cover(const TagLib::FileRef f) {
char hasCover = 0;
// ----- MP3
if (TagLib::MPEG::File * mp3File{dynamic_cast<TagLib::MPEG::File *>(f.file())}) {
if (mp3File->ID3v2Tag()) {
const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()};
hasCover = !frameListMap["APIC"].isEmpty();
}
}
// ----- FLAC
else if (TagLib::FLAC::File * flacFile{dynamic_cast<TagLib::FLAC::File *>(f.file())}) {
hasCover = !flacFile->pictureList().isEmpty();
}
// ----- MP4
else if (TagLib::MP4::File * mp4File{dynamic_cast<TagLib::MP4::File *>(f.file())}) {
auto &coverItem{mp4File->tag()->itemMap()["covr"]};
TagLib::MP4::CoverArtList coverArtList{coverItem.toCoverArtList()};
hasCover = !coverArtList.isEmpty();
}
// ----- Ogg
else if (TagLib::Ogg::Vorbis::File * vorbisFile{dynamic_cast<TagLib::Ogg::Vorbis::File *>(f.file())}) {
hasCover = !vorbisFile->tag()->pictureList().isEmpty();
}
// ----- Opus
else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) {
hasCover = !opusFile->tag()->pictureList().isEmpty();
}
// ----- WAV
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(f.file()) }) {
if (wavFile->hasID3v2Tag()) {
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
hasCover = !frameListMap["APIC"].isEmpty();
}
}
// ----- AIFF
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file())}) {
if (aiffFile->hasID3v2Tag()) {
const auto& frameListMap{ aiffFile->tag()->frameListMap() };
hasCover = !frameListMap["APIC"].isEmpty();
}
}
// ----- WMA
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
const TagLib::ASF::Tag *tag{ asfFile->tag() };
hasCover = tag && tag->attributeListMap().contains("WM/Picture");
}
// ----- DSF
else if (TagLib::DSF::File * dsffile{ dynamic_cast<TagLib::DSF::File *>(f.file())}) {
const TagLib::ID3v2::Tag *tag { dsffile->tag() };
hasCover = tag && !tag->frameListMap()["APIC"].isEmpty();
}
// ----- WAVPAK (APE tag)
else if (TagLib::WavPack::File * wvFile{dynamic_cast<TagLib::WavPack::File *>(f.file())}) {
if (wvFile->hasAPETag()) {
// This is the particular string that Picard uses
hasCover = !wvFile->APETag()->itemListMap()["COVER ART (FRONT)"].isEmpty();
}
}
return hasCover;
}
+157
View File
@@ -0,0 +1,157 @@
package taglib
/*
#cgo !windows pkg-config: --define-prefix taglib
#cgo windows pkg-config: taglib
#cgo illumos LDFLAGS: -lstdc++ -lsendfile
#cgo linux darwin CXXFLAGS: -std=c++11
#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "taglib_wrapper.h"
*/
import "C"
import (
"encoding/json"
"fmt"
"os"
"runtime/debug"
"strconv"
"strings"
"sync"
"sync/atomic"
"unsafe"
"github.com/navidrome/navidrome/log"
)
const iTunesKeyPrefix = "----:com.apple.itunes:"
func Version() string {
return C.GoString(C.taglib_version())
}
func Read(filename string) (tags map[string][]string, err error) {
// Do not crash on failures in the C code/library
debug.SetPanicOnFault(true)
defer func() {
if r := recover(); r != nil {
log.Error("extractor: recovered from panic when reading tags", "file", filename, "error", r)
err = fmt.Errorf("extractor: recovered from panic: %s", r)
}
}()
fp := getFilename(filename)
defer C.free(unsafe.Pointer(fp))
id, m, release := newMap()
defer release()
log.Trace("extractor: reading tags", "filename", filename, "map_id", id)
res := C.taglib_read(fp, C.ulong(id))
switch res {
case C.TAGLIB_ERR_PARSE:
// Check additional case whether the file is unreadable due to permission
file, fileErr := os.OpenFile(filename, os.O_RDONLY, 0600)
defer file.Close()
if os.IsPermission(fileErr) {
return nil, fmt.Errorf("navidrome does not have permission: %w", fileErr)
} else if fileErr != nil {
return nil, fmt.Errorf("cannot parse file media file: %w", fileErr)
} else {
return nil, fmt.Errorf("cannot parse file media file")
}
case C.TAGLIB_ERR_AUDIO_PROPS:
return nil, fmt.Errorf("can't get audio properties from file")
}
if log.IsGreaterOrEqualTo(log.LevelDebug) {
j, _ := json.Marshal(m)
log.Trace("extractor: read tags", "tags", string(j), "filename", filename, "id", id)
} else {
log.Trace("extractor: read tags", "tags", m, "filename", filename, "id", id)
}
return m, nil
}
type tagMap map[string][]string
var allMaps sync.Map
var mapsNextID atomic.Uint32
func newMap() (uint32, tagMap, func()) {
id := mapsNextID.Add(1)
m := tagMap{}
allMaps.Store(id, m)
return id, m, func() {
allMaps.Delete(id)
}
}
func doPutTag(id C.ulong, key string, val *C.char) {
if key == "" {
return
}
r, _ := allMaps.Load(uint32(id))
m := r.(tagMap)
k := strings.ToLower(key)
v := strings.TrimSpace(C.GoString(val))
m[k] = append(m[k], v)
}
//export goPutM4AStr
func goPutM4AStr(id C.ulong, key *C.char, val *C.char) {
k := C.GoString(key)
// Special for M4A, do not catch keys that have no actual name
k = strings.TrimPrefix(k, iTunesKeyPrefix)
doPutTag(id, k, val)
}
//export goPutStr
func goPutStr(id C.ulong, key *C.char, val *C.char) {
doPutTag(id, C.GoString(key), val)
}
//export goPutInt
func goPutInt(id C.ulong, key *C.char, val C.int) {
valStr := strconv.Itoa(int(val))
vp := C.CString(valStr)
defer C.free(unsafe.Pointer(vp))
goPutStr(id, key, vp)
}
//export goPutLyrics
func goPutLyrics(id C.ulong, lang *C.char, val *C.char) {
doPutTag(id, "lyrics:"+C.GoString(lang), val)
}
//export goPutLyricLine
func goPutLyricLine(id C.ulong, lang *C.char, text *C.char, time C.int) {
language := C.GoString(lang)
line := C.GoString(text)
timeGo := int64(time)
ms := timeGo % 1000
timeGo /= 1000
sec := timeGo % 60
timeGo /= 60
minimum := timeGo % 60
formattedLine := fmt.Sprintf("[%02d:%02d.%02d]%s\n", minimum, sec, ms/10, line)
key := "lyrics:" + language
r, _ := allMaps.Load(uint32(id))
m := r.(tagMap)
k := strings.ToLower(key)
existing, ok := m[k]
if ok {
existing[0] += formattedLine
} else {
m[k] = []string{formattedLine}
}
}
+24
View File
@@ -0,0 +1,24 @@
#define TAGLIB_ERR_PARSE -1
#define TAGLIB_ERR_AUDIO_PROPS -2
#ifdef __cplusplus
extern "C" {
#endif
#ifdef WIN32
#define FILENAME_CHAR_T wchar_t
#else
#define FILENAME_CHAR_T char
#endif
extern void goPutM4AStr(unsigned long id, char *key, char *val);
extern void goPutStr(unsigned long id, char *key, char *val);
extern void goPutInt(unsigned long id, char *key, int val);
extern void goPutLyrics(unsigned long id, char *lang, char *val);
extern void goPutLyricLine(unsigned long id, char *lang, char *text, int time);
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id);
char* taglib_version();
#ifdef __cplusplus
}
#endif
+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 {
+27 -197
View File
@@ -7,19 +7,11 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/ioutils"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
"github.com/spf13/cobra"
)
@@ -28,7 +20,6 @@ var (
outputFile string
userID string
outputFormat string
syncFlag bool
)
type displayPlaylist struct {
@@ -50,15 +41,6 @@ func init() {
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
plsCmd.AddCommand(listCommand)
exportCommand.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID")
exportCommand.Flags().StringVarP(&outputFile, "output", "o", "", "output directory")
exportCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
plsCmd.AddCommand(exportCommand)
importCommand.Flags().StringVarP(&userID, "user", "u", "", "owner username or ID (default: first admin)")
importCommand.Flags().BoolVar(&syncFlag, "sync", false, "mark imported playlists as synced")
plsCmd.AddCommand(importCommand)
}
var (
@@ -78,165 +60,72 @@ var (
runList(cmd.Context())
},
}
exportCommand = &cobra.Command{
Use: "export",
Short: "Export playlists to M3U files",
Long: "Export one or more Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
runExport(cmd.Context())
},
}
importCommand = &cobra.Command{
Use: "import [files...]",
Short: "Import M3U playlists",
Long: "Import one or more M3U files as Navidrome playlists",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runImport(cmd.Context(), args)
},
}
)
func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.Playlists {
options := model.QueryOptions{Sort: sort}
if userID != "" {
user, err := getUser(ctx, userID, ds)
if err != nil {
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
options.Filters = squirrel.Eq{"owner_id": user.ID}
}
pls, err := ds.Playlist(ctx).GetAll(options)
if err != nil {
log.Fatal(ctx, "Failed to retrieve playlists", err)
}
return pls
}
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false)
func runExporter(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
log.Fatal("Error retrieving playlist", "name", playlistID, err)
}
if errors.Is(err, model.ErrNotFound) {
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}})
if err != nil {
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
log.Fatal("Error retrieving playlist", "name", playlistID, err)
}
if len(playlists) > 0 {
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
if err != nil {
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
log.Fatal("Error retrieving playlist", "name", playlistID, err)
}
}
}
if playlist == nil {
log.Fatal("Playlist not found", "name", nameOrID)
log.Fatal("Playlist not found", "name", playlistID)
}
return playlist
}
func runExporter(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
playlist := findPlaylist(ctx, ds, playlistID)
pls := playlist.ToM3U8()
if outputFile == "-" || outputFile == "" {
println(pls)
return
}
err := os.WriteFile(outputFile, []byte(pls), 0600)
err = os.WriteFile(outputFile, []byte(pls), 0600)
if err != nil {
log.Fatal("Error writing to the output file", "file", outputFile, err)
}
}
func runExport(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
if playlistID != "" && outputFile == "" {
playlist := findPlaylist(ctx, ds, playlistID)
println(playlist.ToM3U8())
return
}
if outputFile == "" {
log.Fatal("Output directory (-o) is required for bulk export or when filtering by user")
}
info, err := os.Stat(outputFile)
if err != nil || !info.IsDir() {
log.Fatal("Output path must be an existing directory", "path", outputFile)
}
if playlistID != "" {
pls := findPlaylist(ctx, ds, playlistID)
filename := str.SanitizeFilename(pls.Name) + ".m3u"
path := filepath.Join(outputFile, filename)
err := os.WriteFile(path, []byte(pls.ToM3U8()), 0600)
if err != nil {
log.Fatal("Error writing playlist", "file", path, err)
}
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
return
}
allPls := fetchPlaylists(ctx, ds, "name")
nameCounts := make(map[string]int)
for _, pls := range allPls {
nameCounts[str.SanitizeFilename(pls.Name)]++
}
exported := 0
for _, pls := range allPls {
plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
if err != nil {
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
continue
}
sanitized := str.SanitizeFilename(pls.Name)
filename := sanitized + ".m3u"
if nameCounts[sanitized] > 1 {
shortID := pls.ID
if len(shortID) > 6 {
shortID = shortID[:6]
}
filename = sanitized + "_" + shortID + ".m3u"
}
path := filepath.Join(outputFile, filename)
err = os.WriteFile(path, []byte(plsWithTracks.ToM3U8()), 0600)
if err != nil {
log.Error("Error writing playlist", "file", path, err)
continue
}
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
exported++
}
fmt.Printf("\nExported %d playlists to %s\n", exported, outputFile)
}
func runList(ctx context.Context) {
if outputFormat != "csv" && outputFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
}
ds, ctx := getAdminContext(ctx)
allPls := fetchPlaylists(ctx, ds, "owner_name")
options := model.QueryOptions{Sort: "owner_name"}
if userID != "" {
user, err := getUser(ctx, userID, ds)
if err != nil {
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
options.Filters = squirrel.Eq{"owner_id": user.ID}
}
playlists, err := ds.Playlist(ctx).GetAll(options)
if err != nil {
log.Fatal(ctx, "Failed to retrieve playlists", err)
}
if outputFormat == "csv" {
w := csv.NewWriter(os.Stdout)
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
for _, playlist := range allPls {
for _, playlist := range playlists {
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
}
w.Flush()
} else {
display := make(displayPlaylists, len(allPls))
for idx, playlist := range allPls {
display := make(displayPlaylists, len(playlists))
for idx, playlist := range playlists {
display[idx].Id = playlist.ID
display[idx].Name = playlist.Name
display[idx].OwnerId = playlist.OwnerID
@@ -248,62 +137,3 @@ func runList(ctx context.Context) {
fmt.Printf("%s\n", j)
}
}
func runImport(ctx context.Context, files []string) {
ds, ctx := getAdminContext(ctx)
if userID != "" {
user, err := getUser(ctx, userID, ds)
if err != nil {
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
ctx = request.WithUser(ctx, *user)
}
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
for _, file := range files {
absPath, err := filepath.Abs(file)
if err != nil {
log.Error("Error resolving path", "file", file, err)
fmt.Fprintf(os.Stderr, "Error: could not resolve path %s: %v\n", file, err)
continue
}
totalLines := countM3UTrackLines(absPath)
imported, err := pls.ImportFile(ctx, absPath, syncFlag)
if err != nil {
log.Error("Error importing playlist", "file", absPath, err)
fmt.Fprintf(os.Stderr, "Error importing %s: %v\n", file, err)
continue
}
matched := len(imported.Tracks)
if totalLines > 0 {
notFound := totalLines - matched
fmt.Printf("Imported \"%s\" — %d/%d tracks matched (%d not found)\n", imported.Name, matched, totalLines, notFound)
} else {
fmt.Printf("Imported \"%s\" — %d tracks\n", imported.Name, matched)
}
}
}
func countM3UTrackLines(path string) int {
file, err := os.Open(path)
if err != nil {
return 0
}
defer file.Close()
count := 0
reader := ioutils.UTF8Reader(file)
for line := range slice.LinesFrom(reader) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
count++
}
return count
}
-558
View File
@@ -1,558 +0,0 @@
package cmd
import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"text/tabwriter"
"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 := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0)
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))
})
})
+10 -78
View File
@@ -11,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"
@@ -28,6 +27,8 @@ import (
_ "github.com/navidrome/navidrome/adapters/gotaglib"
_ "github.com/navidrome/navidrome/adapters/lastfm"
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
_ "github.com/navidrome/navidrome/adapters/spotify"
_ "github.com/navidrome/navidrome/adapters/taglib"
)
var (
@@ -87,11 +88,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))
@@ -128,9 +126,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
@@ -201,8 +196,7 @@ func runInitialScan(ctx context.Context) func() error {
if err != nil {
return err
}
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
if scanNeeded {
s := CreateScanner(ctx)
@@ -282,24 +276,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
}
@@ -348,60 +334,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 runs the startup fingerprint backfill and registers the
// recurring stale-absent recheck and prune jobs. Scan-triggered prune lands in a later phase.
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
return func() error {
ds := CreateDataStore()
schedulerInstance := scheduler.GetInstance()
if _, err := schedulerInstance.Add(consts.ArtworkStaleAbsentRecheckSchedule, func() {
if err := artwork.EnqueueStaleAbsentAll(ctx, ds); err != nil {
log.Error(ctx, "Error enqueueing stale artwork rechecks", err)
}
}); err != nil {
log.Error(ctx, "Error scheduling artwork stale-absent 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)
}
backfilled, err := artwork.Backfill(ctx, ds)
if err != nil {
log.Error(ctx, "Error running artwork backfill", err)
return nil
}
if !backfilled {
return nil
}
log.Info(ctx, "Artwork backfill enqueued, scheduling a follow-up prune")
timer := time.NewTimer(consts.ArtworkPostBackfillPruneDelay)
defer timer.Stop()
select {
case <-timer.C:
if err := worker.RunPrune(ctx); err != nil {
log.Error(ctx, "Error running post-backfill artwork prune", err)
}
case <-ctx.Done():
}
return nil
}
}
// startPluginManager starts the plugin manager, if configured.
func startPluginManager(ctx context.Context) func() error {
return func() error {
+4 -36
View File
@@ -4,13 +4,11 @@ import (
"bufio"
"context"
"encoding/gob"
"errors"
"fmt"
"os"
"strings"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -44,20 +42,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 +58,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 +74,7 @@ func runScanner(ctx context.Context) {
sqlDB := db.Db()
defer db.Db().Close()
ds := persistence.New(sqlDB)
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
pls := core.NewPlaylists(ds)
// Parse targets from command line or file
var scanTargets []model.ScanTarget
@@ -102,16 +94,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 +103,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 -16
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,22 +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}}
Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
EnvironmentFile=-/etc/sysconfig/{{.Name}}
DevicePolicy=closed
NoNewPrivileges=yes
@@ -259,7 +259,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])
}
}
})
})
+23 -79
View File
@@ -1,6 +1,6 @@
// Code generated by Wire. DO NOT EDIT.
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
//go:build !wireinject
// +build !wireinject
@@ -16,14 +16,9 @@ import (
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/matcher"
"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/core/stream"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
@@ -31,7 +26,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"
@@ -42,6 +36,8 @@ import (
_ "github.com/navidrome/navidrome/adapters/gotaglib"
_ "github.com/navidrome/navidrome/adapters/lastfm"
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
_ "github.com/navidrome/navidrome/adapters/spotify"
_ "github.com/navidrome/navidrome/adapters/taglib"
)
// Injectors from wire_injectors.go:
@@ -65,8 +61,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
playlists := core.NewPlaylists(dataStore)
insights := metrics.GetInstance(dataStore)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
@@ -74,16 +69,15 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
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)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
user := core.NewUser(dataStore, manager)
maintenance := core.NewMaintenance(dataStore)
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService)
router := nativeapi.New(dataStore, share, playlists, insights, library, user, maintenance, manager)
return router
}
@@ -96,49 +90,19 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
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)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodingCache := core.GetTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
players := core.NewPlayers(dataStore)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
playlists := core.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
return router
}
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
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)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
players := core.NewPlayers(dataStore)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
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)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
return router
}
@@ -151,11 +115,10 @@ func CreatePublicRouter() *public.Router {
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)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodingCache := core.GetTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
@@ -199,13 +162,11 @@ func CreateScanner(ctx context.Context) model.Scanner {
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)
provider := external.NewProvider(dataStore, agentsAgents)
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)
playlists := core.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
return modelScanner
}
@@ -218,13 +179,11 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
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)
provider := external.NewProvider(dataStore, agentsAgents)
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)
playlists := core.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
}
@@ -236,21 +195,6 @@ func GetPlaybackServer() playback.PlaybackServer {
return playbackServer
}
func CreateArtworkWorker() *artwork.Worker {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
imageStore := artwork.ProvideImageStore()
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)
fFmpeg := ffmpeg.New()
worker := artwork.NewWorker(dataStore, imageStore, provider, fFmpeg)
return worker
}
func getPluginManager() *plugins.Manager {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
@@ -262,7 +206,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)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(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()
-20
View File
@@ -11,11 +11,9 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
@@ -23,7 +21,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"
@@ -34,7 +31,6 @@ var allProviders = wire.NewSet(
artwork.Set,
server.New,
subsonic.New,
jellyfin.New,
nativeapi.New,
public.New,
persistence.New,
@@ -46,12 +42,8 @@ var allProviders = wire.NewSet(
metrics.GetPrometheusInstance,
db.Db,
plugins.GetManager,
sonic.New,
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(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)),
@@ -82,12 +74,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,
@@ -136,12 +122,6 @@ func GetPlaybackServer() playback.PlaybackServer {
))
}
func CreateArtworkWorker() *artwork.Worker {
panic(wire.Build(
allProviders,
))
}
func getPluginManager() *plugins.Manager {
panic(wire.Build(
allProviders,
+4
View File
@@ -0,0 +1,4 @@
package buildtags
// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all
// required build tags are disabled.
-6
View File
@@ -1,6 +0,0 @@
// Package buildtags provides compile-time enforcement of required build tags.
//
// Each file in this package is guarded by a build constraint and exports a variable
// that main.go references. If a required tag is missing during compilation, the build
// fails with an "undefined" error, directing the developer to use `make build`.
package buildtags
+5 -1
View File
@@ -2,6 +2,10 @@
package buildtags
// The `netgo` tag is required when compiling the project. See https://github.com/navidrome/navidrome/issues/700
// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project.
// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go`
// file requires it.
// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700
var NETGO = true
-8
View File
@@ -1,8 +0,0 @@
//go:build sqlite_fts5
package buildtags
// FTS5 is required for full-text search. Without this tag, the SQLite driver
// won't include FTS5 support, causing runtime failures on migrations and search queries.
var SQLITE_FTS5 = true
+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
}
}
+97 -326
View File
@@ -2,7 +2,6 @@ package conf
import (
"cmp"
"encoding/json"
"fmt"
"net/url"
"os"
@@ -13,14 +12,12 @@ import (
"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/robfig/cron/v3"
"github.com/spf13/viper"
)
@@ -29,10 +26,9 @@ type configOptions struct {
Address string
Port int
UnixSocketPerm string
EnforceNonRootUser bool
MusicFolder string
DataFolder Dir
CacheFolder Dir
DataFolder string
CacheFolder string
DbPath string
LogLevel string
LogFile string
@@ -47,26 +43,23 @@ 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
AlbumPlayCountMode string
EnableArtworkPrecache bool
ArtworkWorkerConcurrency int
ArtworkExternalMaxRPS int
AutoImportPlaylists bool
DefaultPlaylistPublicVisibility bool
PlaylistsPath string
SmartPlaylistRefreshDelay time.Duration
AutoTranscodeDownload bool
DefaultDownsamplingFormat string
Search searchOptions `json:",omitzero"`
Matcher matcherOptions `json:",omitzero"`
SearchFullString bool
SimilarSongsMatchThreshold int
RecentlyAddedByModTime bool
PreferSortTags bool
IgnoredArticles string
@@ -75,18 +68,13 @@ type configOptions struct {
MPVPath string
MPVCmdTemplate string
CoverArtPriority string
CoverArtQuality int
EnableWebPEncoding bool
CoverJpegQuality int
ArtistArtPriority string
ArtistImageFolder string
DiscArtPriority string
LyricsPriority string
EnableGravatar bool
EnableFavourites bool
EnableStarRating bool
EnableUserEditing bool
EnableArtworkUpload bool
MaxImageUploadSize string
EnableSharing bool
ShareURL string
DefaultShareExpiration time.Duration
@@ -94,12 +82,9 @@ type configOptions struct {
DefaultTheme string
DefaultLanguage string
DefaultUIVolume int
UISearchDebounceMs int
UICoverArtSize int
EnableReplayGain bool
EnableCoverAnimation bool
EnableNowPlaying bool
UIPlaybackReportInterval time.Duration
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
@@ -115,11 +100,10 @@ type configOptions struct {
PID pidOptions `json:",omitzero"`
Inspect inspectOptions `json:",omitzero"`
Subsonic subsonicOptions `json:",omitzero"`
Transcoding transcodingOptions `json:",omitzero"`
LastFM lastfmOptions `json:",omitzero"`
Spotify spotifyOptions `json:",omitzero"`
Deezer deezerOptions `json:",omitzero"`
ListenBrainz listenBrainzOptions `json:",omitzero"`
Jellyfin jellyfinOptions `json:",omitzero"`
EnableScrobbleHistory bool
Tags map[string]TagConf `json:",omitempty"`
Agents string
@@ -140,45 +124,36 @@ type configOptions struct {
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtworkThrottleBuffered bool
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
DevScannerThreads uint
DevSelectiveWatcher bool
DevLegacyEmbedImage bool
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
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 {
AppendSubtitle bool
AppendAlbumVersion bool
ArtistParticipations bool
DefaultReportRealPath bool
EnableAverageRating bool
@@ -206,6 +181,11 @@ type lastfmOptions struct {
Languages []string // Computed from Language, split by comma
}
type spotifyOptions struct {
ID string
Secret string //nolint:gosec
}
type deezerOptions struct {
Enabled bool
Language string
@@ -221,18 +201,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
}
@@ -254,7 +222,7 @@ type jukeboxOptions struct {
type backupOptions struct {
Count int
Path Dir
Path string
Schedule string
}
@@ -272,7 +240,7 @@ type inspectOptions struct {
type pluginsOptions struct {
Enabled bool
Folder Dir
Folder string
CacheSize string
AutoReload bool
LogLevel string
@@ -281,30 +249,6 @@ type pluginsOptions struct {
type extAuthOptions struct {
TrustedSources string
UserHeader string
LogoutURL string
}
type searchOptions struct {
Backend string
FullString bool
}
type matcherOptions struct {
PreferStarred bool
FuzzyThreshold int
}
// logFatal prints a fatal error message to stderr and exits.
// Overridden in tests to allow testing fatal paths.
var logFatal = func(args ...any) {
_, _ = fmt.Fprintln(os.Stderr, append([]any{"FATAL:"}, args...)...)
os.Exit(1)
}
var getEUID = os.Geteuid
var currentGOOS = func() string {
return runtime.GOOS
}
var (
@@ -312,94 +256,77 @@ var (
hooks []func()
)
// SnapshotConfig returns a function that restores Server to its current state.
// Uses JSON round-tripping so Dir fields get fresh sync.Once values.
func SnapshotConfig() func() {
snapshot, err := json.Marshal(Server)
if err != nil {
panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err))
}
return func() {
var restored configOptions
if err := json.Unmarshal(snapshot, &restored); err != nil {
panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err))
}
Server = &restored
}
}
func LoadFromFile(confFile string) {
viper.SetConfigFile(confFile)
err := viper.ReadInConfig()
if err != nil {
logFatal("Error reading config file:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
os.Exit(1)
}
Load(true)
}
func Load(noConfigDump bool) {
parseIniFileConfiguration()
remapEnvVarKeysFromConfig()
// Map deprecated options to their new names for backwards compatibility
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
mapDeprecatedOption("DevArtworkWorkerConcurrency", "ArtworkWorkerConcurrency")
mapDeprecatedOption("DevArtworkExternalRPS", "ArtworkExternalMaxRPS")
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)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
// Validate non-root user early, before any filesystem operations
if err := validateEnforceNonRootUser(); err != nil {
logFatal(err)
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
os.Exit(1)
}
if Server.CacheFolder.String() == "" {
Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache"))
if Server.CacheFolder == "" {
Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
}
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
os.Exit(1)
}
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 {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
os.Exit(1)
}
}
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 {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
os.Exit(1)
}
}
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()))
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
os.Exit(1)
}
log.SetOutput(out)
} else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" {
// When running under systemd, prepend syslog priority prefixes so
// journald assigns the correct severity to each log line.
// Note that we have an additional environment variable, as JOURNAL_STREAM
// can be present in a systemd environment even if not running as a systemd service
log.EnableJournalFormat()
}
log.SetLevelString(Server.LogLevel)
@@ -412,19 +339,16 @@ func Load(noConfigDump bool) {
validateBackupSchedule,
validatePlaylistsPath,
validatePurgeMissingOption,
validateMaxImageUploadSize,
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
)
if err != nil {
logFatal(err)
os.Exit(1)
}
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
if Server.BaseURL != "" {
u, err := url.Parse(Server.BaseURL)
if err != nil {
logFatal("Invalid BaseURL:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
os.Exit(1)
}
Server.BasePath = u.Path
u.Path = ""
@@ -465,27 +389,12 @@ 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")
logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
// Removed options
logRemovedOptions("Spotify.ID", "Spotify.Secret")
// Validate other options
if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
newValue := max(200, min(1200, Server.UICoverArtSize))
log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", Server.UICoverArtSize, "newValue", newValue)
Server.UICoverArtSize = newValue
}
// Call init hooks
for _, hook := range hooks {
@@ -511,52 +420,6 @@ func logDeprecatedOptions(oldName, newName string) {
}
}
// logRemovedOptions checks if the option is set, and if yes, outputs a warning message saying the option is
// not available anymore
func logRemovedOptions(options ...string) {
for _, option := range options {
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))
}
if viper.InConfig(option) {
logWarning(option)
}
if os.Getenv(envVar) != "" {
logWarning(envVar)
}
}
}
// remapEnvVarKeysFromConfig detects ND_-prefixed keys in the config file (users mistakenly
// using environment variable names) and remaps them to canonical Viper keys with a warning.
func remapEnvVarKeysFromConfig() {
for _, key := range viper.AllKeys() {
if !strings.HasPrefix(key, "nd_") || !viper.InConfig(key) {
continue
}
stripped := strings.TrimPrefix(key, "nd_")
canonicalKey := strings.ReplaceAll(stripped, "_", ".")
displayNDKey := "ND_" + strings.ToUpper(stripped)
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, displayCanonical,
))
return
}
viper.Set(canonicalKey, viper.Get(key))
_, _ = 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) {
@@ -574,15 +437,18 @@ func parseIniFileConfiguration() {
var iniConfig map[string]any
err := viper.Unmarshal(&iniConfig)
if err != nil {
logFatal("Error parsing config:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
cfg, ok := iniConfig["default"].(map[string]any)
if !ok {
logFatal("Error parsing config: missing [default] section:", iniConfig)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
os.Exit(1)
}
err = viper.MergeConfigMap(cfg)
if err != nil {
logFatal("Error parsing config:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
}
}
@@ -590,8 +456,8 @@ func parseIniFileConfiguration() {
func disableExternalServices() {
log.Info("All external integrations are DISABLED!")
Server.EnableInsightsCollector = false
Server.EnableM3UExternalAlbumArt = false
Server.LastFM.Enabled = false
Server.Spotify.ID = ""
Server.Deezer.Enabled = false
Server.ListenBrainz.Enabled = false
Server.Agents = ""
@@ -604,7 +470,8 @@ func validatePlaylistsPath() error {
for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
_, err := doublestar.Match(path, "")
if err != nil {
return fmt.Errorf("invalid PlaylistsPath %q: %w", path, err)
log.Error("Invalid PlaylistsPath", "path", path, err)
return err
}
}
return nil
@@ -631,31 +498,13 @@ func validatePurgeMissingOption() error {
valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
if !valid {
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
log.Error(err.Error())
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
return err
}
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 {
if !Server.EnforceNonRootUser || currentGOOS() == "windows" {
return nil
}
if getEUID() == 0 {
return fmt.Errorf("EnforceNonRootUser is enabled but Navidrome is running as root")
}
return nil
}
func validateScanSchedule() error {
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
Server.Scanner.Schedule = ""
@@ -667,7 +516,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
}
@@ -677,58 +526,17 @@ func validateBackupSchedule() error {
}
func validateSchedule(schedule, field string) (string, error) {
_, err := scheduler.ParseCrontab(schedule)
if _, err := time.ParseDuration(schedule); err == nil {
schedule = "@every " + schedule
}
c := cron.New()
id, err := c.AddFunc(schedule, func() {})
if err != nil {
return schedule, fmt.Errorf("invalid %s %q (see https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format): %w", field, schedule, err)
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err)
} else {
c.Remove(id)
}
return schedule, nil
}
// validateURL checks if the provided URL is valid and has either http or https scheme.
// It returns a function that can be used as a hook to validate URLs in the config.
func validateURL(optionName, optionURL string) func() error {
return func() error {
if optionURL == "" {
return nil
}
u, err := url.Parse(optionURL)
if err != nil {
return fmt.Errorf("invalid %s %q: %w", optionName, optionURL, err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
}
if u.Host == "" || u.Opaque != "" {
return fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
}
return nil
}
}
func normalizeSearchBackend(value string) string {
v := strings.ToLower(strings.TrimSpace(value))
switch v {
case "fts", "legacy":
return v
default:
log.Error("Invalid Search.Backend value, falling back to 'fts'", "value", value)
return "fts"
}
}
// toPascalCase converts a dotted lowercase config key to PascalCase for display.
// Example: "scanner.schedule" → "Scanner.Schedule"
func toPascalCase(key string) string {
if key == "" {
return ""
}
parts := strings.Split(key, ".")
for i, part := range parts {
if len(part) > 0 {
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
}
return strings.Join(parts, ".")
return schedule, err
}
// AddHook is used to register initialization code that should run as soon as the config is loaded
@@ -755,7 +563,6 @@ func setViperDefaults() {
viper.SetDefault("address", "0.0.0.0")
viper.SetDefault("port", 4533)
viper.SetDefault("unixsocketperm", "0660")
viper.SetDefault("enforcenonrootuser", false)
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
viper.SetDefault("baseurl", "")
viper.SetDefault("tlscert", "")
@@ -764,6 +571,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)
@@ -774,28 +582,21 @@ func setViperDefaults() {
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
viper.SetDefault("enabledownloads", true)
viper.SetDefault("enableexternalservices", true)
viper.SetDefault("enablem3uexternalalbumart", false)
viper.SetDefault("enablemediafilecoverart", true)
viper.SetDefault("autotranscodedownload", false)
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
viper.SetDefault("search.fullstring", false)
viper.SetDefault("search.backend", "fts")
viper.SetDefault("matcher.preferstarred", true)
viper.SetDefault("matcher.fuzzythreshold", 85)
viper.SetDefault("searchfullstring", false)
viper.SetDefault("similarsongsmatchthreshold", 85)
viper.SetDefault("recentlyaddedbymodtime", false)
viper.SetDefault("prefersorttags", 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", "")
viper.SetDefault("mpvpath", "")
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
viper.SetDefault("coverartquality", 75)
viper.SetDefault("enablewebpencoding", false)
viper.SetDefault("coverjpegquality", 75)
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)
@@ -803,28 +604,21 @@ func setViperDefaults() {
viper.SetDefault("defaulttheme", "Dark")
viper.SetDefault("defaultlanguage", "")
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize)
viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablenowplaying", true)
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
viper.SetDefault("enableartworkupload", true)
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
viper.SetDefault("enablesharing", true)
viper.SetDefault("enablesharing", false)
viper.SetDefault("shareurl", "")
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", 20*time.Second)
viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("extauth.userheader", "Remote-User")
viper.SetDefault("extauth.trustedsources", "")
viper.SetDefault("extauth.logouturl", "")
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "")
@@ -838,36 +632,30 @@ 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)
viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false)
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("agents", "lastfm,spotify,deezer")
viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
viper.SetDefault("lastfm.apikey", "")
viper.SetDefault("lastfm.secret", "")
viper.SetDefault("lastfm.scrobblefirstartistonly", false)
viper.SetDefault("spotify.id", "")
viper.SetDefault("spotify.secret", "")
viper.SetDefault("deezer.enabled", true)
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
viper.SetDefault("listenbrainz.enabled", true)
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", "")
@@ -883,7 +671,6 @@ func setViperDefaults() {
viper.SetDefault("plugins.enabled", true)
viper.SetDefault("plugins.cachesize", "200MB")
viper.SetDefault("plugins.autoreload", false)
viper.SetDefault("plugins.loglevel", "")
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
@@ -897,15 +684,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("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("devartworkthrottlebuffered", true)
viper.SetDefault("artworkworkerconcurrency", 4)
viper.SetDefault("artworkexternalmaxrps", 2)
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
viper.SetDefault("devexternalscanner", true)
@@ -916,8 +697,8 @@ 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)
}
func init() {
@@ -954,7 +735,8 @@ func InitConfig(cfgFile string, loadEnvVars bool) {
err := viper.ReadInConfig()
if viper.ConfigFileUsed() != "" && err != nil {
logFatal("Navidrome could not open config file:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err)
os.Exit(1)
}
}
@@ -972,14 +754,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())
}
-239
View File
@@ -2,7 +2,6 @@ package conf_test
import (
"fmt"
"os"
"path/filepath"
"testing"
@@ -25,11 +24,6 @@ var _ = Describe("Configuration", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
// Panic instead of exiting on fatal errors to allow testing error conditions
DeferCleanup(conf.SetLogFatal(func(args ...any) {
panic(fmt.Sprint(args...))
}))
})
Describe("ParseLanguages", func() {
@@ -58,239 +52,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")
Expect(fn()).To(Succeed())
})
It("accepts a valid https URL", func() {
fn := conf.ValidateURL("TestOption", "https://example.com/path")
Expect(fn()).To(Succeed())
})
It("rejects a URL with no scheme", func() {
fn := conf.ValidateURL("TestOption", "example.com/path")
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
})
It("rejects a URL with an unsupported scheme", func() {
fn := conf.ValidateURL("TestOption", "javascript://example.com/path")
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
})
It("accepts an empty URL (optional config)", func() {
fn := conf.ValidateURL("TestOption", "")
Expect(fn()).To(Succeed())
})
It("includes the option name in the error message", func() {
fn := conf.ValidateURL("MyOption", "ftp://example.com")
Expect(fn()).To(MatchError(ContainSubstring("MyOption")))
})
It("rejects a URL that cannot be parsed", func() {
fn := conf.ValidateURL("TestOption", "://invalid")
Expect(fn()).To(HaveOccurred())
})
It("rejects a URL without a host", func() {
fn := conf.ValidateURL("TestOption", "http:///path")
Expect(fn()).To(MatchError(ContainSubstring("non-empty host is required")))
})
})
DescribeTable("NormalizeSearchBackend",
func(input, expected string) {
Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected))
},
Entry("accepts 'fts'", "fts", "fts"),
Entry("accepts 'legacy'", "legacy", "legacy"),
Entry("normalizes 'FTS' to lowercase", "FTS", "fts"),
Entry("normalizes 'Legacy' to lowercase", "Legacy", "legacy"),
Entry("trims whitespace", " fts ", "fts"),
Entry("falls back to 'fts' for 'fts5'", "fts5", "fts"),
Entry("falls back to 'fts' for unrecognized values", "invalid", "fts"),
Entry("falls back to 'fts' for empty string", "", "fts"),
)
DescribeTable("ToPascalCase",
func(input, expected string) {
Expect(conf.ToPascalCase(input)).To(Equal(expected))
},
Entry("simple key", "address", "Address"),
Entry("dotted key", "scanner.schedule", "Scanner.Schedule"),
Entry("already capitalized", "Address", "Address"),
Entry("multi-segment", "lastfm.enabled", "Lastfm.Enabled"),
Entry("empty string", "", ""),
)
Describe("remapEnvVarKeysFromConfig", func() {
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
})
It("remaps ND_-prefixed keys to canonical keys", func() {
filename := filepath.Join("testdata", "cfg_nd_keys.toml")
conf.InitConfig(filename, false)
conf.Load(true)
Expect(conf.Server.Address).To(Equal("127.0.0.1"))
Expect(conf.Server.Port).To(Equal(4531))
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
})
It("exits with fatal error when both ND_ and canonical key exist", func() {
filename := filepath.Join("testdata", "cfg_nd_conflict.toml")
conf.InitConfig(filename, false)
Expect(func() { conf.Load(true) }).To(PanicWith(And(
ContainSubstring("ND_ADDRESS"),
ContainSubstring("Address"),
ContainSubstring("only needed for environment variables"),
)))
})
It("does nothing when no ND_ keys are present", func() {
filename := filepath.Join("testdata", "cfg.toml")
conf.InitConfig(filename, false)
conf.Load(true)
// Verify normal config loading still works
Expect(conf.Server.MusicFolder).To(Equal("/toml/music"))
})
})
Describe("logFatal", func() {
var invalidPath string
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("loglevel", "error")
conf.ResetConf()
// Create a file so that any path under it is invalid on all OSes
f, err := os.CreateTemp(GinkgoT().TempDir(), "blocker")
Expect(err).ToNot(HaveOccurred())
f.Close()
invalidPath = filepath.Join(f.Name(), "subdir")
})
It("is called when LoadFromFile gets an invalid config file", func() {
Expect(func() {
conf.LoadFromFile(filepath.Join(invalidPath, "file.toml"))
}).To(PanicWith(ContainSubstring("Error reading config file")))
})
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")))
})
It("is called when BaseURL is invalid", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("baseurl", "://invalid")
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Invalid BaseURL")))
})
})
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) {
conf.Server.MaxImageUploadSize = input
Expect(conf.ValidateMaxImageUploadSize()).To(Succeed())
},
Entry("megabytes", "10MB"),
Entry("gigabytes", "1GB"),
Entry("raw bytes", "10485760"),
Entry("mebibytes", "10MiB"),
Entry("lower case", "50mb"),
)
DescribeTable("rejects invalid size values",
func(input string) {
conf.Server.MaxImageUploadSize = input
Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize")))
},
Entry("garbage string", "not-a-size"),
Entry("negative-looking", "-10MB"),
)
})
Describe("EnforceNonRootUser", func() {
It("defaults to false", func() {
conf.Load(true)
Expect(conf.Server.EnforceNonRootUser).To(BeFalse())
})
It("allows startup for non-root users when enabled", func() {
DeferCleanup(conf.SetRuntimeInfoForTest("linux", 1000))
viper.Set("enforcenonrootuser", true)
conf.Load(true)
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
})
It("exits when enabled and running as root without having created a data folder", func() {
// Create a path that doesn't exist yet
tempBase := GinkgoT().TempDir()
nonExistentDataFolder := filepath.Join(tempBase, "nonexistent", "data")
DeferCleanup(conf.SetRuntimeInfoForTest("linux", 0))
viper.Set("enforcenonrootuser", true)
viper.Set("datafolder", nonExistentDataFolder)
// Attempt to load config as root user - should fail before creating directories
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("EnforceNonRootUser is enabled but Navidrome is running as root")))
// Verify that the data folder was NOT created
Expect(nonExistentDataFolder).ToNot(BeAnExistingFile())
})
It("is a no-op on non-unix platforms", func() {
DeferCleanup(conf.SetRuntimeInfoForTest("windows", 0))
viper.Set("enforcenonrootuser", true)
conf.Load(true)
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
})
})
DescribeTable("should load configuration from",
func(format string) {
filename := filepath.Join("testdata", "cfg."+format)
-77
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()
})
})
})
-25
View File
@@ -7,28 +7,3 @@ func ResetConf() {
var SetViperDefaults = setViperDefaults
var ParseLanguages = parseLanguages
var ValidateURL = validateURL
var NormalizeSearchBackend = normalizeSearchBackend
var ToPascalCase = toPascalCase
var ValidateMaxImageUploadSize = validateMaxImageUploadSize
func SetRuntimeInfoForTest(goos string, euid int) func() {
oldGOOS := currentGOOS
oldEUID := getEUID
currentGOOS = func() string { return goos }
getEUID = func() int { return euid }
return func() {
currentGOOS = oldGOOS
getEUID = oldEUID
}
}
func SetLogFatal(f func(...any)) func() {
old := logFatal
logFatal = f
return func() { logFatal = old }
}
-2
View File
@@ -1,2 +0,0 @@
ND_ADDRESS = "127.0.0.1"
Address = "0.0.0.0"
-3
View File
@@ -1,3 +0,0 @@
ND_ADDRESS = "127.0.0.1"
ND_PORT = 4531
ND_SCANNER_SCHEDULE = "@every 1h"
+9 -46
View File
@@ -14,16 +14,9 @@ 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"
UIAuthorizationHeader = "X-ND-Authorization"
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
@@ -32,12 +25,7 @@ const (
DefaultSessionTimeout = 48 * time.Hour
CookieExpiry = 365 * 24 * 3600 // One year
DBAnalyzeCheckSchedule = "@every 30m"
DBAnalyzeMaxAge = 24 * time.Hour
ArtworkStaleAbsentRecheckSchedule = "@every 1h"
ArtworkPruneSchedule = "@daily"
ArtworkPostBackfillPruneDelay = 10 * time.Minute
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
@@ -53,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
@@ -82,14 +65,12 @@ const (
I18nFolder = "i18n"
ScanIgnoreFile = ".ndignore"
ArtworkFolder = "artwork"
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
DefaultUIVolume = 100
DefaultUISearchDebounceMs = 200
DefaultUIPlaybackReportInterval = time.Minute
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
UICoverArtSize = 300
DefaultUIVolume = 100
DefaultHttpClientTimeOut = 10 * time.Second
@@ -102,11 +83,6 @@ const (
Zwsp = string('\u200b')
)
const (
DefaultUICoverArtSize = 300
DefaultMaxImageUploadSize = "10MB"
)
// Prometheus options
const (
PrometheusDefaultPath = "/metrics"
@@ -125,13 +101,6 @@ const (
DefaultCacheCleanUpInterval = 10 * time.Minute
)
// Entity types
const (
EntityArtist = "artist"
EntityPlaylist = "playlist"
EntityRadio = "radio"
)
const (
AlbumPlayCountModeAbsolute = "absolute"
AlbumPlayCountModeNormalized = "normalized"
@@ -170,25 +139,19 @@ 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 -",
},
{
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 -b:a %bk -v 0 -c:a aac -f adts -",
},
}
)
-4
View File
@@ -1,4 +0,0 @@
{
"url": "https://context7.com/navidrome/navidrome",
"public_key": "pk_WqzhKScNKWQ84J4n0oG0J"
}
+1 -1
View File
@@ -7,6 +7,6 @@ A new agent must comply with these simple implementation rules:
2) Implement one or more of the `*Retriever()` interfaces. That's where the agent's logic resides.
3) Register itself (in its `init()` function).
For an agent to be used it needs to be listed in the `Agents` config option (default is `"deezer,lastfm"`). The order dictates the priority of the agents
For an agent to be used it needs to be listed in the `Agents` config option (default is `"lastfm,spotify"`). The order dictates the priority of the agents
For a simple Agent example, look at the [local_agent](local_agent.go) agent source code.
+9 -17
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"github.com/gohugoio/hashstructure"
"github.com/navidrome/navidrome/model"
)
@@ -34,22 +33,15 @@ 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
}
// 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
ID string
Name string
MBID string
ISRC string
Artist string
ArtistMBID string
Album string
AlbumMBID string
Duration uint32 // Duration in milliseconds, 0 means unknown
}
var (
-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())
})
})
+28 -44
View File
@@ -3,7 +3,6 @@ package core
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"os"
@@ -11,11 +10,9 @@ import (
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
type Archiver interface {
@@ -25,13 +22,13 @@ type Archiver interface {
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
}
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver {
func NewArchiver(ms MediaStreamer, ds model.DataStore, shares Share) Archiver {
return &archiver{ds: ds, ms: ms, shares: shares}
}
type archiver struct {
ds model.DataStore
ms stream.MediaStreamer
ms MediaStreamer
shares Share
}
@@ -61,15 +58,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()
@@ -97,7 +86,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
if isMultiDisc {
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
}
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file)
}
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
@@ -129,19 +118,14 @@ 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
}
// Add M3U file if requested
if addM3U && len(zippedMfs) > 0 {
plsName := str.SanitizeFilename(name)
plsName := sanitizeName(name)
w, err := z.CreateHeader(&zip.FileHeader{
Name: plsName + ".m3u",
Modified: mfs[0].UpdatedAt,
@@ -171,32 +155,15 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
if format != "" && format != "raw" {
ext = format
}
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
}
func sanitizeName(target string) string {
return strings.ReplaceAll(target, "/", "_")
}
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,
@@ -207,6 +174,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.DoStream(ctx, &mf, format, bitrate, 0)
} 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)
+8 -35
View File
@@ -9,7 +9,6 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -45,7 +44,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
out := new(bytes.Buffer)
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
@@ -74,7 +73,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
@@ -89,32 +88,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{
@@ -131,7 +104,7 @@ var _ = Describe("Archiver", func() {
}
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)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipShare(context.Background(), "1", out)
@@ -163,7 +136,7 @@ var _ = Describe("Archiver", func() {
plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
ds.On("Playlist", mock.Anything).Return(plRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
@@ -241,15 +214,15 @@ func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists,
type mockMediaStreamer struct {
mock.Mock
stream.MediaStreamer
core.MediaStreamer
}
func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) {
args := m.Called(ctx, mf, req)
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
if args.Error(1) != nil {
return nil, args.Error(1)
}
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
}
type mockShare struct {
-120
View File
@@ -1,120 +0,0 @@
package artwork
import (
"bytes"
"encoding/binary"
)
// isAnimatedGIF checks for multiple image descriptor blocks (0x2C) in a GIF file.
// Animated GIFs use GIF89a and contain multiple image blocks.
func isAnimatedGIF(data []byte) bool {
// GIF header: "GIF87a" or "GIF89a"
if !bytes.HasPrefix(data, []byte("GIF")) {
return false
}
// Skip header (6 bytes) + logical screen descriptor (7 bytes)
pos := 13
if pos >= len(data) {
return false
}
// Skip Global Color Table if present (bit 7 of packed byte at offset 10)
if len(data) > 10 && data[10]&0x80 != 0 {
// GCT size = 3 * 2^(N+1) where N = bits 0-2 of packed byte
gctSize := 3 * (1 << ((data[10] & 0x07) + 1))
pos += gctSize
}
frameCount := 0
for pos < len(data) {
switch data[pos] {
case 0x2C: // Image Descriptor - marks a frame
frameCount++
if frameCount > 1 {
return true
}
pos++ // skip introducer
if pos+8 >= len(data) {
return false
}
pos += 8 // skip x, y, w, h (each 2 bytes)
packed := data[pos]
pos++ // skip packed byte
// Skip Local Color Table if present
if packed&0x80 != 0 {
lctSize := 3 * (1 << ((packed & 0x07) + 1))
pos += lctSize
}
// Skip LZW minimum code size
pos++
// Skip sub-blocks
pos = skipGIFSubBlocks(data, pos)
case 0x21: // Extension block
pos++ // skip introducer
if pos >= len(data) {
return false
}
pos++ // skip extension label
// Skip sub-blocks
pos = skipGIFSubBlocks(data, pos)
case 0x3B: // Trailer
return false
default:
// Unknown block, bail
return false
}
}
return false
}
// skipGIFSubBlocks advances past a sequence of GIF sub-blocks (terminated by a zero-length block).
func skipGIFSubBlocks(data []byte, pos int) int {
for pos < len(data) {
blockSize := int(data[pos])
pos++ // skip size byte
if blockSize == 0 {
break
}
pos += blockSize
}
return pos
}
// isAnimatedWebP checks for ANMF (animation frame) chunks in a WebP RIFF container.
func isAnimatedWebP(data []byte) bool {
// WebP header: "RIFF" + 4 bytes size + "WEBP"
if !bytes.HasPrefix(data, []byte("RIFF")) || len(data) < 12 {
return false
}
if !bytes.Equal(data[8:12], []byte("WEBP")) {
return false
}
// Scan for ANMF chunk identifier
return bytes.Contains(data[12:], []byte("ANMF"))
}
// isAnimatedPNG checks for the acTL (animation control) chunk in a PNG file.
// APNG files contain an acTL chunk that is not present in static PNGs.
func isAnimatedPNG(data []byte) bool {
// PNG signature: 8 bytes
pngSig := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
if !bytes.HasPrefix(data, pngSig) {
return false
}
// Scan chunks for "acTL" (animation control)
pos := uint64(8)
dataLen := uint64(len(data))
for pos+8 <= dataLen {
chunkLen := uint64(binary.BigEndian.Uint32(data[pos : pos+4]))
chunkType := string(data[pos+4 : pos+8])
if chunkType == "acTL" {
return true
}
// Move to next chunk: 4 (length) + 4 (type) + chunkLen (data) + 4 (CRC)
pos += 12 + chunkLen
}
return false
}
-161
View File
@@ -1,161 +0,0 @@
package artwork
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/gif"
"image/png"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Animation detection", func() {
Describe("isAnimatedGIF", func() {
It("detects an animated GIF with multiple frames", func() {
Expect(isAnimatedGIF(createAnimatedGIF(2))).To(BeTrue())
})
It("detects an animated GIF with many frames", func() {
Expect(isAnimatedGIF(createAnimatedGIF(5))).To(BeTrue())
})
It("does not flag a static GIF (single frame)", func() {
Expect(isAnimatedGIF(createAnimatedGIF(1))).To(BeFalse())
})
It("returns false for non-GIF data", func() {
Expect(isAnimatedGIF(nil)).To(BeFalse())
Expect(isAnimatedGIF([]byte{0xFF, 0xD8})).To(BeFalse())
})
})
Describe("isAnimatedWebP", func() {
It("detects an animated WebP with ANMF chunk", func() {
Expect(isAnimatedWebP(createAnimatedWebPBytes())).To(BeTrue())
})
It("does not flag a static WebP (no ANMF chunk)", func() {
Expect(isAnimatedWebP(createStaticWebPBytes())).To(BeFalse())
})
It("returns false for non-WebP data", func() {
Expect(isAnimatedWebP(nil)).To(BeFalse())
Expect(isAnimatedWebP([]byte{0xFF, 0xD8})).To(BeFalse())
})
})
Describe("isAnimatedPNG", func() {
It("detects an APNG with acTL chunk", func() {
Expect(isAnimatedPNG(createAPNGBytes())).To(BeTrue())
})
It("does not flag a static PNG (no acTL chunk)", func() {
Expect(isAnimatedPNG(createStaticPNGBytes())).To(BeFalse())
})
It("returns false for non-PNG data", func() {
Expect(isAnimatedPNG(nil)).To(BeFalse())
Expect(isAnimatedPNG([]byte{0xFF, 0xD8})).To(BeFalse())
})
})
})
// createAnimatedGIF creates a minimal animated GIF with the given number of frames.
func createAnimatedGIF(frames int) []byte {
g := &gif.GIF{
LoopCount: 0,
}
for range frames {
img := image.NewPaletted(image.Rect(0, 0, 2, 2), color.Palette{color.Black, color.White})
g.Image = append(g.Image, img)
g.Delay = append(g.Delay, 10)
}
var buf bytes.Buffer
err := gif.EncodeAll(&buf, g)
if err != nil {
panic(err)
}
return buf.Bytes()
}
// writeUint32LE appends a little-endian uint32 to the buffer.
func writeUint32LE(buf *bytes.Buffer, v uint32) {
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, v)
buf.Write(b)
}
// writeUint32BE appends a big-endian uint32 to the buffer.
func writeUint32BE(buf *bytes.Buffer, v uint32) {
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, v)
buf.Write(b)
}
// createAnimatedWebPBytes creates a minimal RIFF/WEBP container with an ANMF chunk.
func createAnimatedWebPBytes() []byte {
var buf bytes.Buffer
buf.WriteString("RIFF")
writeUint32LE(&buf, 100) // file size placeholder
buf.WriteString("WEBP")
// VP8X chunk (extended format, required for animation)
buf.WriteString("VP8X")
writeUint32LE(&buf, 10)
buf.Write(make([]byte, 10))
// ANIM chunk (animation parameters)
buf.WriteString("ANIM")
writeUint32LE(&buf, 6)
buf.Write(make([]byte, 6))
// ANMF chunk (animation frame)
buf.WriteString("ANMF")
writeUint32LE(&buf, 16)
buf.Write(make([]byte, 16))
return buf.Bytes()
}
// createStaticWebPBytes creates a minimal RIFF/WEBP container without ANMF chunks.
func createStaticWebPBytes() []byte {
var buf bytes.Buffer
buf.WriteString("RIFF")
writeUint32LE(&buf, 20) // file size
buf.WriteString("WEBP")
// VP8 chunk (simple lossy format)
buf.WriteString("VP8 ")
writeUint32LE(&buf, 4)
buf.Write(make([]byte, 4))
return buf.Bytes()
}
// createAPNGBytes creates a minimal PNG with an acTL chunk (making it APNG).
func createAPNGBytes() []byte {
// Start with a real PNG
staticPNG := createStaticPNGBytes()
// Insert an acTL chunk after the IHDR chunk.
// PNG structure: signature (8) + IHDR chunk (4 len + 4 type + 13 data + 4 crc = 25)
ihdrEnd := 8 + 25
var buf bytes.Buffer
buf.Write(staticPNG[:ihdrEnd])
// Write acTL chunk: length=8, type="acTL", data=num_frames(4)+num_plays(4), CRC=4
writeUint32BE(&buf, 8) // chunk data length
buf.WriteString("acTL")
writeUint32BE(&buf, 2) // num_frames
writeUint32BE(&buf, 0) // num_plays (0 = infinite)
writeUint32BE(&buf, 0) // CRC placeholder
buf.Write(staticPNG[ihdrEnd:])
return buf.Bytes()
}
// createStaticPNGBytes creates a minimal valid static PNG.
func createStaticPNGBytes() []byte {
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
var buf bytes.Buffer
err := png.Encode(&buf, img)
if err != nil {
panic(err)
}
return buf.Bytes()
}
-4
View File
@@ -122,10 +122,6 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
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
}
+16 -289
View File
@@ -9,9 +9,7 @@ import (
"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"
@@ -27,7 +25,7 @@ var _ = Describe("Artwork", func() {
var ffmpeg *tests.MockFFmpeg
var folderRepo *fakeFolderRepo
ctx := log.NewContext(context.TODO())
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
var arMultipleCovers model.Artist
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
@@ -37,20 +35,14 @@ var _ = Describe("Artwork", func() {
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
folderRepo = &fakeFolderRepo{}
libRepo := &tests.MockLibraryRepo{}
repoRoot, _ := os.Getwd()
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
ds = &tests.MockDataStore{
MockedTranscoding: &tests.MockTranscodingRepo{},
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
// Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB.
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}}
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}}
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",
@@ -150,61 +142,13 @@ var _ = Describe("Artwork", func() {
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"),
)
})
Context("LastUpdated", func() {
// Regression test for #5377: LastUpdated feeds the HTTP Last-Modified header.
// It must return max(album.UpdatedAt, ImagesUpdatedAt) so browsers revalidate
// cached cover art when only the image file changes.
now := time.Now().Truncate(time.Second)
DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt",
func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) {
album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt}
folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album})
ar, err := newAlbumArtworkReader(ctx, aw, album.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
Expect(ar.LastUpdated()).To(Equal(expected))
},
Entry("album newer than images", now, now.Add(-1*time.Hour), now),
Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)),
Entry("equal timestamps", now, now, now),
)
})
})
Describe("discArtworkReader", func() {
Context("LastUpdated", func() {
// Regression test for #5377: same bug as albumArtworkReader — disc covers
// must also revalidate when the image file changes, not only when media files do.
now := time.Now().Truncate(time.Second)
DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt",
func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) {
album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt}
folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "al1", DiscNumber: 1, Path: "tests/fixtures/test.mp3"},
})
artID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("al1", 1), nil)
dr, err := newDiscArtworkReader(ctx, aw, artID)
Expect(err).ToNot(HaveOccurred())
Expect(dr.LastUpdated()).To(Equal(expected))
},
Entry("album newer than images", now, now.Add(-1*time.Hour), now),
Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)),
Entry("equal timestamps", now, now, now),
)
})
})
Describe("artistArtworkReader", func() {
Context("Multiple covers", func() {
BeforeEach(func() {
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
folderRepo.result = []model.Folder{{
LibraryPath: testFileLibPath(repoRoot),
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
}}
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
arMultipleCovers,
@@ -223,7 +167,7 @@ var _ = Describe("Artwork", func() {
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(filepath.ToSlash(path)).To(HaveSuffix(expected))
Expect(path).To(Equal(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"),
@@ -246,7 +190,6 @@ var _ = Describe("Artwork", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alOnlyEmbed,
alOnlyExternal,
alSingleDisc,
})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
mfWithEmbed,
@@ -290,137 +233,8 @@ var _ = Describe("Artwork", func() {
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{{
@@ -432,7 +246,7 @@ var _ = Describe("Artwork", func() {
})
})
When("Square is false", func() {
It("returns PNG if original image is a PNG", func() {
It("returns a 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())
@@ -443,7 +257,7 @@ var _ = Describe("Artwork", func() {
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() {
It("returns a 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())
@@ -459,18 +273,15 @@ var _ = Describe("Artwork", func() {
var alCover model.Album
DescribeTable("resize",
func(srcFormat string, expectedFormat string, landscape bool, size int) {
coverFileName := "cover." + srcFormat
dirName := createImage(srcFormat, landscape, size)
func(format string, landscape bool, size int) {
coverFileName := "cover." + format
dirName := createImage(format, 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
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alCover,
})
@@ -481,100 +292,16 @@ var _ = Describe("Artwork", func() {
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal(expectedFormat))
Expect(format).To(Equal("png"))
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),
Entry("portrait png image", "png", false, 200),
Entry("landscape png image", "png", true, 200),
Entry("portrait jpg image", "jpg", false, 200),
Entry("landscape jpg image", "jpg", 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"
-69
View File
@@ -1,86 +1,17 @@
package artwork
import (
"io/fs"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"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"),
// The old cache_warmer.go starts a goroutine per NewCacheWarmer call with
// no shutdown path (dark-launch target for Phase 2, not touched here).
goleak.IgnoreTopFunction("github.com/navidrome/navidrome/core/artwork.(*cacheWarmer).waitSignal"),
)
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Artwork Suite")
}
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
// ReadTags is not used by albumArtworkReader, so it is left as a stub.
type osDirFS struct{ fs.FS }
func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil }
// testFileScheme is the URL scheme registered to expose a tempdir as a
// storage.MusicFS for artwork integration tests.
const testFileScheme = "testfile"
// testFileLibPath builds a `testfile://` library URL for the given absolute
// filesystem path. On Windows, the native path (e.g. `C:\foo`) has no leading
// slash after ToSlash, which makes url.Parse treat the drive letter as a
// host. We prepend a `/` so parsing yields `u.Path == /C:/foo`, and the
// registered constructor below strips that leading slash back off.
func testFileLibPath(absPath string) string {
p := filepath.ToSlash(absPath)
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return testFileScheme + "://" + p
}
func init() {
// Register the testfile storage scheme (os.DirFS-backed MusicFS). Used by
// integration tests that need real files but not the taglib extractor.
storage.Register(testFileScheme, func(u url.URL) storage.Storage {
root := u.Path
// Undo the leading slash added by testFileLibPath on Windows so that
// os.Stat / os.DirFS receive a native path like `C:\foo`.
if runtime.GOOS == "windows" && len(root) >= 3 && root[0] == '/' && root[2] == ':' {
root = root[1:]
}
return &osDirStorage{root: filepath.FromSlash(root)}
})
}
type osDirStorage struct{ root string }
func (s *osDirStorage) FS() (storage.MusicFS, error) {
if _, err := os.Stat(s.root); err != nil {
return nil, err
}
return osDirFS{os.DirFS(s.root)}, nil
}
-37
View File
@@ -1,37 +0,0 @@
package artwork
import (
"bytes"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"testing"
)
func BenchmarkImageDecode(b *testing.B) {
sizes := []int{300, 1000, 3000}
formats := []struct {
name string
gen func(tb testing.TB, w, h int) []byte
}{
{"jpeg", func(tb testing.TB, w, h int) []byte { return generateJPEG(tb, w, h, 75) }},
{"png", func(tb testing.TB, w, h int) []byte { return generatePNG(tb, w, h) }},
}
for _, format := range formats {
for _, size := range sizes {
data := format.gen(b, size, size)
b.Run(fmt.Sprintf("%s/%dx%d", format.name, size, size), func(b *testing.B) {
b.SetBytes(int64(len(data)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
b.Fatal(err)
}
}
})
}
}
}
-189
View File
@@ -1,189 +0,0 @@
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 = conf.NewDir(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 range n {
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()
}
})
}
}
}
-40
View File
@@ -1,40 +0,0 @@
package artwork
import (
"bytes"
"fmt"
"image/jpeg"
"image/png"
"testing"
)
func BenchmarkImageEncode(b *testing.B) {
img := generateGradientImage(300, 300)
jpegQualities := []int{60, 75, 90}
for _, q := range jpegQualities {
b.Run(fmt.Sprintf("jpeg/q%d/300x300", q), func(b *testing.B) {
var buf bytes.Buffer
b.ResetTimer()
for i := 0; i < b.N; i++ {
buf.Reset()
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil {
b.Fatal(err)
}
}
b.ReportMetric(float64(buf.Len()), "bytes")
})
}
b.Run("png/300x300", func(b *testing.B) {
var buf bytes.Buffer
b.ResetTimer()
for i := 0; i < b.N; i++ {
buf.Reset()
if err := png.Encode(&buf, img); err != nil {
b.Fatal(err)
}
}
b.ReportMetric(float64(buf.Len()), "bytes")
})
}
-47
View File
@@ -1,47 +0,0 @@
package artwork
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"testing"
)
// generateJPEG creates a JPEG image of the given dimensions with a gradient pattern.
// The gradient ensures the image has realistic entropy (not trivially compressible).
func generateJPEG(t testing.TB, width, height, quality int) []byte {
t.Helper()
img := generateGradientImage(width, height)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// generatePNG creates a PNG image of the given dimensions with a gradient pattern.
func generatePNG(t testing.TB, width, height int) []byte {
t.Helper()
img := generateGradientImage(width, height)
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// 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 {
r := uint8((x * 255) / width)
g := uint8((y * 255) / height)
b := uint8(((x + y) * 255) / (width + height))
img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: 255})
}
}
return img
}
-50
View File
@@ -1,50 +0,0 @@
package artwork
import (
"fmt"
"testing"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
)
func BenchmarkResizeFullPipeline(b *testing.B) {
cleanup := configtest.SetupConfig()
b.Cleanup(cleanup)
conf.Server.CoverArtQuality = 75
sourceSizes := []int{1000, 3000}
targetSize := 300
for _, srcSize := range sourceSizes {
jpegData := generateJPEG(b, srcSize, srcSize, 90)
b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d", srcSize, srcSize, targetSize), func(b *testing.B) {
b.SetBytes(int64(len(jpegData)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
result, _, err := resizeStaticImage(jpegData, targetSize, false)
if err != nil {
b.Fatal(err)
}
if result == nil {
b.Fatal("expected non-nil resized image")
}
}
})
b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d_square", srcSize, srcSize, targetSize), func(b *testing.B) {
b.SetBytes(int64(len(jpegData)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
result, _, err := resizeStaticImage(jpegData, targetSize, true)
if err != nil {
b.Fatal(err)
}
if result == nil {
b.Fatal("expected non-nil resized image")
}
}
})
}
}
-38
View File
@@ -1,38 +0,0 @@
package artwork
import (
"path/filepath"
"runtime"
"testing"
"go.senan.xyz/taglib"
)
func BenchmarkTagExtraction(b *testing.B) {
// Ensure working directory is the project root (tests.Init not called with -run='^$')
_, file, _, ok := runtime.Caller(0)
if !ok {
b.Fatal("runtime.Caller failed")
}
appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
// Use existing test fixture with embedded artwork
testFile := filepath.Join(appPath, "tests/fixtures/artist/an-album/test.mp3")
b.ResetTimer()
for i := 0; i < b.N; i++ {
f, err := taglib.OpenReadOnly(testFile, taglib.WithReadStyle(taglib.ReadStyleFast))
if err != nil {
b.Fatal(err)
}
images := f.Properties().Images
if len(images) == 0 {
b.Fatal("no images found in test file")
}
data, err := f.Image(0)
if err != nil || len(data) == 0 {
b.Fatal("failed to extract image data")
}
f.Close()
}
}
-177
View File
@@ -1,177 +0,0 @@
// Package blurhash implements the blurhash encoding algorithm (https://github.com/woltapp/blurhash),
// matching Jellyfin's parameters so clients tuned against Jellyfin see equivalent hashes.
package blurhash
import (
"errors"
"image"
"image/draw"
"math"
"strings"
"sync"
xdraw "golang.org/x/image/draw"
)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
// maxInputSize matches Jellyfin: larger inputs are slower with no visually discernible difference.
const maxInputSize = 128
// Components picks x/y component counts for an image, targeting ~16 near-square tiles (Jellyfin's formula).
func Components(width, height int) (int, int) {
if width <= 0 || height <= 0 {
return 0, 0
}
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 using xComp x yComp components.
func Encode(img image.Image, xComp, yComp int) (string, error) {
if xComp < 1 || xComp > 9 || yComp < 1 || yComp > 9 {
return "", errors.New("blurhash: components must be between 1 and 9")
}
rgba := toRGBA(downscale(img))
bounds := rgba.Bounds()
w, h := bounds.Dx(), bounds.Dy()
if w == 0 || h == 0 {
return "", errors.New("blurhash: empty image")
}
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)
for y := 0; y < h; y++ {
row := rgba.Pix[y*rgba.Stride:]
for x := 0; x < w; x++ {
p := x * 4
lr, lg, lb := lin[row[p]], lin[row[p+1]], lin[row[p+2]]
for j := 0; j < yComp; j++ {
for i := 0; i < xComp; i++ {
basis := cosX[i][x] * cosY[j][y]
f := &factors[j*xComp+i]
f[0] += basis * lr
f[1] += basis * lg
f[2] += basis * lb
}
}
}
}
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))
ac := factors[1:]
maxVal := 1.0
if len(ac) > 0 {
actualMax := 0.0
for _, f := range ac {
actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2]))
}
quantMax := int(math.Max(0, math.Min(82, math.Floor(actualMax*166-0.5))))
maxVal = float64(quantMax+1) / 166
sb.WriteString(Encode83(quantMax, 1))
} else {
sb.WriteString(Encode83(0, 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
}
// toRGBA gives the pixel loop direct Pix access, avoiding a per-pixel allocation through the
// image.At interface (~16k allocs per encode).
func toRGBA(img image.Image) *image.RGBA {
if rgba, ok := img.(*image.RGBA); ok {
return rgba
}
b := img.Bounds()
dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src)
return dst
}
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(math.Max(0, math.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 = math.Min(math.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, using the
// blurhash spec's alphabet.
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,41 +0,0 @@
package blurhash_test
import (
"fmt"
"image"
"image/color"
"testing"
"github.com/navidrome/navidrome/core/artwork/blurhash"
)
// benchImage builds a deterministic gradient so runs are comparable across revisions.
func benchImage(size int) image.Image {
img := image.NewNRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
img.SetNRGBA(x, y, color.NRGBA{
R: uint8(255 * x / size),
G: uint8(255 * y / size),
B: uint8((x + y) * 255 / (2 * size)),
A: 255,
})
}
}
return img
}
func BenchmarkEncode(b *testing.B) {
for _, size := range []int{100, 300, 600, 900, 1200, 1500} {
img := benchImage(size)
x, y := blurhash.Components(size, size)
b.Run(fmt.Sprintf("%dx%d", size, size), func(b *testing.B) {
b.ReportAllocs()
for range b.N {
if _, err := blurhash.Encode(img, x, y); err != nil {
b.Fatal(err)
}
}
})
}
}
@@ -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")
}
-113
View File
@@ -1,113 +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 := 0; y < h; y++ {
for x := 0; x < w; x++ {
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 := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255})
}
}
return img
}
var _ = Describe("Components", func() {
DescribeTable("derives component counts from aspect ratio (Jellyfin formula)",
func(w, h, expectedX, expectedY int) {
x, y := blurhash.Components(w, h)
Expect(x).To(Equal(expectedX))
Expect(y).To(Equal(expectedY))
},
Entry("square album art", 600, 600, 5, 5),
Entry("small square", 1, 1, 5, 5),
Entry("landscape 16:9", 1920, 1080, 6, 4),
Entry("portrait 9:16", 1080, 1920, 4, 6),
Entry("extreme landscape capped at 9", 10000, 100, 9, 1),
Entry("zero width", 0, 600, 0, 0),
Entry("zero height", 600, 0, 0, 0),
)
})
var _ = Describe("Encode", func() {
It("rejects out-of-range components", func() {
_, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 0, 5)
Expect(err).To(HaveOccurred())
_, err = blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 5, 10)
Expect(err).To(HaveOccurred())
})
It("produces the spec-mandated length", func() {
// 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component
h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255}), 4, 3)
Expect(err).ToNot(HaveOccurred())
Expect(h).To(HaveLen(4 + 2 + 2*(4*3-1)))
})
It("encodes the size flag as the first character", func() {
h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 4, 3)
Expect(err).ToNot(HaveOccurred())
Expect(decode83(h[:1])).To(Equal((4 - 1) + (3-1)*9))
})
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}), 4, 3)
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, 5, 5)
h2, err2 := blurhash.Encode(img, 5, 5)
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}), 4, 4)
h2, _ := blurhash.Encode(gradientImage(16, 16), 4, 4)
Expect(h1).ToNot(Equal(h2))
})
It("downscales large images internally without changing the result materially", func() {
// A 1000px solid image must encode fine and carry the same DC as its small version.
big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5)
Expect(err).ToNot(HaveOccurred())
small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5)
Expect(err).ToNot(HaveOccurred())
Expect(big[2:6]).To(Equal(small[2:6]))
})
})
+19 -18
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -23,7 +24,7 @@ type CacheWarmer interface {
// 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.
// image size, as well as the size defined in the UICoverArtSize constant.
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 {
@@ -37,11 +38,10 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
}
a := &cacheWarmer{
artwork: artwork,
cache: cache,
buffer: make(map[model.ArtworkID]struct{}),
wakeSignal: make(chan struct{}, 1),
coverArtSize: conf.Server.UICoverArtSize,
artwork: artwork,
cache: cache,
buffer: make(map[model.ArtworkID]struct{}),
wakeSignal: make(chan struct{}, 1),
}
// Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts
@@ -51,12 +51,11 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
}
type cacheWarmer struct {
artwork Artwork
buffer map[model.ArtworkID]struct{}
mutex sync.Mutex
cache cache.FileCache
wakeSignal chan struct{}
coverArtSize int
artwork Artwork
buffer map[model.ArtworkID]struct{}
mutex sync.Mutex
cache cache.FileCache
wakeSignal chan struct{}
}
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
@@ -133,7 +132,7 @@ func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) {
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)
errs := pl.Sink(ctx, 2, input, a.doCacheImage)
for err := range errs {
log.Debug(ctx, "Error warming cache", err)
}
@@ -143,14 +142,16 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
size := a.coverArtSize
r, _, err := a.artwork.Get(ctx, id, size, true)
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true)
if err != nil {
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
return fmt.Errorf("caching id='%s': %w", id, err)
}
defer r.Close()
_, err = io.Copy(io.Discard, r)
r.Close()
return err
if err != nil {
return err
}
return nil
}
func NoopCacheWarmer() CacheWarmer {
+1 -24
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"strings"
"sync"
"sync/atomic"
"time"
@@ -174,42 +173,20 @@ var _ = Describe("CacheWarmer", func() {
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
err error
}
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)
}
-469
View File
@@ -1,469 +0,0 @@
package artworke2e_test
import (
"testing/fstest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const (
defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external"
defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded"
)
var _ = Describe("Album artwork resolution", func() {
BeforeEach(func() {
setupHarness()
})
When("an album has a single folder with cover.jpg at the album root", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── cover.jpg ← matched by cover.*
It("returns the album-root cover", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
// https://github.com/navidrome/navidrome/issues/5376
// cover.* basenames tie across album-root and per-disc folders;
// compareImageFiles must prefer shallower paths.
When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg ← should not win
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg
// └── cover.jpg ← should win (album-root fallback)
It("prefers the album-root cover over per-disc covers", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
"Artist/Album/cover.jpg": imageFile("album-root"),
"Artist/Album/CD1/cover.jpg": imageFile("disc1"),
"Artist/Album/CD2/cover.jpg": imageFile("disc2"),
})
scan()
al := firstAlbum()
Expect(al.FolderIDs).To(HaveLen(2),
"sanity check: scanner should treat the two disc subfolders as one multi-disc album")
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
// https://github.com/navidrome/navidrome/issues/5376
// folder.jpg basenames tie across album-root and per-disc folders;
// compareImageFiles must prefer shallower paths.
When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← should not win
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── folder.jpg ← should win (album-root fallback)
It("prefers the album-root folder.jpg over per-disc folder.jpg", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
"Artist/Album/folder.jpg": imageFile("album-root"),
"Artist/Album/CD1/folder.jpg": imageFile("disc1"),
"Artist/Album/CD2/folder.jpg": imageFile("disc2"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
// https://github.com/navidrome/navidrome/issues/5376
// Single-subfolder albums must still consider the parent folder's images.
When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() {
// Artist/
// └── Album/
// ├── disc1/
// │ └── 01 - Track.mp3
// └── cover.jpg ← should win (parent-folder fallback)
It("uses the parent-folder cover for single-disc-subfolder albums", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
// https://github.com/navidrome/navidrome/issues/5456
When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() {
// Album/ (top-level folder, Path=".")
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── cover.jpg ← should win (album-root)
It("prefers the album-root cover.jpg", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
"Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
"Album/cover.jpg": imageFile("album-root"),
"Album/CD1/folder.jpg": imageFile("disc1"),
"Album/CD2/folder.jpg": imageFile("disc2"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded")
// └── cover.jpg
It("returns the embedded image", func() {
conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
"Artist/Album/cover.jpg": imageFile("external"),
})
scan()
// Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream.
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
})
})
When("CoverArtPriority lists external first but no external file is present", func() {
// Artist/
// └── Album/
// └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded")
It("falls through to embedded artwork", func() {
conf.Server.CoverArtPriority = "external, embedded"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
})
scan()
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
})
})
When("the only cover file uses uppercase extension and a different case in its name", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── Cover.JPG ← matched case-insensitively by cover.*
It("matches case-insensitively against cover.*", func() {
conf.Server.CoverArtPriority = "cover.*, folder.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/Cover.JPG": imageFile("case-insensitive"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive")))
})
})
When("two cover files have basenames that tie under the natural-sort tiebreaker", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── cover.jpg ← wins (no numeric suffix)
// └── cover.1.jpg
It("prefers the file without a numeric suffix", func() {
conf.Server.CoverArtPriority = "cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("primary"),
"Artist/Album/cover.1.jpg": imageFile("secondary"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
})
})
When("the album has no cover and CoverArtPriority lists only file patterns", func() {
// Artist/
// └── Album/
// └── 01 - Track.mp3 (no image files — returns ErrUnavailable)
It("returns ErrUnavailable", func() {
conf.Server.CoverArtPriority = "cover.*, folder.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
})
scan()
al := firstAlbum()
_, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt))
Expect(err).To(HaveOccurred())
})
})
// Doc scenarios from:
// https://www.navidrome.org/docs/usage/library/artwork/#albums
// Default CoverArtPriority is "cover.*, folder.*, front.*, embedded, external".
When("only folder.jpg is present (cover.* and front.* missing)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── folder.jpg ← matched by folder.*
It("falls through to folder.jpg", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/folder.jpg": imageFile("folder"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
})
})
When("only front.jpg is present (cover.* and folder.* missing)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── front.jpg ← matched by front.*
It("falls through to front.jpg", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/front.jpg": imageFile("front"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front")))
})
})
When("cover.*, folder.*, and front.* all exist in the same folder", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── cover.jpg ← wins (cover.* is first in priority)
// ├── folder.jpg
// └── front.jpg
It("prefers cover.* (first in CoverArtPriority)", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("cover"),
"Artist/Album/folder.jpg": imageFile("folder"),
"Artist/Album/front.jpg": imageFile("front"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
})
})
When("only folder.* and front.* exist (priority order check)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── folder.jpg ← wins (folder.* comes before front.*)
// └── front.jpg
It("prefers folder.* over front.*", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/folder.jpg": imageFile("folder"),
"Artist/Album/front.jpg": imageFile("front"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
})
})
When("three cover files tie by basename and differ only by numeric suffix", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── cover.jpg ← wins (no numeric suffix)
// ├── cover.1.jpg
// └── cover.2.jpg
It("selects the unsuffixed file first regardless of numeric-suffix order", func() {
conf.Server.CoverArtPriority = "cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.2.jpg": imageFile("second"),
"Artist/Album/cover.jpg": imageFile("primary"),
"Artist/Album/cover.1.jpg": imageFile("first"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
})
})
When("CoverArtPriority contains an unknown pattern before a matching one", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── cover.jpg ← wins (unknown "bogus.*" is skipped)
It("skips the unknown pattern and falls through to the matching one", func() {
conf.Server.CoverArtPriority = "bogus.*, cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("cover"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
})
})
// 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": imageFile("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": imageFile("album-b"),
})
scan()
alA := albumByName("Album A")
_, err := readArtworkOrErr(alA.CoverArtID())
Expect(err).To(HaveOccurred(),
"Album A has no images of its own, so it must fall through to the placeholder "+
"instead of inheriting the artist folder's folder.jpg")
alB := albumByName("Album B")
Expect(readArtwork(alB.CoverArtID())).To(Equal(imageBytes("album-b")))
})
})
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": imageFile("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": imageFile("album-b"),
})
scan()
alA := albumByName("Album A")
Expect(alA.FolderIDs).To(HaveLen(2),
"sanity check: scanner should treat the two sibling folders as one spread album")
_, err := readArtworkOrErr(alA.CoverArtID())
Expect(err).To(HaveOccurred(),
"the spread album has no images of its own, so it must fall through to the "+
"placeholder instead of inheriting the artist folder's folder.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
It("prefers the album's own art over the artist image", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/cover.jpg": imageFile("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": imageFile("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: scanner should treat the two sibling folders as one spread album")
Expect(readArtwork(alA.CoverArtID())).To(Equal(imageBytes("album-a-front")))
})
})
When("embedded is first in CoverArtPriority but the track has no embedded art", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3 (no embedded picture)
// └── cover.jpg ← wins (embedded skipped, falls through)
It("falls through to the next priority entry", func() {
conf.Server.CoverArtPriority = "embedded, cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("cover"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
})
})
})
-167
View File
@@ -1,167 +0,0 @@
package artworke2e_test
import (
"os"
"path/filepath"
"testing/fstest"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Doc reference:
// https://www.navidrome.org/docs/usage/library/artwork/#artists
// Default ArtistArtPriority is "artist.*, album/artist.*, external".
var _ = Describe("Artist artwork resolution", func() {
BeforeEach(func() {
setupHarness()
})
When("the artist folder contains an artist.jpg", func() {
// Artist/
// ├── artist.jpg ← matched by artist.*
// └── Album/
// └── 01 - Track.mp3
It("returns the artist.* image from the artist folder", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/artist.jpg": imageFile("artist-folder"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
})
})
When("artist.* only exists inside an album folder", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── artist.jpg ← matched by album/artist.*
It("falls through to album/artist.* and returns that image", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/Album/artist.jpg": imageFile("album-artist"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
})
})
When("both the artist folder and an album folder have an artist.* image", func() {
// Artist/
// ├── artist.jpg ← wins (artist.* before album/artist.*)
// └── Album/
// ├── 01 - Track.mp3
// └── artist.jpg
It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/artist.jpg": imageFile("artist-folder"),
"Artist/Album/artist.jpg": imageFile("album-artist"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
})
})
When("an artist has an uploaded image and a matching artist.* file", func() {
// <DataFolder>/
// └── artwork/
// └── artist/
// └── <id>_upload.jpg ← wins (uploaded image beats the priority chain)
// Library:
// Artist/
// ├── artist.jpg (ignored — uploaded image comes first)
// └── Album/
// └── 01 - Track.mp3
It("prefers the uploaded image over any priority-chain match", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/artist.jpg": imageFile("artist-folder"),
})
scan()
ar := soleArtist()
uploaded := ar.ID + "_upload.jpg"
writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded"))
ar.UploadedImage = uploaded
Expect(ds.Artist(ctx).Put(&ar)).To(Succeed())
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded")))
})
})
When("ArtistArtPriority uses album/<arbitrary pattern> (not just album/artist.*)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── artist.jpg ← matched by album/artist.*
It("resolves the pattern against the artist's album image files", func() {
conf.Server.ArtistArtPriority = "album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/Album/artist.jpg": imageFile("album-artist"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
})
})
When("ArtistArtPriority starts with image-folder and ArtistImageFolder has a name-matching image", func() {
// <ArtistImageFolder>/
// └── Artist.jpg ← matched by artist name (image-folder source)
// Library:
// Artist/
// └── Album/
// └── 01 - Track.mp3 (no artist.* present in library)
It("returns the image from the configured artist image folder", func() {
imgFolder := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed())
conf.Server.ArtistImageFolder = imgFolder
conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder")))
})
})
})
func soleArtist() model.Artist {
GinkgoHelper()
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"artist.name": "Artist"},
})
Expect(err).ToNot(HaveOccurred())
if len(artists) == 0 {
Fail("sole artist not found")
return model.Artist{}
}
return artists[0]
}
-371
View File
@@ -1,371 +0,0 @@
package artworke2e_test
import (
"fmt"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Disc artwork resolution", func() {
BeforeEach(func() {
setupHarness()
})
When("the album is single-disc with a disc1.jpg in the only folder", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── disc1.jpg ← matched by disc*.*
It("returns the disc1.jpg image (matched as disc*.*)", func() {
conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/disc1.jpg": imageFile("disc1-image"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image")))
})
})
When("the album has no per-disc image and no album cover", func() {
// Artist/
// └── Album/
// └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable)
It("returns ErrUnavailable for the disc lookup", func() {
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
conf.Server.CoverArtPriority = "cover.*, folder.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
_, err := readArtworkOrErr(discID)
Expect(err).To(HaveOccurred())
})
})
When("the album has no per-disc image but has an album cover", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── cover.jpg ← album-level fallback (no disc art present)
It("falls back to the album cover", func() {
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("album-cover"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")))
})
})
When("multiple disc images exist in the same folder (disc1 vs disc10)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── disc1.jpg ← matches request for disc 1
// └── disc10.jpg
It("matches the requested disc number, not a higher-numbered one", func() {
conf.Server.DiscArtPriority = "disc*.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/disc1.jpg": imageFile("disc-one"),
"Artist/Album/disc10.jpg": imageFile("disc-ten"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one")))
})
})
When("a multi-disc album has per-disc covers", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── disc1.jpg ← matches request for disc 1
// └── CD2/
// ├── 01 - Track.mp3
// └── disc2.jpg ← matches request for disc 2
It("returns the requested disc's image", func() {
conf.Server.DiscArtPriority = "disc*.*"
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/disc2.jpg": imageFile("disc-2"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2")))
})
})
// Doc scenarios from:
// https://www.navidrome.org/docs/usage/library/artwork/#disc-cover-art
// Default DiscArtPriority is "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded".
When("a disc subfolder has a cd2.png image", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── disc1.jpg
// └── CD2/
// ├── 01 - Track.mp3
// └── cd2.png ← matched by cd*.* for disc 2
It("matches via the cd*.* pattern", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2")))
})
})
When("a disc subfolder has cover.jpg but no disc*.*/cd*.* image", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg ← matched by cover.* inside disc folder
// └── CD2/
// ├── 01 - Track.mp3
// └── cover.jpg
It("falls through to cover.* inside the disc folder", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"),
"Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover")))
})
})
When("DiscArtPriority is the empty string", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── disc1.jpg (ignored — DiscArtPriority is empty)
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── cd2.png (ignored — DiscArtPriority is empty)
// └── cover.jpg ← used for every disc (album-level fallback)
It("skips every disc-level source and returns the album cover", func() {
conf.Server.DiscArtPriority = ""
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
"Artist/Album/cover.jpg": imageFile("album-cover"),
})
scan()
al := firstAlbum()
for _, n := range []int{1, 2} {
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")),
"disc %d should use the album cover when DiscArtPriority is empty", n)
}
})
})
When("the documented multi-disc layout is used (disc1.jpg + cd2.png + album-root cover.jpg)", func() {
// Artist/
// └── Album/
// ├── disc1/
// │ ├── disc1.jpg ← matched by disc*.* for disc 1
// │ ├── 01 - Track.mp3
// │ └── 02 - Track.mp3
// ├── disc2/
// │ ├── cd2.png ← matched by cd*.* for disc 2
// │ ├── 01 - Track.mp3
// │ └── 02 - Track.mp3
// └── cover.jpg (album-level fallback, unused here)
It("matches the per-disc image for each disc", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}),
"Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}),
"Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}),
"Artist/Album/disc1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/disc2/cd2.png": imageFile("cd-2"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
al := firstAlbum()
disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1")))
Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2")))
})
})
When("discsubtitle keyword matches an image whose stem equals the disc's subtitle", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
// └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword
It("selects the subtitle-named image", func() {
conf.Server.DiscArtPriority = "discsubtitle"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
"Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks")))
})
})
// Reproduces https://github.com/navidrome/navidrome/issues/5456
// Deeply nested layout matching the reporter's actual structure.
When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() {
// Genre/Artist/Album/ ← album root with cover.jpg
// ├── cover.jpg ← album-level cover
// ├── Disc 01 (Subtitle)/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← disc 1 art
// ├── Disc 02 (Subtitle)/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── ... (12 discs)
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
conf.Server.CoverArtPriority = defaultCoverPriority
discNames := []string{
"Disc 01 (Birth of the Dead - The Studio Sides)",
"Disc 02 (Birth of the Dead - The Live Sides)",
"Disc 03 (The Grateful Dead)",
"Disc 04 (Anthem of the Sun)",
"Disc 05 (Aoxomoxoa)",
"Disc 06 (Live; Dead)",
"Disc 07 (Workingman's Dead)",
"Disc 08 (American Beauty)",
"Disc 09 (Grateful Dead)",
"Disc 10 (Europe '72)",
"Disc 11 (Europe '72)",
"Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))",
}
layout := fstest.MapFS{
"Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"),
}
for i, name := range discNames {
discNum := i + 1
prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name)
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)})
layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum))
}
setLayout(layout)
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
for i := range discNames {
discNum := i + 1
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))),
"disc %d should use its own folder.jpg", discNum)
}
})
})
// https://github.com/navidrome/navidrome/issues/5456
// Top-level album variant — album folder at library root (Path=".").
When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() {
// Album/ (top-level, Path=".")
// ├── cover.jpg ← album-level cover
// ├── Disc 01/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← disc 1 art
// ├── Disc 02/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── Disc 03/
// ├── 01 - Track.mp3
// └── folder.jpg
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
conf.Server.CoverArtPriority = defaultCoverPriority
layout := fstest.MapFS{
"Album/cover.jpg": imageFile("album-root-cover"),
}
for i := 1; i <= 3; i++ {
prefix := fmt.Sprintf("Album/Disc %02d/", i)
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)})
layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i))
}
setLayout(layout)
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
for i := 1; i <= 3; i++ {
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))),
"disc %d should use its own folder.jpg", i)
}
})
})
When("discsubtitle is set but no image filename matches the subtitle", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
// └── cover.jpg ← wins (discsubtitle has no match, falls through)
It("falls through to the next priority entry", func() {
conf.Server.DiscArtPriority = "discsubtitle, cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
"Artist/Album/cover.jpg": imageFile("cover"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("cover")))
})
})
})
-187
View File
@@ -1,187 +0,0 @@
package artworke2e_test
import (
"bytes"
"context"
_ "embed"
"errors"
"hash/fnv"
"image"
"image/color"
"image/png"
"io"
"maps"
"net/url"
"os"
"path/filepath"
"testing/fstest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.senan.xyz/taglib"
)
// realMP3WithEmbeddedArt is the bytes of the canonical test fixture that
// contains a valid MP3 stream with an embedded picture. Used in the
// embedded-art e2e scenarios where FakeFS's JSON-encoded tag data isn't
// readable by taglib. Swap this into fakeFS.MapFS *after* scanning so the
// scanner still populates EmbedArtPath via the JSON-tagged track, and the
// artwork reader gets real bytes when it calls libFS.Open.
//
//go:embed testdata/embedded_art.mp3
var realMP3WithEmbeddedArt []byte
// embeddedArtBytes is the exact image payload that the artwork reader will
// extract from realMP3WithEmbeddedArt. Computed once via taglib so tests can
// assert byte-for-byte equality — if this ever differs it means the reader
// pulled from a different source.
var embeddedArtBytes = extractEmbeddedArt(realMP3WithEmbeddedArt)
func extractEmbeddedArt(mp3 []byte) []byte {
tf, err := taglib.OpenStream(bytes.NewReader(mp3))
if err != nil {
panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error())
}
defer tf.Close()
images := tf.Properties().Images
if len(images) == 0 {
panic("embedded-art fixture has no embedded images")
}
data, err := tf.Image(0)
if err != nil || len(data) == 0 {
panic("embedded-art fixture: could not read image 0")
}
return data
}
// replaceWithRealMP3 swaps the FakeFS entry at the given library-relative
// path so libFS.Open returns an MP3 stream taglib can parse.
func replaceWithRealMP3(relPath string) {
GinkgoHelper()
fakeFS.MapFS[relPath] = &fstest.MapFile{Data: realMP3WithEmbeddedArt}
}
// placeholderBytes returns the bundled album-placeholder image bytes — the
// same stream the artwork reader emits when every source falls through.
func placeholderBytes() []byte {
GinkgoHelper()
r, err := resources.FS().Open(consts.PlaceholderAlbumArt)
Expect(err).ToNot(HaveOccurred())
defer r.Close()
data, err := io.ReadAll(r)
Expect(err).ToNot(HaveOccurred())
return data
}
// writeUploadedImage drops `filename` into <DataFolder>/artwork/<entity>/ with
// the given bytes, matching the on-disk layout expected by
// model.UploadedImagePath.
func writeUploadedImage(entity, filename string, data []byte) {
GinkgoHelper()
dir := filepath.Dir(model.UploadedImagePath(entity, filename))
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, filename), data, 0600)).To(Succeed())
}
func newNoopFFmpeg() *tests.MockFFmpeg {
ff := tests.NewMockFFmpeg("")
ff.Error = errors.New("noop")
return ff
}
// trackFile builds a FakeFS MP3 entry with optional tag overrides.
func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile {
tags := storagetest.Track(num, title)
for _, e := range extra {
maps.Copy(tags, e)
}
return storagetest.MP3(tags)
}
// imageFile builds a label-keyed image entry. The bytes are deterministic
// per-label so tests can assert which file won.
func imageFile(label string) *fstest.MapFile {
return &fstest.MapFile{Data: []byte("image:" + label)}
}
// realPNG builds a minimal 2x2 PNG with a color derived from label. Needed by
// tests that feed the bytes into image.Decode (e.g. playlist tiled covers).
func realPNG(label string) *fstest.MapFile {
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
// Derive a deterministic color per label.
h := fnv.New32a()
_, _ = h.Write([]byte(label))
sum := h.Sum32()
c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255}
for y := range 2 {
for x := range 2 {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
Expect(png.Encode(&buf, img)).To(Succeed())
return &fstest.MapFile{Data: buf.Bytes()}
}
// imageBytes returns the bytes that imageFile(label) writes.
func imageBytes(label string) []byte { return imageFile(label).Data }
// setLayout populates fakeFS with the given map. Call after setupHarness.
// All paths must be forward-slash and relative (no leading "/").
func setLayout(files fstest.MapFS) {
GinkgoHelper()
fakeFS.SetFiles(files)
}
func readArtwork(artID model.ArtworkID) []byte {
GinkgoHelper()
r, _, err := aw.Get(ctx, artID, 0, false)
Expect(err).ToNot(HaveOccurred())
defer r.Close()
b, err := io.ReadAll(r)
Expect(err).ToNot(HaveOccurred())
return b
}
func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) {
r, _, err := aw.Get(ctx, artID, 0, false)
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
// noopProvider implements external.Provider with not-found returns so the
// "external" priority entry never produces a result.
type noopProvider struct{}
func (n *noopProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) {
return nil, model.ErrNotFound
}
func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) {
return nil, model.ErrNotFound
}
func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
return nil, nil
}
func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) {
return nil, nil
}
func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) {
return nil, model.ErrNotFound
}
func (n *noopProvider) ArtistImageResult(context.Context, string) (*url.URL, error) {
return nil, model.ErrNotFound
}
func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) {
return nil, model.ErrNotFound
}
var _ external.Provider = (*noopProvider)(nil)
-110
View File
@@ -1,110 +0,0 @@
package artworke2e_test
import (
"testing/fstest"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Doc reference:
// https://www.navidrome.org/docs/usage/library/artwork/#mediafiles
// Navidrome resolves mediafile artwork in this order:
// 1. Embedded image from the mediafile itself
// 2. For multi-disc albums, disc-level artwork
// 3. Album cover art
//
// FakeFS cannot synthesize taglib-readable embedded JPEGs, so scenario (1)
// is covered by the existing embedded-art album tests (which currently
// Skip under FakeFS). The tests below cover (2) and (3): the fallback
// chain for tracks without embedded art.
var _ = Describe("MediaFile artwork fallback", func() {
BeforeEach(func() {
setupHarness()
})
When("a multi-disc album track has no embedded art", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── disc1.jpg
// ├── CD2/
// │ ├── 01 - Track.mp3 ← track requested
// │ └── disc2.jpg ← wins (disc-level before album-level)
// └── cover.jpg
It("falls back to the disc-level artwork (not the album cover)", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/disc2.jpg": imageFile("disc-2"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3")
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2")))
})
})
When("a single-disc album track has no embedded art", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3 ← track requested
// └── cover.jpg ← wins (album-level fallback, no disc subfolder)
It("falls back to the album cover", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("album-cover"),
})
scan()
mf := mediafileOn("Artist/Album/01 - Track.mp3")
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover")))
})
})
When("a multi-disc album track has no embedded art and the disc has no disc-level image", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ └── 01 - Track.mp3
// ├── CD2/
// │ └── 01 - Track.mp3 ← track requested
// └── cover.jpg ← wins (no disc image → album-level fallback)
It("falls through from disc to album cover", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3")
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
})
func mediafileOn(relPath string) model.MediaFile {
GinkgoHelper()
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Like{"media_file.path": relPath},
})
Expect(err).ToNot(HaveOccurred())
if len(mfs) == 0 {
Fail("mediafile not found: " + relPath)
return model.MediaFile{}
}
return mfs[0]
}
-158
View File
@@ -1,158 +0,0 @@
package artworke2e_test
import (
"os"
"path/filepath"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Playlist artwork resolves in this priority order:
// 1. Uploaded image (<DataFolder>/artwork/playlist/<file>)
// 2. Sidecar image next to the .m3u file (same basename, any image ext)
// 3. ExternalImageURL (http/https requires EnableM3UExternalAlbumArt; local path always allowed)
// 4. Generated 2x2 tiled cover from the playlist's albums
// 5. Album placeholder image
//
// The library FS is FakeFS, but uploaded/sidecar/local-external images are
// real files on disk — the reader reads them via os.Open, so the tests
// place them in a real tempdir under DataFolder.
var _ = Describe("Playlist artwork resolution", func() {
BeforeEach(func() {
setupHarness()
})
When("a playlist has an uploaded image", func() {
// <DataFolder>/
// └── artwork/
// └── playlist/
// └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority)
It("returns the uploaded image bytes", func() {
writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", imageBytes("playlist-upload"))
pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"})
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload")))
})
})
When("a playlist has no uploaded image but a sidecar image beside its .m3u file", func() {
// <tempdir>/
// ├── MyList.m3u
// └── MyList.jpg ← matched by sidecar (same basename, case-insensitive)
It("returns the sidecar image", func() {
dir := GinkgoT().TempDir()
m3uPath := filepath.Join(dir, "MyList.m3u")
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), imageBytes("sidecar"), 0600)).To(Succeed())
pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath})
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar")))
})
})
When("a playlist's sidecar uses a different extension case", func() {
// <tempdir>/
// ├── MyList.m3u
// └── MyList.PNG ← matched case-insensitively
It("matches case-insensitively", func() {
dir := GinkgoT().TempDir()
m3uPath := filepath.Join(dir, "MyList.m3u")
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), imageBytes("sidecar-png"), 0600)).To(Succeed())
pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath})
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png")))
})
})
When("a playlist has an ExternalImageURL pointing to a local file", func() {
// <tempdir>/
// └── cover.jpg ← absolute path stored in ExternalImageURL
It("returns the local file regardless of EnableM3UExternalAlbumArt", func() {
conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, imageBytes("external-local"), 0600)).To(Succeed())
pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath})
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("external-local")))
})
})
When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() {
// (no local files — http source is gated off, reader falls through to placeholder)
It("skips the URL and falls through to the bundled placeholder", func() {
conf.Server.EnableM3UExternalAlbumArt = false
pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"})
Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes()))
})
})
When("a playlist has no images and no tracks", func() {
// (reader falls all the way through to the bundled album placeholder)
It("returns the album placeholder", func() {
pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"})
Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes()))
})
})
When("a playlist has no uploaded/sidecar/external image but has tracks with album covers", func() {
// Library:
// Artist/
// ├── AlbumA/
// │ ├── 01 - Track.mp3
// │ └── cover.png (real PNG — wins as tile 1 source)
// └── AlbumB/
// ├── 01 - Track.mp3
// └── cover.png (real PNG — wins as tile 2 source)
// Playlist "pl-7" references tracks from both albums, so the reader
// generates a 2x2 tiled cover from 2 distinct album art tiles (the
// tiled generator mirrors when it has fewer than 4 unique tiles).
It("generates a tiled cover from album art", func() {
conf.Server.CoverArtPriority = "cover.*"
setLayout(fstest.MapFS{
"Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}),
"Artist/AlbumA/cover.png": realPNG("albumA"),
"Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}),
"Artist/AlbumB/cover.png": realPNG("albumB"),
})
scan()
// Pull the scanned mediafile IDs so we can attach them to the playlist.
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(2))
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed())
data := readArtwork(pl.CoverArtID())
// The tiled cover is a PNG-encoded 600x600 image (tileSize const).
// Exact bytes vary (random album order), so assert format + non-trivial size.
Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}))
Expect(len(data)).To(BeNumerically(">", 1000))
})
})
})
func putPlaylist(pl model.Playlist) model.Playlist {
GinkgoHelper()
if pl.OwnerID == "" {
pl.OwnerID = "admin-1"
}
Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed())
return pl
}
Loaded 100 of 1122 files, more files were not shown because too many files have changed in this diff. Show more