mirror of
https://github.com/navidrome/navidrome.git
synced 2026-09-09 04:00:15 -04:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4b1830513 | ||
|
|
eca4c5acf0 | ||
|
|
e766a5d780 | ||
|
|
90d6cd5f47 | ||
|
|
24ab04581a | ||
|
|
8e647a0e41 | ||
|
|
86c326bd4a | ||
|
|
929e7193b4 | ||
|
|
9bcefea0ca | ||
|
|
b0cb40b029 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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: |
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
+42
-143
@@ -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,87 +126,14 @@ 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/setup-node@v7
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -37,6 +37,4 @@ AGENTS.md
|
||||
*.wasm
|
||||
*.ndp
|
||||
openspec/
|
||||
.agents
|
||||
go.work*
|
||||
.worktrees/
|
||||
go.work*
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
bytes "bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: "a‐ha"}},
|
||||
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: "a‐ha",
|
||||
ArtistMBID: "",
|
||||
Album: "Hunting High and Low",
|
||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||
Duration: 0,
|
||||
},
|
||||
{
|
||||
ID: "",
|
||||
Name: "Wake Me Up Before You Go‐Go",
|
||||
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 Go‐Go",
|
||||
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: "a‐ha"}},
|
||||
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: "a‐ha",
|
||||
ArtistMBID: "",
|
||||
Album: "Hunting High and Low",
|
||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||
Duration: 0,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 stream
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestStream(t *testing.T) {
|
||||
func TestSpotify(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Stream Suite")
|
||||
RunSpecs(t, "Spotify Test Suite")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package taglib
|
||||
|
||||
import "C"
|
||||
|
||||
func getFilename(s string) *C.char {
|
||||
return C.CString(s)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
-20
@@ -27,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 (
|
||||
@@ -86,7 +88,7 @@ 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))
|
||||
g.Go(runInitialScan(ctx))
|
||||
if conf.Server.Scanner.Enabled {
|
||||
@@ -124,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
|
||||
@@ -197,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)
|
||||
@@ -278,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
|
||||
}
|
||||
|
||||
+4
-36
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
-64
@@ -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
|
||||
}
|
||||
@@ -247,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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
-320
@@ -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,11 +43,10 @@ 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
|
||||
@@ -63,8 +58,8 @@ type configOptions struct {
|
||||
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
|
||||
@@ -73,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
|
||||
@@ -92,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
|
||||
@@ -113,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
|
||||
@@ -138,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
|
||||
@@ -204,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
|
||||
@@ -219,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
|
||||
}
|
||||
@@ -252,7 +222,7 @@ type jukeboxOptions struct {
|
||||
|
||||
type backupOptions struct {
|
||||
Count int
|
||||
Path Dir
|
||||
Path string
|
||||
Schedule string
|
||||
}
|
||||
|
||||
@@ -270,7 +240,7 @@ type inspectOptions struct {
|
||||
|
||||
type pluginsOptions struct {
|
||||
Enabled bool
|
||||
Folder Dir
|
||||
Folder string
|
||||
CacheSize string
|
||||
AutoReload bool
|
||||
LogLevel string
|
||||
@@ -279,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 (
|
||||
@@ -310,92 +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")
|
||||
|
||||
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)
|
||||
@@ -408,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 = ""
|
||||
@@ -461,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 {
|
||||
@@ -507,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) {
|
||||
@@ -570,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,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 = ""
|
||||
@@ -600,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
|
||||
@@ -627,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 = ""
|
||||
@@ -663,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
|
||||
}
|
||||
@@ -673,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
|
||||
@@ -751,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", "")
|
||||
@@ -760,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)
|
||||
@@ -770,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)
|
||||
@@ -799,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", "")
|
||||
@@ -834,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", "")
|
||||
@@ -879,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)
|
||||
@@ -893,13 +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("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
|
||||
viper.SetDefault("devexternalscanner", true)
|
||||
@@ -910,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() {
|
||||
@@ -948,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,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())
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
ND_ADDRESS = "127.0.0.1"
|
||||
Address = "0.0.0.0"
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
ND_ADDRESS = "127.0.0.1"
|
||||
ND_PORT = 4531
|
||||
ND_SCANNER_SCHEDULE = "@every 1h"
|
||||
+9
-42
@@ -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,8 +25,7 @@ const (
|
||||
DefaultSessionTimeout = 48 * time.Hour
|
||||
CookieExpiry = 365 * 24 * 3600 // One year
|
||||
|
||||
DBAnalyzeCheckSchedule = "@every 30m"
|
||||
DBAnalyzeMaxAge = 24 * time.Hour
|
||||
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
|
||||
@@ -49,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
|
||||
@@ -78,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
|
||||
|
||||
@@ -98,11 +83,6 @@ const (
|
||||
Zwsp = string('\u200b')
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultUICoverArtSize = 300
|
||||
DefaultMaxImageUploadSize = "10MB"
|
||||
)
|
||||
|
||||
// Prometheus options
|
||||
const (
|
||||
PrometheusDefaultPath = "/metrics"
|
||||
@@ -121,13 +101,6 @@ const (
|
||||
DefaultCacheCleanUpInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Entity types
|
||||
const (
|
||||
EntityArtist = "artist"
|
||||
EntityPlaylist = "playlist"
|
||||
EntityRadio = "radio"
|
||||
)
|
||||
|
||||
const (
|
||||
AlbumPlayCountModeAbsolute = "absolute"
|
||||
AlbumPlayCountModeNormalized = "normalized"
|
||||
@@ -166,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 -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"url": "https://context7.com/navidrome/navidrome",
|
||||
"public_key": "pk_WqzhKScNKWQ84J4n0oG0J"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
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"
|
||||
@@ -23,49 +15,3 @@ func TestArtwork(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Artwork Suite")
|
||||
}
|
||||
|
||||
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
|
||||
// ReadTags is not used by albumArtworkReader, so it is left as a stub.
|
||||
type osDirFS struct{ fs.FS }
|
||||
|
||||
func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil }
|
||||
|
||||
// testFileScheme is the URL scheme registered to expose a tempdir as a
|
||||
// storage.MusicFS for artwork integration tests.
|
||||
const testFileScheme = "testfile"
|
||||
|
||||
// testFileLibPath builds a `testfile://` library URL for the given absolute
|
||||
// filesystem path. On Windows, the native path (e.g. `C:\foo`) has no leading
|
||||
// slash after ToSlash, which makes url.Parse treat the drive letter as a
|
||||
// host. We prepend a `/` so parsing yields `u.Path == /C:/foo`, and the
|
||||
// registered constructor below strips that leading slash back off.
|
||||
func testFileLibPath(absPath string) string {
|
||||
p := filepath.ToSlash(absPath)
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
return testFileScheme + "://" + p
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Register the testfile storage scheme (os.DirFS-backed MusicFS). Used by
|
||||
// integration tests that need real files but not the taglib extractor.
|
||||
storage.Register(testFileScheme, func(u url.URL) storage.Storage {
|
||||
root := u.Path
|
||||
// Undo the leading slash added by testFileLibPath on Windows so that
|
||||
// os.Stat / os.DirFS receive a native path like `C:\foo`.
|
||||
if runtime.GOOS == "windows" && len(root) >= 3 && root[0] == '/' && root[2] == ':' {
|
||||
root = root[1:]
|
||||
}
|
||||
return &osDirStorage{root: filepath.FromSlash(root)}
|
||||
})
|
||||
}
|
||||
|
||||
type osDirStorage struct{ root string }
|
||||
|
||||
func (s *osDirStorage) FS() (storage.MusicFS, error) {
|
||||
if _, err := os.Stat(s.root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return osDirFS{os.DirFS(s.root)}, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,184 +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) AlbumImage(context.Context, string) (*url.URL, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
var _ external.Provider = (*noopProvider)(nil)
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Radio artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupHarness()
|
||||
})
|
||||
|
||||
When("a radio has an uploaded image", func() {
|
||||
// <DataFolder>/
|
||||
// └── artwork/
|
||||
// └── radio/
|
||||
// └── rd-1_logo.jpg ← matched by UploadedImagePath()
|
||||
It("returns the uploaded image bytes", func() {
|
||||
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", imageBytes("radio-logo"))
|
||||
|
||||
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
|
||||
Expect(ds.Radio(ctx).Put(&rd)).To(Succeed())
|
||||
|
||||
artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("radio-logo")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a radio has no uploaded image", func() {
|
||||
// (no files on disk — reader has no sources to fall back to)
|
||||
It("returns ErrUnavailable", func() {
|
||||
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
|
||||
Expect(ds.Radio(ctx).Put(&rd)).To(Succeed())
|
||||
|
||||
artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil)
|
||||
_, err := readArtworkOrErr(artID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestArtworkE2E(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Artwork E2E Suite")
|
||||
}
|
||||
|
||||
const fakeLibScheme = "artworkfake"
|
||||
const fakeLibPath = fakeLibScheme + ":///music"
|
||||
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
aw artwork.Artwork
|
||||
fakeFS *storagetest.FakeFS
|
||||
)
|
||||
|
||||
// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps
|
||||
// the file open for the whole suite, and Ginkgo's per-spec TempDir cleanup
|
||||
// can't unlink a file with a live handle on Windows. A suite-level tempdir
|
||||
// combined with an AfterSuite close avoids the lock conflict.
|
||||
var suiteDBTempDir string
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
suiteDBTempDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
var _ = AfterSuite(func() {
|
||||
db.Close(GinkgoT().Context())
|
||||
})
|
||||
|
||||
func setupHarness() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
tempDir := GinkgoT().TempDir()
|
||||
// Reuse the suite-level DB path so the singleton connection keeps working
|
||||
// across specs (see suiteDBTempDir comment).
|
||||
conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL"
|
||||
conf.Server.DataFolder = conf.NewDir(tempDir)
|
||||
conf.Server.MusicFolder = fakeLibPath
|
||||
conf.Server.DevExternalScanner = false
|
||||
conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call
|
||||
conf.Server.EnableExternalServices = false
|
||||
|
||||
db.Db().SetMaxOpenConns(1)
|
||||
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true})
|
||||
db.Init(ctx)
|
||||
DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) })
|
||||
|
||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
|
||||
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
||||
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
|
||||
Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
fakeFS = &storagetest.FakeFS{}
|
||||
storagetest.Register(fakeLibScheme, fakeFS)
|
||||
|
||||
aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{})
|
||||
}
|
||||
|
||||
func scan() {
|
||||
GinkgoHelper()
|
||||
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
|
||||
_, err := s.ScanAll(ctx, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
func firstAlbum() model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
||||
return albums[0]
|
||||
}
|
||||
|
||||
func albumByName(name string) model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, al := range albums {
|
||||
if al.Name == name {
|
||||
return al
|
||||
}
|
||||
}
|
||||
Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums)))
|
||||
return model.Album{}
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -1,44 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// libraryView bundles the MusicFS for a library with its absolute root path,
|
||||
// so readers can open library-relative paths through FS and compose absolute
|
||||
// paths (for ffmpeg, which is path-based) via Abs.
|
||||
type libraryView struct {
|
||||
FS storage.MusicFS
|
||||
absRoot string
|
||||
}
|
||||
|
||||
// Abs returns the absolute path for a library-relative path. Returns "" for an
|
||||
// empty rel so callers (fromFFmpegTag) can treat it as "no path available".
|
||||
func (v libraryView) Abs(rel string) string {
|
||||
if rel == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(v.absRoot, rel)
|
||||
}
|
||||
|
||||
// loadLibraryView resolves the MusicFS and absolute root path in a single
|
||||
// library lookup.
|
||||
func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) {
|
||||
lib, err := ds.Library(ctx).Get(libID)
|
||||
if err != nil {
|
||||
return libraryView{}, err
|
||||
}
|
||||
s, err := storage.For(lib.Path)
|
||||
if err != nil {
|
||||
return libraryView{}, err
|
||||
}
|
||||
fs, err := s.FS()
|
||||
if err != nil {
|
||||
return libraryView{}, err
|
||||
}
|
||||
return libraryView{FS: fs, absRoot: lib.Path}, nil
|
||||
}
|
||||
Loaded 100 of 1094 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user