diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 4fc7a5b73..b2aa76450 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -9,12 +9,9 @@ ARG INSTALL_NODE="true"
ARG NODE_VERSION="lts/*"
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
-# [Optional] Uncomment this section to install additional OS packages.
+# Install additional OS packages
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
- && apt-get -y install --no-install-recommends libtag1-dev ffmpeg
-
-# [Optional] Uncomment the next line to use go get to install anything else you need
-# RUN go get -x
+ && apt-get -y install --no-install-recommends ffmpeg
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index f339f62f7..c9e4ba2bf 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -4,10 +4,10 @@
"dockerfile": "Dockerfile",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
- "VARIANT": "1.24",
+ "VARIANT": "1.26",
// Options
"INSTALL_NODE": "true",
- "NODE_VERSION": "v20"
+ "NODE_VERSION": "v24"
}
},
"workspaceMount": "",
@@ -54,12 +54,10 @@
4533,
4633
],
- // Use 'postCreateCommand' to run commands after the container is created.
- // "postCreateCommand": "make setup-dev",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode",
"remoteEnv": {
"ND_MUSICFOLDER": "./music",
"ND_DATAFOLDER": "./data"
}
-}
+}
\ No newline at end of file
diff --git a/.dockerignore b/.dockerignore
index 596aa2955..eb012c6e2 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -15,4 +15,5 @@ dist
binaries
cache
music
+music.old
!Dockerfile
\ No newline at end of file
diff --git a/.github/actions/download-taglib/action.yml b/.github/actions/download-taglib/action.yml
deleted file mode 100644
index ea6de8783..000000000
--- a/.github/actions/download-taglib/action.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-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
diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml
index 760a0528b..b8cde4aaf 100644
--- a/.github/actions/prepare-docker/action.yml
+++ b/.github/actions/prepare-docker/action.yml
@@ -53,13 +53,13 @@ runs:
- name: Login to Docker Hub
if: inputs.hub_username != '' && inputs.hub_password != ''
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
username: ${{ inputs.hub_username }}
password: ${{ inputs.hub_password }}
- name: Login to GitHub Container Registry
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -67,12 +67,13 @@ runs:
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
- name: Extract metadata for Docker image
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
+ github-token: ${{ inputs.github_token }}
labels: |
maintainer=deluan@navidrome.org
images: |
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
deleted file mode 100644
index 73ad6e727..000000000
--- a/.github/copilot-instructions.md
+++ /dev/null
@@ -1,53 +0,0 @@
-# Navidrome Code Guidelines
-
-This is a music streaming server written in Go with a React frontend. The application manages music libraries, provides streaming capabilities, and offers various features like artist information, artwork handling, and external service integrations.
-
-## Code Standards
-
-### Backend (Go)
-- Follow standard Go conventions and idioms
-- Use context propagation for cancellation signals
-- Write unit tests for new functionality using Ginkgo/Gomega
-- Use mutex appropriately for concurrent operations
-- Implement interfaces for dependencies to facilitate testing
-
-### Frontend (React)
-- Use functional components with hooks
-- Follow React best practices for state management
-- Implement PropTypes for component properties
-- Prefer using React-Admin and Material-UI components
-- Icons should be imported from `react-icons` only
-- Follow existing patterns for API interaction
-
-## Repository Structure
-- `core/`: Server-side business logic (artwork handling, playback, etc.)
-- `ui/`: React frontend components
-- `model/`: Data models and repository interfaces
-- `server/`: API endpoints and server implementation
-- `utils/`: Shared utility functions
-- `persistence/`: Database access layer
-- `scanner/`: Music library scanning functionality
-
-## Key Guidelines
-1. Maintain cache management patterns for performance
-2. Follow the existing concurrency patterns (mutex, atomic)
-3. Use the testing framework appropriately (Ginkgo/Gomega for Go)
-4. Keep UI components focused and reusable
-5. Document configuration options in code
-6. Consider performance implications when working with music libraries
-7. Follow existing error handling patterns
-8. Ensure compatibility with external services (LastFM, Spotify)
-
-## Development Workflow
-- Test changes thoroughly, especially around concurrent operations
-- Validate both backend and frontend interactions
-- Consider how changes will affect user experience and performance
-- Test with different music library sizes and configurations
-- Before committing, ALWAYS run `make format lint test`, and make sure there are no issues
-
-## Important commands
-- `make build`: Build the application
-- `make test`: Run Go tests
-- To run tests for a specific package, use `make test PKG=./pkgname/...`
-- `make lintall`: Run linters
-- `make format`: Format code
\ No newline at end of file
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..10431e909
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,38 @@
+### Description
+
+
+### Related Issues
+
+
+### Type of Change
+- [ ] Bug fix
+- [ ] New feature
+- [ ] Documentation update
+- [ ] Refactor
+- [ ] Other (please describe):
+
+### Checklist
+Please review and check all that apply:
+
+- [ ] My code follows the project’s coding style
+- [ ] I have tested the changes locally
+- [ ] I have added or updated documentation as needed
+- [ ] I have added tests that prove my fix/feature works (or explain why not)
+- [ ] All existing and new tests pass
+
+### How to Test
+
+
+### Screenshots / Demos (if applicable)
+
+
+### Additional Notes
+
+
+
\ No newline at end of file
diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml
index 38b7b8a86..076f963d4 100644
--- a/.github/workflows/download-link-on-pr.yml
+++ b/.github/workflows/download-link-on-pr.yml
@@ -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@v3
+ - uses: actions/github-script@v7
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
@@ -19,8 +19,7 @@ jobs:
const pull_user_id = ${{github.event.sender.id}};
const issue_number = await (async () => {
- const pulls = await github.pulls.list({owner, repo});
- for await (const {data} of github.paginate.iterator(pulls)) {
+ for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) {
for (const pull of data) {
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
return pull.number;
@@ -34,7 +33,7 @@ jobs:
return core.error(`No matching pull request found`);
}
- const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
+ const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
@@ -43,12 +42,12 @@ jobs:
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
- const {data: comments} = await github.issues.listComments({repo, owner, issue_number});
+ const {data: comments} = await github.rest.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.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
+ await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
} else {
core.info(`Creating a comment`);
- await github.issues.createComment({repo, owner, issue_number, body});
+ await github.rest.issues.createComment({repo, owner, issue_number, body});
}
diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml
index 4ac1b2c6b..6f858a5a7 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -14,7 +14,6 @@ concurrency:
cancel-in-progress: true
env:
- CROSS_TAGLIB_VERSION: "2.0.2-1"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
jobs:
@@ -25,7 +24,7 @@ jobs:
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
@@ -63,22 +62,21 @@ jobs:
name: Lint Go code
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - name: Download TagLib
- uses: ./.github/actions/download-taglib
+ - uses: actions/setup-go@v6
with:
- version: ${{ env.CROSS_TAGLIB_VERSION }}
+ go-version-file: go.mod
- name: golangci-lint
- uses: golangci/golangci-lint-action@v8
+ uses: golangci/golangci-lint-action@v9
with:
version: latest
problem-matchers: true
args: --timeout 2m
- name: Run go goimports
- run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$'`
+ run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$'`
- run: go mod tidy
- name: Verify no changes from goimports and go mod tidy
run: |
@@ -88,25 +86,112 @@ jobs:
exit 1
fi
+ - name: Run go generate
+ run: go generate ./...
+ - name: Verify no changes from go generate
+ run: |
+ git status --porcelain
+ if [ -n "$(git status --porcelain)" ]; then
+ echo 'Generated code is out of date. Run "make gen" and commit the changes'
+ exit 1
+ fi
+
go:
name: Test Go code
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- - name: Download TagLib
- uses: ./.github/actions/download-taglib
+ - uses: actions/setup-go@v6
with:
- version: ${{ env.CROSS_TAGLIB_VERSION }}
+ go-version-file: go.mod
- name: Download dependencies
run: go mod download
- name: Test
+ run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
+
+ - name: Test ndpgen
run: |
- pkg-config --define-prefix --cflags --libs taglib # for debugging
- go test -shuffle=on -tags netgo -race -cover ./... -v
+ cd plugins/cmd/ndpgen
+ go test -shuffle=on -v
+ 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@v6
+
+ - 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@v5
+ 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
@@ -114,10 +199,10 @@ jobs:
env:
NODE_OPTIONS: "--max_old_space_size=4096"
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@v6
+ - uses: actions/setup-node@v6
with:
- node-version: 20
+ node-version: 24
cache: "npm"
cache-dependency-path: "**/package-lock.json"
@@ -145,7 +230,7 @@ jobs:
name: Lint i18n files
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- run: |
set -e
for file in resources/i18n/*.json; do
@@ -157,6 +242,8 @@ jobs:
exit 1
fi
done
+ - run: ./.github/workflows/validate-translations.sh -v
+
check-push-enabled:
name: Check Docker configuration
@@ -170,10 +257,10 @@ jobs:
build:
name: Build
- needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
+ needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled]
strategy:
matrix:
- platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
+ 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 ]
runs-on: ubuntu-latest
env:
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
@@ -189,7 +276,7 @@ jobs:
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
@@ -201,7 +288,7 @@ jobs:
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Build Binaries
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
@@ -212,10 +299,9 @@ jobs:
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
- CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
@@ -224,7 +310,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@v6
+ uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
@@ -233,7 +319,6 @@ 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
@@ -246,7 +331,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
@@ -254,18 +339,55 @@ jobs:
if-no-files-found: error
retention-days: 1
- push-manifest:
- name: Push Docker manifest
+ push-manifest-ghcr:
+ name: Push to GHCR
+ permissions:
+ contents: read
+ packages: write
runs-on: ubuntu-latest
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true'
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Download digests
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
+ with:
+ path: /tmp/digests
+ pattern: digests-*
+ merge-multiple: true
+
+ - name: Prepare Docker Buildx
+ uses: ./.github/actions/prepare-docker
+ id: docker
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Create manifest list and push to ghcr.io
+ working-directory: /tmp/digests
+ run: |
+ docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io"))) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
+ $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
+
+ - name: Inspect image in ghcr.io
+ run: |
+ docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
+
+ push-manifest-dockerhub:
+ name: Push to Docker Hub
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ needs: [build, check-push-enabled]
+ if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Download digests
+ uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digests-*
@@ -280,28 +402,27 @@ jobs:
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- - name: Create manifest list and push to ghcr.io
- working-directory: /tmp/digests
- run: |
- docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
- $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
-
- name: Create manifest list and push to Docker Hub
- working-directory: /tmp/digests
- if: vars.DOCKER_HUB_REPO != ''
- run: |
- docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
- $(printf '${{ vars.DOCKER_HUB_REPO }}@sha256:%s ' *)
-
- - name: Inspect image in ghcr.io
- run: |
- docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
+ uses: nick-fields/retry@v4
+ with:
+ timeout_minutes: 5
+ max_attempts: 3
+ retry_wait_seconds: 30
+ command: |
+ cd /tmp/digests
+ docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io") | not)) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
+ $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Inspect image in Docker Hub
- if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
+ cleanup-digests:
+ name: Cleanup digest artifacts
+ runs-on: ubuntu-latest
+ needs: [push-manifest-ghcr, push-manifest-dockerhub]
+ if: always() && needs.push-manifest-ghcr.result == 'success'
+ steps:
- name: Delete unnecessary digest artifacts
env:
GH_TOKEN: ${{ github.token }}
@@ -316,9 +437,9 @@ jobs:
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v8
with:
path: ./binaries
pattern: navidrome-windows*
@@ -337,7 +458,7 @@ jobs:
du -h binaries/msi/*.msi
- name: Upload MSI files
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
@@ -350,12 +471,12 @@ jobs:
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v8
with:
path: ./binaries
pattern: navidrome-*
@@ -368,7 +489,7 @@ jobs:
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
- name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v6
+ uses: goreleaser/goreleaser-action@v7
with:
version: '~> v2'
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
@@ -381,7 +502,7 @@ jobs:
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: packages
path: dist/navidrome_0*
@@ -404,13 +525,13 @@ jobs:
item: ${{ fromJson(needs.release.outputs.package_list) }}
steps:
- name: Download all-packages artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: packages
path: ./dist
- name: Upload all-packages artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}
diff --git a/.github/workflows/push-translations.sh b/.github/workflows/push-translations.sh
new file mode 100755
index 000000000..be153eea8
--- /dev/null
+++ b/.github/workflows/push-translations.sh
@@ -0,0 +1,138 @@
+#!/bin/sh
+
+set -e
+
+I18N_DIR=resources/i18n
+
+# Normalize JSON for deterministic comparison:
+# remove empty/null attributes, sort keys alphabetically
+process_json() {
+ jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
+}
+
+# Get list of all languages configured in the POEditor project
+get_language_list() {
+ curl -s -X POST https://api.poeditor.com/v2/languages/list \
+ -d api_token="${POEDITOR_APIKEY}" \
+ -d id="${POEDITOR_PROJECTID}"
+}
+
+# Extract language name from the language list JSON given a language code
+get_language_name() {
+ lang_code="$1"
+ lang_list="$2"
+ echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name"
+}
+
+# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr")
+get_lang_code() {
+ filepath="$1"
+ filename=$(basename "$filepath")
+ echo "${filename%.*}"
+}
+
+# Export the current translation for a language from POEditor (v2 API)
+export_language() {
+ lang_code="$1"
+ response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \
+ -d api_token="${POEDITOR_APIKEY}" \
+ -d id="${POEDITOR_PROJECTID}" \
+ -d language="$lang_code" \
+ -d type="key_value_json")
+
+ url=$(echo "$response" | jq -r '.result.url')
+ if [ -z "$url" ] || [ "$url" = "null" ]; then
+ echo "Failed to export $lang_code: $response" >&2
+ return 1
+ fi
+ echo "$url"
+}
+
+# Flatten nested JSON to POEditor languages/update format.
+# POEditor uses term + context pairs, where:
+# term = the leaf key name
+# context = the parent path as "key1"."key2"."key3" (empty for root keys)
+flatten_to_poeditor() {
+ jq -c '[paths(scalars) as $p |
+ {
+ "term": ($p | last | tostring),
+ "context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end),
+ "translation": {"content": getpath($p)}
+ }
+ ]' "$1"
+}
+
+# Update translations for a language in POEditor via languages/update API
+update_language() {
+ lang_code="$1"
+ file="$2"
+
+ flatten_to_poeditor "$file" > /tmp/poeditor_data.json
+ response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \
+ -d api_token="${POEDITOR_APIKEY}" \
+ -d id="${POEDITOR_PROJECTID}" \
+ -d language="$lang_code" \
+ --data-urlencode data@/tmp/poeditor_data.json)
+ rm -f /tmp/poeditor_data.json
+
+ status=$(echo "$response" | jq -r '.response.status')
+ if [ "$status" != "success" ]; then
+ echo "Failed to update $lang_code: $response" >&2
+ return 1
+ fi
+
+ parsed=$(echo "$response" | jq -r '.result.translations.parsed')
+ added=$(echo "$response" | jq -r '.result.translations.added')
+ updated=$(echo "$response" | jq -r '.result.translations.updated')
+ echo " Translations - parsed: $parsed, added: $added, updated: $updated"
+}
+
+# --- Main ---
+
+if [ $# -eq 0 ]; then
+ echo "Usage: $0 [file2] ..."
+ echo "No files specified. Nothing to do."
+ exit 0
+fi
+
+lang_list=$(get_language_list)
+upload_count=0
+
+for file in "$@"; do
+ if [ ! -f "$file" ]; then
+ echo "Warning: File not found: $file, skipping"
+ continue
+ fi
+
+ lang_code=$(get_lang_code "$file")
+ lang_name=$(get_language_name "$lang_code" "$lang_list")
+
+ if [ -z "$lang_name" ]; then
+ echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file"
+ continue
+ fi
+
+ echo "Processing $lang_name ($lang_code)..."
+
+ # Export current state from POEditor
+ url=$(export_language "$lang_code")
+ curl -sSL "$url" -o poeditor_export.json
+
+ # Normalize both files for comparison
+ process_json "$file" > local_normalized.json
+ process_json poeditor_export.json > remote_normalized.json
+
+ # Compare normalized versions
+ if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then
+ echo " No differences, skipping"
+ else
+ echo " Differences found, updating POEditor..."
+ update_language "$lang_code" "$file"
+ upload_count=$((upload_count + 1))
+ fi
+
+ rm -f poeditor_export.json local_normalized.json remote_normalized.json
+done
+
+echo ""
+echo "Done. Updated $upload_count translation(s) in POEditor."
diff --git a/.github/workflows/push-translations.yml b/.github/workflows/push-translations.yml
new file mode 100644
index 000000000..f7cf00621
--- /dev/null
+++ b/.github/workflows/push-translations.yml
@@ -0,0 +1,32 @@
+name: POEditor export
+
+on:
+ push:
+ branches:
+ - master
+ paths:
+ - 'resources/i18n/*.json'
+
+jobs:
+ push-translations:
+ runs-on: ubuntu-latest
+ if: ${{ github.repository_owner == 'navidrome' }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 2
+
+ - name: Detect changed translation files
+ id: changed
+ run: |
+ CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ')
+ echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT
+ echo "Changed translation files: $CHANGED_FILES"
+
+ - name: Push translations to POEditor
+ if: ${{ steps.changed.outputs.files != '' }}
+ env:
+ POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
+ POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
+ run: |
+ .github/workflows/push-translations.sh ${{ steps.changed.outputs.files }}
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index c8bf3ae7f..33c8fadbd 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -12,7 +12,7 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- - uses: dessant/lock-threads@v5
+ - uses: dessant/lock-threads@v6
with:
process-only: 'issues, prs'
issue-inactive-days: 120
@@ -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@v9
+ - uses: actions/stale@v10
with:
operations-per-run: 999
days-before-issue-stale: 180
diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml
index 70a9de3d8..8fe0b5379 100644
--- a/.github/workflows/update-translations.yml
+++ b/.github/workflows/update-translations.yml
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Get updated translations
id: poeditor
env:
@@ -24,7 +24,7 @@ jobs:
git status --porcelain
git diff
- name: Create Pull Request
- uses: peter-evans/create-pull-request@v7
+ uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.PAT }}
author: "navidrome-bot "
diff --git a/.github/workflows/validate-translations.sh b/.github/workflows/validate-translations.sh
new file mode 100755
index 000000000..a6b346e78
--- /dev/null
+++ b/.github/workflows/validate-translations.sh
@@ -0,0 +1,236 @@
+#!/bin/bash
+
+# validate-translations.sh
+#
+# This script validates the structure of JSON translation files by comparing them
+# against the reference English translation file (ui/src/i18n/en.json).
+#
+# The script performs the following validations:
+# 1. JSON syntax validation using jq
+# 2. Structural validation - ensures all keys from English file are present
+# 3. Reports missing keys (translation incomplete)
+# 4. Reports extra keys (keys not in English reference, possibly deprecated)
+# 5. Emits GitHub Actions annotations for CI/CD integration
+#
+# Usage:
+# ./validate-translations.sh
+#
+# Environment Variables:
+# EN_FILE - Path to reference English file (default: ui/src/i18n/en.json)
+# TRANSLATION_DIR - Directory containing translation files (default: resources/i18n)
+#
+# Exit codes:
+# 0 - All translations are valid
+# 1 - One or more translations have structural issues
+#
+# GitHub Actions Integration:
+# The script outputs GitHub Actions annotations using ::error and ::warning
+# format that will be displayed in PR checks and workflow summaries.
+
+# Script to validate JSON translation files structure against en.json
+set -e
+
+# Path to the reference English translation file
+EN_FILE="${EN_FILE:-ui/src/i18n/en.json}"
+TRANSLATION_DIR="${TRANSLATION_DIR:-resources/i18n}"
+VERBOSE=false
+
+# Parse command line arguments
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -v|--verbose)
+ VERBOSE=true
+ shift
+ ;;
+ -h|--help)
+ echo "Usage: $0 [options]"
+ echo ""
+ echo "Validates JSON translation files structure against English reference file."
+ echo ""
+ echo "Options:"
+ echo " -h, --help Show this help message"
+ echo " -v, --verbose Show detailed output (default: only show errors)"
+ echo ""
+ echo "Environment Variables:"
+ echo " EN_FILE Path to reference English file (default: ui/src/i18n/en.json)"
+ echo " TRANSLATION_DIR Directory with translation files (default: resources/i18n)"
+ echo ""
+ echo "Examples:"
+ echo " $0 # Validate all translation files (quiet mode)"
+ echo " $0 -v # Validate with detailed output"
+ echo " EN_FILE=custom/en.json $0 # Use custom reference file"
+ echo " TRANSLATION_DIR=custom/i18n $0 # Use custom translations directory"
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ echo "Use --help for usage information" >&2
+ exit 1
+ ;;
+ esac
+done
+
+# Color codes for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+if [[ "$VERBOSE" == "true" ]]; then
+ echo "Validating translation files structure against ${EN_FILE}..."
+fi
+
+# Check if English reference file exists
+if [[ ! -f "$EN_FILE" ]]; then
+ echo "::error::Reference file $EN_FILE not found"
+ exit 1
+fi
+
+# Function to extract all JSON keys from a file, creating a flat list of dot-separated paths
+extract_keys() {
+ local file="$1"
+ jq -r 'paths(scalars) as $p | $p | join(".")' "$file" 2>/dev/null | sort
+}
+
+# Function to extract all non-empty string keys (to identify structural issues)
+extract_structure_keys() {
+ local file="$1"
+ # Get only keys where values are not empty strings
+ jq -r 'paths(scalars) as $p | select(getpath($p) != "") | $p | join(".")' "$file" 2>/dev/null | sort
+}
+
+# Function to validate a single translation file
+validate_translation() {
+ local translation_file="$1"
+ local filename=$(basename "$translation_file")
+ local has_errors=false
+ local verbose=${2:-false}
+
+ if [[ "$verbose" == "true" ]]; then
+ echo "Validating $filename..."
+ fi
+
+ # First validate JSON syntax
+ if ! jq empty "$translation_file" 2>/dev/null; then
+ echo "::error file=$translation_file::Invalid JSON syntax"
+ echo -e "${RED}✗ $filename has invalid JSON syntax${NC}"
+ return 1
+ fi
+
+ # Extract all keys from both files (for statistics)
+ local en_keys_file=$(mktemp)
+ local translation_keys_file=$(mktemp)
+
+ extract_keys "$EN_FILE" > "$en_keys_file"
+ extract_keys "$translation_file" > "$translation_keys_file"
+
+ # Extract only non-empty structure keys (to validate structural issues)
+ local en_structure_file=$(mktemp)
+ local translation_structure_file=$(mktemp)
+
+ extract_structure_keys "$EN_FILE" > "$en_structure_file"
+ extract_structure_keys "$translation_file" > "$translation_structure_file"
+
+ # Find structural issues: keys in translation not in English (misplaced)
+ local extra_keys=$(comm -13 "$en_keys_file" "$translation_keys_file")
+
+ # Find missing keys (for statistics only)
+ local missing_keys=$(comm -23 "$en_keys_file" "$translation_keys_file")
+
+ # Count keys for statistics
+ local total_en_keys=$(wc -l < "$en_keys_file")
+ local total_translation_keys=$(wc -l < "$translation_keys_file")
+ local missing_count=0
+ local extra_count=0
+
+ if [[ -n "$missing_keys" ]]; then
+ missing_count=$(echo "$missing_keys" | grep -c '^' || echo 0)
+ fi
+
+ if [[ -n "$extra_keys" ]]; then
+ extra_count=$(echo "$extra_keys" | grep -c '^' || echo 0)
+ has_errors=true
+ fi
+
+ # Report extra/misplaced keys (these are structural issues)
+ if [[ -n "$extra_keys" ]]; then
+ if [[ "$verbose" == "true" ]]; then
+ echo -e "${YELLOW}Misplaced keys in $filename ($extra_count):${NC}"
+ fi
+
+ while IFS= read -r key; do
+ # Try to find the line number
+ line=$(grep -n "\"$(echo "$key" | sed 's/.*\.//')" "$translation_file" | head -1 | cut -d: -f1)
+ line=${line:-1} # Default to line 1 if not found
+
+ echo "::error file=$translation_file,line=$line::Misplaced key: $key"
+
+ if [[ "$verbose" == "true" ]]; then
+ echo " + $key (line ~$line)"
+ fi
+ done <<< "$extra_keys"
+ fi
+
+ # Clean up temp files
+ rm -f "$en_keys_file" "$translation_keys_file" "$en_structure_file" "$translation_structure_file"
+
+ # Print statistics
+ if [[ "$verbose" == "true" ]]; then
+ echo " Keys: $total_translation_keys/$total_en_keys (Missing: $missing_count, Extra/Misplaced: $extra_count)"
+
+ if [[ "$has_errors" == "true" ]]; then
+ echo -e "${RED}✗ $filename has structural issues${NC}"
+ else
+ echo -e "${GREEN}✓ $filename structure is valid${NC}"
+ fi
+ elif [[ "$has_errors" == "true" ]]; then
+ echo -e "${RED}✗ $filename has structural issues (Extra/Misplaced: $extra_count)${NC}"
+ fi
+
+ return $([[ "$has_errors" == "true" ]] && echo 1 || echo 0)
+}
+
+# Main validation loop
+validation_failed=false
+total_files=0
+failed_files=0
+valid_files=0
+
+for translation_file in "$TRANSLATION_DIR"/*.json; do
+ if [[ -f "$translation_file" ]]; then
+ total_files=$((total_files + 1))
+ if ! validate_translation "$translation_file" "$VERBOSE"; then
+ validation_failed=true
+ failed_files=$((failed_files + 1))
+ else
+ valid_files=$((valid_files + 1))
+ fi
+
+ if [[ "$VERBOSE" == "true" ]]; then
+ echo "" # Add spacing between files
+ fi
+ fi
+done
+
+# Summary
+if [[ "$VERBOSE" == "true" ]]; then
+ echo "========================================="
+ echo "Translation Validation Summary:"
+ echo " Total files: $total_files"
+ echo " Valid files: $valid_files"
+ echo " Files with structural issues: $failed_files"
+ echo "========================================="
+fi
+
+if [[ "$validation_failed" == "true" ]]; then
+ if [[ "$VERBOSE" == "true" ]]; then
+ echo -e "${RED}Translation validation failed - $failed_files file(s) have structural issues${NC}"
+ else
+ echo -e "${RED}Translation validation failed - $failed_files/$total_files file(s) have structural issues${NC}"
+ fi
+ exit 1
+elif [[ "$VERBOSE" == "true" ]]; then
+ echo -e "${GREEN}All translation files are structurally valid${NC}"
+fi
+
+exit 0
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 4e32e14fd..fc8eaac69 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
/navidrome
/iTunes*.xml
/tmp
+/bin
data/*
vendor/*/
wiki
@@ -16,14 +17,26 @@ master.zip
testDB
cache/*
*.swp
+coverage.out
dist
music
+music.old
*.db*
.gitinfo
docker-compose.yml
!contrib/docker-compose.yml
binaries
-navidrome-master
+navidrome-*
+/ndpgen
AGENTS.md
+.github/prompts
+.github/instructions
+.github/git-commit-instructions.md
*.exe
-bin/
\ No newline at end of file
+*.test
+*.wasm
+*.ndp
+openspec/
+.agents
+go.work*
+.worktrees/
diff --git a/.golangci.yml b/.golangci.yml
index 996dafccb..28eb375a5 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -2,6 +2,7 @@ version: "2"
run:
build-tags:
- netgo
+ - sqlite_fts5
linters:
enable:
- asasalint
@@ -39,6 +40,11 @@ linters:
enable:
- nilness
exclusions:
+ rules:
+ - linters:
+ - gosec
+ path: _test\.go
+ text: "G703"
generated: lax
presets:
- comments
@@ -49,6 +55,7 @@ linters:
- third_party$
- builtin$
- examples$
+ - node_modules
formatters:
exclusions:
generated: lax
@@ -56,3 +63,4 @@ formatters:
- third_party$
- builtin$
- examples$
+ - node_modules
diff --git a/.nvmrc b/.nvmrc
index 9a2a0e219..54c65116f 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v20
+v24
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f2631f597..71c13b497 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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:
```
-(scope): -
+(scope):
[optional body]
```
diff --git a/Dockerfile b/Dockerfile
index 4b4c3d18c..ad1e2a41c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,11 +1,11 @@
FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcross
########################################################################################################################
-### Build xx (orignal image: tonistiigi/xx)
-FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 AS xx-build
+### Build xx (original image: tonistiigi/xx)
+FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
-# v1.5.0
-ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a
+# v1.9.0
+ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
RUN apk add -U --no-cache git
RUN git clone https://github.com/tonistiigi/xx && \
@@ -24,24 +24,6 @@ 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.21 AS taglib-build
-ARG TARGETPLATFORM
-ARG CROSS_TAGLIB_VERSION=2.0.2-1
-ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
-
-RUN </dev/null | head -1) && \
+ [ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \
+ done
-# Copy navidrome binary
-COPY --from=build /out/navidrome /app/
+# Copy navidrome binary (musl build for Docker, enables native libwebp)
+COPY --from=build-alpine /out/navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER=/music
ENV ND_DATAFOLDER=/data
ENV ND_CONFIGFILE=/data/navidrome.toml
ENV ND_PORT=4533
-ENV GODEBUG="asyncpreemptoff=1"
+ENV ND_ENABLEWEBPENCODING=true
RUN touch /.nddockerenv
EXPOSE ${ND_PORT}
WORKDIR /app
+ENV PATH="/app:${PATH}"
ENTRYPOINT ["/app/navidrome"]
diff --git a/Makefile b/Makefile
index 8425f6998..e303017c7 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,12 @@
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 ND_ENABLEINSIGHTSCOLLECTOR=false
+
ifneq ("$(wildcard .git/HEAD)","")
GIT_SHA=$(shell git rev-parse --short HEAD)
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
@@ -9,52 +15,85 @@ GIT_SHA=source_archive
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
endif
-SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,darwin/amd64,darwin/arm64,windows/amd64,windows/386
+SUPPORTED_PLATFORMS ?= 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
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
-# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
-CROSS_TAGLIB_VERSION ?= 2.0.2-1
+GOLANGCI_LINT_VERSION ?= v2.12.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
-setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
+setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First Install dependencies and prepare development environment
@echo Downloading Node dependencies...
@(cd ./ui && npm ci)
.PHONY: setup
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
- ND_ENABLEINSIGHTSCOLLECTOR="false" npx foreman -j Procfile.dev -p 4533 start
+ npx foreman -j Procfile.dev -p 4533 start
.PHONY: dev
server: check_go_env buildjs ##@Development Start the backend in development mode
- @ND_ENABLEINSIGHTSCOLLECTOR="false" go tool reflex -d none -c reflex.conf
+ go tool reflex -d none -c reflex.conf
.PHONY: server
+stop: ##@Development Stop development servers (UI and backend)
+ @echo "Stopping development servers..."
+ @-pkill -f "vite"
+ @-pkill -f "go tool reflex.*reflex.conf"
+ @-pkill -f "go run.*netgo"
+ @echo "Development servers stopped."
+.PHONY: stop
+
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
- go tool ginkgo watch -tags=netgo -notify ./...
+ go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./...
.PHONY: watch
PKG ?= ./...
-test: ##@Development Run Go tests
- go test -tags netgo $(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)
.PHONY: test
-testrace: ##@Development Run Go tests with race detector
- go test -tags netgo -race -shuffle=on ./...
-.PHONY: test
+test-ndpgen: ##@Development Run tests for ndpgen plugin
+ cd plugins/cmd/ndpgen && go test ./......
+.PHONY: test-ndpgen
-testall: testrace ##@Development Run Go and JS tests
- @(cd ./ui && npm run test)
+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)
+.PHONY: test-race
+
+test-js: ##@Development Run JS tests
+ @(cd ./ui && npm run test)
+.PHONY: test-js
+
+test-i18n: ##@Development Validate all translations files
+ ./.github/workflows/validate-translations.sh
+.PHONY: test-i18n
+
install-golangci-lint: ##@Development Install golangci-lint if not present
- @PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6)
+ @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); \
+ 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..."; \
+ rm -f ./bin/golangci-lint; \
+ INSTALL=true; \
+ fi; \
+ else \
+ INSTALL=true; \
+ fi; \
+ if [ "$$INSTALL" = "true" ]; then \
+ echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)..."; \
+ curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s $(GOLANGCI_LINT_VERSION); \
+ fi
.PHONY: install-golangci-lint
lint: install-golangci-lint ##@Development Lint Go code
- PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
+ PATH=./bin:$$PATH golangci-lint run --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code
@@ -64,14 +103,23 @@ lintall: lint ##@Development Lint Go and JS code
format: ##@Development Format code
@(cd ./ui && npm run prettier)
- @go tool goimports -w `find . -name '*.go' | grep -v _gen.go$$`
+ @go tool goimports -w `find . -name '*.go' | grep -v _gen.go$$ | grep -v .pb.go$$`
@go mod tidy
.PHONY: format
wire: check_go_env ##@Development Update Dependency Injection
- go tool wire gen -tags=netgo ./...
+ go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
.PHONY: wire
+gen: check_go_env ##@Development Run go generate for code generation
+ go generate ./...
+ 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
+
snapshots: ##@Development Update (GoLang) Snapshot tests
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
.PHONY: snapshots
@@ -96,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=netgo
+ 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)
.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=netgo
+ 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)
.PHONY: debug-build
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
@@ -128,7 +176,6 @@ 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
@@ -140,7 +187,6 @@ 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
@@ -153,6 +199,20 @@ 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=
+ @if [ -z "$(tag)" ]; then echo "Usage: make docker-run 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 \
+ VOLUMES="$$VOLUMES -v $(PWD)/navidrome.toml:/data/navidrome.toml:ro"; \
+ MUSIC_FOLDER=$$(grep '^MusicFolder' navidrome.toml | head -n1 | sed 's/.*= *"//' | sed 's/".*//'); \
+ if [ -n "$$MUSIC_FOLDER" ] && [ -d "$$MUSIC_FOLDER" ]; then \
+ VOLUMES="$$VOLUMES -v $$MUSIC_FOLDER:/music:ro"; \
+ fi; \
+ fi; \
+ echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
+.PHONY: docker-run
+
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
goreleaser release -f release/goreleaser.yml --clean --skip=publish --snapshot
@@ -170,6 +230,39 @@ 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= [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="; 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
diff --git a/adapters/deezer/client.go b/adapters/deezer/client.go
new file mode 100644
index 000000000..d51f65dd9
--- /dev/null
+++ b/adapters/deezer/client.go
@@ -0,0 +1,217 @@
+package deezer
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/microcosm-cc/bluemonday"
+ "github.com/navidrome/navidrome/log"
+)
+
+const apiBaseURL = "https://api.deezer.com"
+const authBaseURL = "https://auth.deezer.com"
+
+var (
+ ErrNotFound = errors.New("deezer: not found")
+)
+
+type httpDoer interface {
+ Do(req *http.Request) (*http.Response, error)
+}
+
+type client struct {
+ httpDoer httpDoer
+ jwt jwtToken
+}
+
+func newClient(hc httpDoer) *client {
+ return &client{
+ httpDoer: hc,
+ }
+}
+
+func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
+ params := url.Values{}
+ params.Add("q", name)
+ params.Add("order", "RANKING")
+ params.Add("limit", strconv.Itoa(limit))
+ req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
+ if err != nil {
+ return nil, err
+ }
+ req.URL.RawQuery = params.Encode()
+
+ var results SearchArtistResults
+ err = c.makeRequest(req, &results)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(results.Data) == 0 {
+ return nil, ErrNotFound
+ }
+ return results.Data, nil
+}
+
+func (c *client) makeRequest(req *http.Request, response any) error {
+ log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL)
+ resp, err := c.httpDoer.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 deezerError Error
+ err := json.Unmarshal(data, &deezerError)
+ if err != nil {
+ return err
+ }
+ return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message)
+}
+
+func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {
+ req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/related", apiBaseURL, artistID), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var results RelatedArtists
+ err = c.makeRequest(req, &results)
+ if err != nil {
+ return nil, err
+ }
+
+ return results.Data, nil
+}
+
+func (c *client) getTopTracks(ctx context.Context, artistID int, limit int) ([]Track, error) {
+ params := url.Values{}
+ params.Add("limit", strconv.Itoa(limit))
+ req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/top", apiBaseURL, artistID), nil)
+ if err != nil {
+ return nil, err
+ }
+ req.URL.RawQuery = params.Encode()
+
+ var results TopTracks
+ err = c.makeRequest(req, &results)
+ if err != nil {
+ return nil, err
+ }
+
+ return results.Data, nil
+}
+
+const pipeAPIURL = "https://pipe.deezer.com/api"
+
+var strictPolicy = bluemonday.StrictPolicy()
+
+func (c *client) getArtistBio(ctx context.Context, artistID int, lang string) (string, error) {
+ jwt, err := c.getJWT(ctx)
+ if err != nil {
+ return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
+ }
+
+ query := map[string]any{
+ "operationName": "ArtistBio",
+ "variables": map[string]any{
+ "artistId": strconv.Itoa(artistID),
+ },
+ "query": `query ArtistBio($artistId: String!) {
+ artist(artistId: $artistId) {
+ bio {
+ full
+ }
+ }
+ }`,
+ }
+
+ body, err := json.Marshal(query)
+ if err != nil {
+ return "", err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", pipeAPIURL, bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept-Language", lang)
+ req.Header.Set("Authorization", "Bearer "+jwt)
+
+ log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", lang)
+ resp, err := c.httpDoer.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return "", fmt.Errorf("deezer: failed to fetch biography: %s", resp.Status)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ type graphQLResponse struct {
+ Data struct {
+ Artist struct {
+ Bio struct {
+ Full string `json:"full"`
+ } `json:"bio"`
+ } `json:"artist"`
+ } `json:"data"`
+ Errors []struct {
+ Message string `json:"message"`
+ }
+ }
+
+ var result graphQLResponse
+ if err := json.Unmarshal(data, &result); err != nil {
+ return "", fmt.Errorf("deezer: failed to parse GraphQL response: %w", err)
+ }
+
+ if len(result.Errors) > 0 {
+ var errs []error
+ for m := range result.Errors {
+ errs = append(errs, errors.New(result.Errors[m].Message))
+ }
+ err := errors.Join(errs...)
+ return "", fmt.Errorf("deezer: GraphQL error: %w", err)
+ }
+
+ if result.Data.Artist.Bio.Full == "" {
+ return "", errors.New("deezer: biography not found")
+ }
+
+ return cleanBio(result.Data.Artist.Bio.Full), nil
+}
+
+func cleanBio(bio string) string {
+ bio = strings.ReplaceAll(bio, "
", "\n")
+ return strictPolicy.Sanitize(bio)
+}
diff --git a/adapters/deezer/client_auth.go b/adapters/deezer/client_auth.go
new file mode 100644
index 000000000..eb664c00b
--- /dev/null
+++ b/adapters/deezer/client_auth.go
@@ -0,0 +1,101 @@
+package deezer
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v3/jwt"
+ "github.com/navidrome/navidrome/log"
+)
+
+type jwtToken struct {
+ token string
+ expiresAt time.Time
+ mu sync.RWMutex
+}
+
+func (j *jwtToken) get() (string, bool) {
+ j.mu.RLock()
+ defer j.mu.RUnlock()
+ if time.Now().Before(j.expiresAt) {
+ return j.token, true
+ }
+ return "", false
+}
+
+func (j *jwtToken) set(token string, expiresIn time.Duration) {
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ j.token = token
+ j.expiresAt = time.Now().Add(expiresIn)
+}
+
+func (c *client) getJWT(ctx context.Context) (string, error) {
+ // Check if we have a valid cached token
+ if token, valid := c.jwt.get(); valid {
+ return token, nil
+ }
+
+ // Fetch a new anonymous token
+ req, err := http.NewRequestWithContext(ctx, "GET", authBaseURL+"/login/anonymous?jo=p&rto=c", nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.httpDoer.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return "", fmt.Errorf("deezer: failed to get JWT token: %s", resp.Status)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ type authResponse struct {
+ JWT string `json:"jwt"` //nolint:gosec
+ }
+
+ var result authResponse
+ if err := json.Unmarshal(data, &result); err != nil {
+ return "", fmt.Errorf("deezer: failed to parse auth response: %w", err)
+ }
+
+ if result.JWT == "" {
+ return "", errors.New("deezer: no JWT token in response")
+ }
+
+ // Parse JWT to get actual expiration time
+ token, err := jwt.ParseString(result.JWT, jwt.WithVerify(false), jwt.WithValidate(false))
+ if err != nil {
+ return "", fmt.Errorf("deezer: failed to parse JWT token: %w", err)
+ }
+
+ // Calculate TTL with a 1-minute buffer for clock skew and network delays
+ expiresAt, ok := token.Expiration()
+ if !ok || expiresAt.IsZero() {
+ return "", errors.New("deezer: JWT token has no expiration time")
+ }
+
+ ttl := time.Until(expiresAt) - 1*time.Minute
+ if ttl <= 0 {
+ return "", errors.New("deezer: JWT token already expired or expires too soon")
+ }
+
+ c.jwt.set(result.JWT, ttl)
+ log.Trace(ctx, "Fetched new Deezer JWT token", "expiresAt", expiresAt, "ttl", ttl)
+
+ return result.JWT, nil
+}
diff --git a/adapters/deezer/client_auth_test.go b/adapters/deezer/client_auth_test.go
new file mode 100644
index 000000000..005a84e1a
--- /dev/null
+++ b/adapters/deezer/client_auth_test.go
@@ -0,0 +1,294 @@
+package deezer
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v3/jwt"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("JWT Authentication", func() {
+ var httpClient *fakeHttpClient
+ var client *client
+ var ctx context.Context
+
+ BeforeEach(func() {
+ httpClient = &fakeHttpClient{}
+ client = newClient(httpClient)
+ ctx = context.Background()
+ })
+
+ Describe("getJWT", func() {
+ Context("with a valid JWT response", func() {
+ It("successfully fetches and caches a JWT token", func() {
+ testJWT := createTestJWT(5 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
+ })
+
+ token, err := client.getJWT(ctx)
+ Expect(err).To(BeNil())
+ Expect(token).To(Equal(testJWT))
+ })
+
+ It("returns the cached token on subsequent calls", func() {
+ testJWT := createTestJWT(5 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
+ })
+
+ // First call should fetch from API
+ token1, err := client.getJWT(ctx)
+ Expect(err).To(BeNil())
+ Expect(token1).To(Equal(testJWT))
+ Expect(httpClient.lastRequest.URL.Path).To(Equal("/login/anonymous"))
+
+ // Second call should return cached token without hitting API
+ httpClient.lastRequest = nil // Clear last request to verify no new request is made
+ token2, err := client.getJWT(ctx)
+ Expect(err).To(BeNil())
+ Expect(token2).To(Equal(testJWT))
+ Expect(httpClient.lastRequest).To(BeNil()) // No new request made
+ })
+
+ It("parses the JWT expiration time correctly", func() {
+ expectedExpiration := time.Now().Add(5 * time.Minute)
+ testToken, err := jwt.NewBuilder().
+ Expiration(expectedExpiration).
+ Build()
+ Expect(err).To(BeNil())
+ testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
+ Expect(err).To(BeNil())
+
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
+ })
+
+ token, err := client.getJWT(ctx)
+ Expect(err).To(BeNil())
+ Expect(token).ToNot(BeEmpty())
+
+ // Verify the token is cached until close to expiration
+ // The cache should expire 1 minute before the JWT expires
+ expectedCacheExpiry := expectedExpiration.Add(-1 * time.Minute)
+ Expect(client.jwt.expiresAt).To(BeTemporally("~", expectedCacheExpiry, 2*time.Second))
+ })
+ })
+
+ Context("with JWT tokens that expire soon", func() {
+ It("rejects tokens that expire in less than 1 minute", func() {
+ // Create a token that expires in 30 seconds (less than 1-minute buffer)
+ testJWT := createTestJWT(30 * time.Second)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
+ })
+
+ _, err := client.getJWT(ctx)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
+ })
+
+ It("rejects already expired tokens", func() {
+ // Create a token that expired 1 minute ago
+ testJWT := createTestJWT(-1 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
+ })
+
+ _, err := client.getJWT(ctx)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
+ })
+
+ It("accepts tokens that expire in more than 1 minute", func() {
+ // Create a token that expires in 2 minutes (just over the 1-minute buffer)
+ testJWT := createTestJWT(2 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
+ })
+
+ token, err := client.getJWT(ctx)
+ Expect(err).To(BeNil())
+ Expect(token).ToNot(BeEmpty())
+ })
+ })
+
+ Context("with invalid responses", func() {
+ It("handles HTTP error responses", func() {
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 500,
+ Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
+ })
+
+ _, err := client.getJWT(ctx)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("failed to get JWT token"))
+ })
+
+ It("handles malformed JSON responses", func() {
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{invalid json}`)),
+ })
+
+ _, err := client.getJWT(ctx)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("failed to parse auth response"))
+ })
+
+ It("handles responses with empty JWT field", func() {
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{"jwt":""}`)),
+ })
+
+ _, err := client.getJWT(ctx)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(Equal("deezer: no JWT token in response"))
+ })
+
+ It("handles invalid JWT tokens", func() {
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"not-a-valid-jwt"}`)),
+ })
+
+ _, err := client.getJWT(ctx)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("failed to parse JWT token"))
+ })
+
+ It("rejects JWT tokens without expiration", func() {
+ // Create a JWT without expiration claim
+ testToken, err := jwt.NewBuilder().
+ Claim("custom", "value").
+ Build()
+ Expect(err).To(BeNil())
+
+ // Verify token has no expiration
+ _, hasExp := testToken.Expiration()
+ Expect(hasExp).To(BeFalse())
+
+ testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
+ Expect(err).To(BeNil())
+
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
+ })
+
+ _, err = client.getJWT(ctx)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(Equal("deezer: JWT token has no expiration time"))
+ })
+ })
+
+ Context("token caching behavior", func() {
+ It("fetches a new token when the cached token expires", func() {
+ // First token expires in 5 minutes
+ firstJWT := createTestJWT(5 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, firstJWT))),
+ })
+
+ token1, err := client.getJWT(ctx)
+ Expect(err).To(BeNil())
+ Expect(token1).To(Equal(firstJWT))
+
+ // Manually expire the cached token
+ client.jwt.expiresAt = time.Now().Add(-1 * time.Second)
+
+ // Second token with different expiration (10 minutes)
+ secondJWT := createTestJWT(10 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, secondJWT))),
+ })
+
+ token2, err := client.getJWT(ctx)
+ Expect(err).To(BeNil())
+ Expect(token2).To(Equal(secondJWT))
+ Expect(token2).ToNot(Equal(token1))
+ })
+ })
+ })
+
+ Describe("jwtToken cache", func() {
+ var cache *jwtToken
+
+ BeforeEach(func() {
+ cache = &jwtToken{}
+ })
+
+ It("returns false for expired tokens", func() {
+ cache.set("test-token", -1*time.Second) // Already expired
+ token, valid := cache.get()
+ Expect(valid).To(BeFalse())
+ Expect(token).To(BeEmpty())
+ })
+
+ It("returns true for valid tokens", func() {
+ cache.set("test-token", 4*time.Minute)
+ token, valid := cache.get()
+ Expect(valid).To(BeTrue())
+ Expect(token).To(Equal("test-token"))
+ })
+
+ It("is thread-safe for concurrent access", func() {
+ wg := sync.WaitGroup{}
+
+ // Writer goroutine
+ wg.Go(func() {
+ for i := range 100 {
+ cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
+ time.Sleep(1 * time.Millisecond)
+ }
+ })
+
+ // Reader goroutine
+ wg.Go(func() {
+ for range 100 {
+ cache.get()
+ time.Sleep(1 * time.Millisecond)
+ }
+ })
+
+ // Wait for both goroutines to complete
+ wg.Wait()
+
+ // Verify final state is valid
+ token, valid := cache.get()
+ Expect(valid).To(BeTrue())
+ Expect(token).To(HavePrefix("token-"))
+ })
+ })
+})
+
+// createTestJWT creates a valid JWT token for testing purposes
+func createTestJWT(expiresIn time.Duration) string {
+ token, err := jwt.NewBuilder().
+ Expiration(time.Now().Add(expiresIn)).
+ Build()
+ if err != nil {
+ panic(fmt.Sprintf("failed to create test JWT: %v", err))
+ }
+ signed, err := jwt.Sign(token, jwt.WithInsecureNoSignature())
+ if err != nil {
+ panic(fmt.Sprintf("failed to sign test JWT: %v", err))
+ }
+ return string(signed)
+}
diff --git a/adapters/deezer/client_test.go b/adapters/deezer/client_test.go
new file mode 100644
index 000000000..9fa7afdd9
--- /dev/null
+++ b/adapters/deezer/client_test.go
@@ -0,0 +1,210 @@
+package deezer
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "time"
+
+ . "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(httpClient)
+ })
+
+ Describe("ArtistImages", func() {
+ It("returns artist images from a successful request", func() {
+ f, err := os.Open("tests/fixtures/deezer.search.artist.json")
+ Expect(err).To(BeNil())
+ httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})
+
+ artists, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
+ Expect(err).To(BeNil())
+ Expect(artists).To(HaveLen(17))
+ Expect(artists[0].Name).To(Equal("Michael Jackson"))
+ Expect(artists[0].PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg"))
+ })
+
+ It("fails if artist was not found", func() {
+ httpClient.mock("https://api.deezer.com/search/artist", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
+ })
+
+ _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
+ Expect(err).To(MatchError(ErrNotFound))
+ })
+ })
+
+ Describe("TopTracks", func() {
+ It("returns top tracks with artist and album info from a successful request", func() {
+ f, err := os.Open("tests/fixtures/deezer.artist.top.json")
+ Expect(err).To(BeNil())
+ httpClient.mock("https://api.deezer.com/artist/27/top", http.Response{Body: f, StatusCode: 200})
+
+ tracks, err := client.getTopTracks(GinkgoT().Context(), 27, 5)
+ Expect(err).To(BeNil())
+ Expect(tracks).To(HaveLen(5))
+
+ // Verify first track has all expected fields
+ Expect(tracks[0].Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
+ Expect(tracks[0].Artist.Name).To(Equal("Daft Punk"))
+ Expect(tracks[0].Album.Title).To(Equal("Random Access Memories"))
+
+ // Verify second track
+ Expect(tracks[1].Title).To(Equal("One More Time"))
+ Expect(tracks[1].Artist.Name).To(Equal("Daft Punk"))
+ Expect(tracks[1].Album.Title).To(Equal("Discovery"))
+ })
+ })
+
+ Describe("ArtistBio", func() {
+ BeforeEach(func() {
+ // Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
+ testJWT := createTestJWT(5 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
+ })
+ })
+
+ It("returns artist bio from a successful request", func() {
+ f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
+ Expect(err).To(BeNil())
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
+
+ bio, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
+ Expect(err).To(BeNil())
+ Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
+ Expect(bio).ToNot(ContainSubstring(""))
+ Expect(bio).ToNot(ContainSubstring("
"))
+ })
+
+ It("uses the provided language", func() {
+ f, err := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
+ Expect(err).To(BeNil())
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
+
+ _, err = client.getArtistBio(GinkgoT().Context(), 27, "fr")
+ Expect(err).To(BeNil())
+ Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
+ })
+
+ It("includes the JWT token in the request", func() {
+ f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
+ Expect(err).To(BeNil())
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
+
+ _, err = client.getArtistBio(GinkgoT().Context(), 27, "en")
+ Expect(err).To(BeNil())
+ // Verify that the Authorization header has the Bearer token format
+ authHeader := httpClient.lastRequest.Header.Get("Authorization")
+ Expect(authHeader).To(HavePrefix("Bearer "))
+ Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars
+ })
+
+ It("handles GraphQL errors", func() {
+ errorResponse := `{
+ "data": {
+ "artist": {
+ "bio": {
+ "full": ""
+ }
+ }
+ },
+ "errors": [
+ {
+ "message": "Artist not found"
+ },
+ {
+ "message": "Invalid artist ID"
+ }
+ ]
+ }`
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
+ })
+
+ _, err := client.getArtistBio(GinkgoT().Context(), 999, "en")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("GraphQL error"))
+ Expect(err.Error()).To(ContainSubstring("Artist not found"))
+ Expect(err.Error()).To(ContainSubstring("Invalid artist ID"))
+ })
+
+ It("handles empty biography", func() {
+ emptyBioResponse := `{
+ "data": {
+ "artist": {
+ "bio": {
+ "full": ""
+ }
+ }
+ }
+ }`
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
+ })
+
+ _, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
+ Expect(err).To(MatchError("deezer: biography not found"))
+ })
+
+ It("handles JWT token fetch failure", func() {
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 500,
+ Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
+ })
+
+ _, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
+ })
+
+ It("handles JWT token that expires too soon", func() {
+ // Create a JWT that expires in 30 seconds (less than the 1-minute buffer)
+ expiredJWT := createTestJWT(30 * time.Second)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
+ })
+
+ _, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
+ })
+ })
+})
+
+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())
+}
diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go
new file mode 100644
index 000000000..ed3071766
--- /dev/null
+++ b/adapters/deezer/deezer.go
@@ -0,0 +1,172 @@
+package deezer
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "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/navidrome/navidrome/utils/slice"
+)
+
+const deezerAgentName = "deezer"
+const deezerApiPictureXlSize = 1000
+const deezerApiPictureBigSize = 500
+const deezerApiPictureMediumSize = 250
+const deezerApiPictureSmallSize = 56
+const deezerArtistSearchLimit = 50
+
+type deezerAgent struct {
+ dataStore model.DataStore
+ client *client
+ languages []string
+}
+
+func deezerConstructor(dataStore model.DataStore) agents.Interface {
+ agent := &deezerAgent{
+ dataStore: dataStore,
+ languages: conf.Server.Deezer.Languages,
+ }
+ httpClient := &http.Client{
+ Timeout: consts.DefaultHttpClientTimeOut,
+ }
+ cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
+ agent.client = newClient(cachedHttpClient)
+ return agent
+}
+
+func (s *deezerAgent) AgentName() string {
+ return deezerAgentName
+}
+
+func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([]agents.ExternalImage, error) {
+ artist, err := s.searchArtist(ctx, name)
+ if err != nil {
+ if errors.Is(err, agents.ErrNotFound) {
+ log.Warn(ctx, "Artist not found in deezer", "artist", name)
+ } else {
+ log.Error(ctx, "Error calling deezer", "artist", name, err)
+ }
+ return nil, err
+ }
+
+ var res []agents.ExternalImage
+ possibleImages := []struct {
+ URL string
+ Size int
+ }{
+ {artist.PictureXl, deezerApiPictureXlSize},
+ {artist.PictureBig, deezerApiPictureBigSize},
+ {artist.PictureMedium, deezerApiPictureMediumSize},
+ {artist.PictureSmall, deezerApiPictureSmallSize},
+ }
+ for _, imgData := range possibleImages {
+ if imgData.URL != "" {
+ res = append(res, agents.ExternalImage{
+ URL: imgData.URL,
+ Size: imgData.Size,
+ })
+ }
+ }
+ return res, nil
+}
+
+func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
+ artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit)
+ if errors.Is(err, ErrNotFound) || len(artists) == 0 {
+ return nil, agents.ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ log.Trace(ctx, "Artists found", "count", len(artists), "searched_name", name)
+ for i := range artists {
+ log.Trace(ctx, fmt.Sprintf("Artists found #%d", i), "name", artists[i].Name, "id", artists[i].ID, "link", artists[i].Link)
+ if i > 2 {
+ break
+ }
+ }
+
+ // 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", 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) {
+ artist, err := s.searchArtist(ctx, name)
+ if err != nil {
+ return nil, err
+ }
+
+ related, err := s.client.getRelatedArtists(ctx, artist.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ res := slice.Map(related, func(r Artist) agents.Artist {
+ return agents.Artist{
+ Name: r.Name,
+ }
+ })
+ if len(res) > limit {
+ res = res[:limit]
+ }
+ return res, nil
+}
+
+func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) {
+ artist, err := s.searchArtist(ctx, artistName)
+ if err != nil {
+ return nil, err
+ }
+
+ tracks, err := s.client.getTopTracks(ctx, artist.ID, count)
+ if err != nil {
+ return nil, err
+ }
+
+ res := slice.Map(tracks, func(r Track) agents.Song {
+ return agents.Song{
+ Name: r.Title,
+ Album: r.Album.Title,
+ Duration: uint32(r.Duration * 1000), // Convert seconds to milliseconds
+ }
+ })
+ return res, nil
+}
+
+func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) {
+ artist, err := s.searchArtist(ctx, name)
+ if err != nil {
+ return "", err
+ }
+
+ for _, lang := range s.languages {
+ bio, err := s.client.getArtistBio(ctx, artist.ID, lang)
+ if err == nil && bio != "" {
+ return bio, nil
+ }
+ log.Debug(ctx, "Deezer/artist.bio returned empty/error, trying next language", "artist", name, "lang", lang, err)
+ }
+ return "", agents.ErrNotFound
+}
+
+func init() {
+ conf.AddHook(func() {
+ if conf.Server.Deezer.Enabled {
+ agents.Register(deezerAgentName, deezerConstructor)
+ }
+ })
+}
diff --git a/core/agents/spotify/spotify_suite_test.go b/adapters/deezer/deezer_suite_test.go
similarity index 74%
rename from core/agents/spotify/spotify_suite_test.go
rename to adapters/deezer/deezer_suite_test.go
index 275b05e73..a42282da7 100644
--- a/core/agents/spotify/spotify_suite_test.go
+++ b/adapters/deezer/deezer_suite_test.go
@@ -1,4 +1,4 @@
-package spotify
+package deezer
import (
"testing"
@@ -9,9 +9,9 @@ import (
. "github.com/onsi/gomega"
)
-func TestSpotify(t *testing.T) {
+func TestDeezer(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
- RunSpecs(t, "Spotify Test Suite")
+ RunSpecs(t, "Deezer Test Suite")
}
diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go
new file mode 100644
index 000000000..4dd251585
--- /dev/null
+++ b/adapters/deezer/deezer_test.go
@@ -0,0 +1,171 @@
+package deezer
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core/agents"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("deezerAgent", func() {
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Deezer.Enabled = true
+ })
+
+ Describe("deezerConstructor", func() {
+ It("uses configured languages", func() {
+ conf.Server.Deezer.Languages = []string{"pt", "en"}
+ agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent)
+ Expect(agent.languages).To(Equal([]string{"pt", "en"}))
+ })
+ })
+
+ Describe("GetArtistBiography - Language Fallback", func() {
+ var agent *deezerAgent
+ var httpClient *langAwareHttpClient
+
+ BeforeEach(func() {
+ httpClient = newLangAwareHttpClient()
+
+ // Mock search artist (returns Michael Jackson)
+ fSearch, _ := os.Open("tests/fixtures/deezer.search.artist.json")
+ httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200}
+
+ // Mock JWT token
+ testJWT := createTestJWT(5 * time.Minute)
+ httpClient.jwtResponse = &http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
+ }
+ })
+
+ setupAgent := func(languages []string) {
+ conf.Server.Deezer.Languages = languages
+ agent = &deezerAgent{
+ dataStore: &tests.MockDataStore{},
+ client: newClient(httpClient),
+ languages: languages,
+ }
+ }
+
+ It("returns content in first language when available (1 bio API call)", func() {
+ setupAgent([]string{"fr", "en"})
+
+ // French biography available
+ fFr, _ := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
+ httpClient.bioResponses["fr"] = &http.Response{Body: fFr, StatusCode: 200}
+
+ bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(bio).To(ContainSubstring("Guy-Manuel de Homem Christo et Thomas Bangalter"))
+ Expect(httpClient.bioRequestCount).To(Equal(1))
+ Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("fr"))
+ })
+
+ It("falls back to second language when first returns empty (2 bio API calls)", func() {
+ setupAgent([]string{"ja", "en"})
+
+ // Japanese returns empty biography
+ fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
+ httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
+ // English returns full biography
+ fEn, _ := os.Open("tests/fixtures/deezer.artist.bio.en.json")
+ httpClient.bioResponses["en"] = &http.Response{Body: fEn, StatusCode: 200}
+
+ bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
+ Expect(httpClient.bioRequestCount).To(Equal(2))
+ Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("ja"))
+ Expect(httpClient.bioRequests[1].Header.Get("Accept-Language")).To(Equal("en"))
+ })
+
+ It("returns ErrNotFound when all languages return empty", func() {
+ setupAgent([]string{"ja", "xx"})
+
+ // Both languages return empty biography
+ fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
+ httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
+ fXx, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
+ httpClient.bioResponses["xx"] = &http.Response{Body: fXx, StatusCode: 200}
+
+ _, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
+
+ Expect(err).To(MatchError(agents.ErrNotFound))
+ Expect(httpClient.bioRequestCount).To(Equal(2))
+ })
+ })
+})
+
+// langAwareHttpClient is a mock HTTP client that returns different responses based on the Accept-Language header
+type langAwareHttpClient struct {
+ searchResponse *http.Response
+ jwtResponse *http.Response
+ bioResponses map[string]*http.Response
+ bioRequests []*http.Request
+ bioRequestCount int
+}
+
+func newLangAwareHttpClient() *langAwareHttpClient {
+ return &langAwareHttpClient{
+ bioResponses: make(map[string]*http.Response),
+ bioRequests: make([]*http.Request, 0),
+ }
+}
+
+func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
+ // Handle search artist request
+ if req.URL.Host == "api.deezer.com" && req.URL.Path == "/search/artist" {
+ if c.searchResponse != nil {
+ return c.searchResponse, nil
+ }
+ return &http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
+ }, nil
+ }
+
+ // Handle JWT token request
+ if req.URL.Host == "auth.deezer.com" && req.URL.Path == "/login/anonymous" {
+ if c.jwtResponse != nil {
+ return c.jwtResponse, nil
+ }
+ return &http.Response{
+ StatusCode: 500,
+ Body: io.NopCloser(bytes.NewBufferString(`{"error":"no mock"}`)),
+ }, nil
+ }
+
+ // Handle bio request (GraphQL API)
+ if req.URL.Host == "pipe.deezer.com" && req.URL.Path == "/api" {
+ c.bioRequestCount++
+ c.bioRequests = append(c.bioRequests, req)
+ lang := req.Header.Get("Accept-Language")
+ if resp, ok := c.bioResponses[lang]; ok {
+ return resp, nil
+ }
+ // Return empty bio by default
+ return &http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{"data":{"artist":{"bio":{"full":""}}}}`)),
+ }, nil
+ }
+
+ panic("URL not mocked: " + req.URL.String())
+}
diff --git a/adapters/deezer/responses.go b/adapters/deezer/responses.go
new file mode 100644
index 000000000..266c44c62
--- /dev/null
+++ b/adapters/deezer/responses.go
@@ -0,0 +1,66 @@
+package deezer
+
+type SearchArtistResults struct {
+ Data []Artist `json:"data"`
+ Total int `json:"total"`
+ Next string `json:"next"`
+}
+
+type Artist struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Link string `json:"link"`
+ Picture string `json:"picture"`
+ PictureSmall string `json:"picture_small"`
+ PictureMedium string `json:"picture_medium"`
+ PictureBig string `json:"picture_big"`
+ PictureXl string `json:"picture_xl"`
+ NbAlbum int `json:"nb_album"`
+ NbFan int `json:"nb_fan"`
+ Radio bool `json:"radio"`
+ Tracklist string `json:"tracklist"`
+ Type string `json:"type"`
+}
+
+type Error struct {
+ Error struct {
+ Type string `json:"type"`
+ Message string `json:"message"`
+ Code int `json:"code"`
+ } `json:"error"`
+}
+
+type RelatedArtists struct {
+ Data []Artist `json:"data"`
+ Total int `json:"total"`
+}
+
+type TopTracks struct {
+ Data []Track `json:"data"`
+ Total int `json:"total"`
+ Next string `json:"next"`
+}
+
+type Track struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Link string `json:"link"`
+ Duration int `json:"duration"`
+ Rank int `json:"rank"`
+ Preview string `json:"preview"`
+ Artist Artist `json:"artist"`
+ Album Album `json:"album"`
+ Contributors []Artist `json:"contributors"`
+}
+
+type Album struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Cover string `json:"cover"`
+ CoverSmall string `json:"cover_small"`
+ CoverMedium string `json:"cover_medium"`
+ CoverBig string `json:"cover_big"`
+ CoverXl string `json:"cover_xl"`
+ Tracklist string `json:"tracklist"`
+ Type string `json:"type"`
+}
diff --git a/adapters/deezer/responses_test.go b/adapters/deezer/responses_test.go
new file mode 100644
index 000000000..a9de5c5fb
--- /dev/null
+++ b/adapters/deezer/responses_test.go
@@ -0,0 +1,69 @@
+package deezer
+
+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 SearchArtistResults
+ body, err := os.ReadFile("tests/fixtures/deezer.search.artist.json")
+ Expect(err).To(BeNil())
+ err = json.Unmarshal(body, &resp)
+ Expect(err).To(BeNil())
+
+ Expect(resp.Data).To(HaveLen(17))
+ michael := resp.Data[0]
+ Expect(michael.Name).To(Equal("Michael Jackson"))
+ Expect(michael.PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg"))
+ })
+ })
+
+ Describe("Error", func() {
+ It("parses the error response correctly", func() {
+ var errorResp Error
+ body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`)
+ err := json.Unmarshal(body, &errorResp)
+ Expect(err).To(BeNil())
+
+ Expect(errorResp.Error.Code).To(Equal(501))
+ Expect(errorResp.Error.Message).To(Equal("Missing parameters: q"))
+ })
+ })
+
+ Describe("Related Artists", func() {
+ It("parses the related artists response correctly", func() {
+ var resp RelatedArtists
+ body, err := os.ReadFile("tests/fixtures/deezer.artist.related.json")
+ Expect(err).To(BeNil())
+ err = json.Unmarshal(body, &resp)
+ Expect(err).To(BeNil())
+
+ Expect(resp.Data).To(HaveLen(20))
+ justice := resp.Data[0]
+ Expect(justice.Name).To(Equal("Justice"))
+ Expect(justice.ID).To(Equal(6404))
+ })
+ })
+
+ Describe("Top Tracks", func() {
+ It("parses the top tracks response correctly", func() {
+ var resp TopTracks
+ body, err := os.ReadFile("tests/fixtures/deezer.artist.top.json")
+ Expect(err).To(BeNil())
+ err = json.Unmarshal(body, &resp)
+ Expect(err).To(BeNil())
+
+ Expect(resp.Data).To(HaveLen(5))
+ track := resp.Data[0]
+ Expect(track.Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
+ Expect(track.ID).To(Equal(67238732))
+ Expect(track.Album.Title).To(Equal("Random Access Memories"))
+ })
+ })
+})
diff --git a/adapters/taglib/end_to_end_test.go b/adapters/gotaglib/end_to_end_test.go
similarity index 53%
rename from adapters/taglib/end_to_end_test.go
rename to adapters/gotaglib/end_to_end_test.go
index 08fc1a506..e7dd18ac1 100644
--- a/adapters/taglib/end_to_end_test.go
+++ b/adapters/gotaglib/end_to_end_test.go
@@ -1,4 +1,4 @@
-package taglib
+package gotaglib
import (
"io/fs"
@@ -78,22 +78,111 @@ var _ = Describe("Extractor", func() {
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)
+ return new(metadata.ToMediaFile(1, "folderID"))
+ }
+
BeforeEach(func() {
- e = &extractor{}
+ e = &extractor{fs: os.DirFS(".")}
+ })
+
+ 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", new(0.0), new(1.0), new(0.0), new(1.0)),
+ )
+ })
+
+ Describe("lyrics", func() {
+ makeLyrics := func(code, secondLine string) model.Lyrics {
+ return model.Lyrics{
+ DisplayArtist: "",
+ DisplayTitle: "",
+ Lang: code,
+ Line: []model.Line{
+ {Start: new(int64(0)), Value: "This is"},
+ {Start: new(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) {
- path := "tests/fixtures/test." + format
- mds, err := e.Parse(path)
- Expect(err).ToNot(HaveOccurred())
-
- info := mds[path]
- fileInfo, _ := os.Stat(path)
- info.FileInfo = testFileInfo{FileInfo: fileInfo}
-
- metadata := metadata.New(path, info)
- mf := metadata.ToMediaFile(1, "folderID")
+ mf := parseTestFile("tests/fixtures/test." + format)
for _, data := range roles {
role := data.Role
@@ -144,11 +233,40 @@ var _ = Describe("Extractor", func() {
Entry("FLAC format", "flac"),
Entry("M4a format", "m4a"),
Entry("OGG format", "ogg"),
- Entry("WMA format", "wv"),
+ 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))
+ }
+ }
+ })
})
})
diff --git a/adapters/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go
new file mode 100644
index 000000000..7ea98a442
--- /dev/null
+++ b/adapters/gotaglib/gotaglib.go
@@ -0,0 +1,301 @@
+// Package gotaglib provides an alternative metadata extractor using go-taglib,
+// a pure Go (WASM-based) implementation of TagLib.
+//
+// This extractor aims for parity with the CGO-based taglib extractor. It uses
+// TagLib's PropertyMap interface for standard tags. The File handle API provides
+// efficient access to format-specific tags (ID3v2 frames, MP4 atoms, ASF attributes)
+// through a single file open operation.
+//
+// This extractor is registered under the name "taglib". It only works with a filesystem
+// (fs.FS) and does not support direct local file paths. Files returned by the filesystem
+// must implement io.ReadSeeker for go-taglib to read them.
+package gotaglib
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "runtime/debug"
+ "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"
+ "go.senan.xyz/taglib"
+)
+
+type extractor struct {
+ fs fs.FS
+}
+
+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 {
+ 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"
+}
+
+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)
+ }
+ }()
+
+ f, close, err := e.openFile(filePath)
+ if err != nil {
+ log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
+ return nil, err
+ }
+ defer close()
+
+ // Get all tags and properties in one go
+ allTags := f.AllTags()
+ props := f.Properties()
+
+ // Map properties to AudioProperties
+ ap := metadata.AudioProperties{
+ Duration: props.Length.Round(time.Millisecond * 10),
+ BitRate: int(props.Bitrate),
+ 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)
+ normalizedTags := make(map[string][]string, len(allTags.Tags))
+ for key, values := range allTags.Tags {
+ lowerKey := strings.ToLower(key)
+ normalizedTags[lowerKey] = values
+ }
+
+ // Process format-specific raw tags
+ processRawTags(allTags, normalizedTags)
+
+ // Parse track/disc totals from "N/Total" format
+ parseTuple(normalizedTags, "track")
+ parseTuple(normalizedTags, "disc")
+
+ // Adjust some ID3 tags
+ parseLyrics(normalizedTags)
+ parseTIPL(normalizedTags)
+ delete(normalizedTags, "tmcl") // TMCL is already parsed by TagLib
+
+ // Determine if file has embedded picture
+ hasPicture := len(props.Images) > 0
+
+ return &metadata.Info{
+ Tags: normalizedTags,
+ AudioProperties: ap,
+ HasPicture: hasPicture,
+ }, nil
+}
+
+// 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) {
+ // Open the file from the filesystem
+ file, err := e.fs.Open(filePath)
+ if err != nil {
+ return nil, nil, err
+ }
+ rs, isSeekable := file.(io.ReadSeeker)
+ if !isSeekable {
+ file.Close()
+ return nil, nil, errors.New("file is not seekable")
+ }
+ // WithFilename provides a format detection hint via the file extension,
+ // since OpenStream alone relies on content-sniffing which fails for some files.
+ f, err = taglib.OpenStream(rs,
+ taglib.WithReadStyle(taglib.ReadStyleFast),
+ taglib.WithFilename(filePath),
+ )
+ if err != nil {
+ file.Close()
+ return nil, nil, err
+ }
+ closeFunc = func() {
+ f.Close()
+ file.Close()
+ }
+ return f, closeFunc, nil
+}
+
+// parseTuple parses track/disc numbers in "N/Total" format and separates them.
+// For example, tracknumber="2/10" becomes tracknumber="2" and tracktotal="10".
+func parseTuple(tags map[string][]string, 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]}
+ }
+ }
+}
+
+// parseLyrics ensures lyrics tags have a language code.
+// If lyrics exist without a language code, they are moved to "lyrics:xxx".
+func parseLyrics(tags map[string][]string) {
+ lyrics := tags["lyrics"]
+ if len(lyrics) > 0 {
+ tags["lyrics:xxx"] = lyrics
+ delete(tags, "lyrics")
+ }
+}
+
+// processRawTags processes format-specific raw tags based on the detected file format.
+// This handles ID3v2 frames (MP3/WAV/AIFF), MP4 atoms, and ASF attributes.
+func processRawTags(allTags taglib.AllTags, normalizedTags map[string][]string) {
+ switch allTags.Format {
+ case taglib.FormatMPEG, taglib.FormatWAV, taglib.FormatAIFF:
+ parseID3v2Frames(allTags.Raw, normalizedTags)
+ case taglib.FormatMP4:
+ parseMP4Atoms(allTags.Raw, normalizedTags)
+ case taglib.FormatASF:
+ parseASFAttributes(allTags.Raw, normalizedTags)
+ }
+}
+
+// parseID3v2Frames processes ID3v2 raw frames to extract USLT/SYLT with language codes.
+// This extracts language-specific lyrics that the standard Tags() doesn't provide.
+func parseID3v2Frames(rawFrames map[string][]string, tags map[string][]string) {
+ // Process frames that have language-specific data
+ for key, values := range rawFrames {
+ lowerKey := strings.ToLower(key)
+
+ // Handle USLT:xxx and SYLT:xxx (lyrics with language codes)
+ if strings.HasPrefix(lowerKey, "uslt:") || strings.HasPrefix(lowerKey, "sylt:") {
+ parts := strings.SplitN(lowerKey, ":", 2)
+ if len(parts) == 2 && parts[1] != "" {
+ lang := parts[1]
+ lyricsKey := "lyrics:" + lang
+ tags[lyricsKey] = append(tags[lyricsKey], values...)
+ }
+ }
+ }
+
+ // If we found any language-specific lyrics from ID3v2 frames, remove the generic lyrics
+ for key := range tags {
+ if strings.HasPrefix(key, "lyrics:") && key != "lyrics" {
+ delete(tags, "lyrics")
+ break
+ }
+ }
+}
+
+const iTunesKeyPrefix = "----:com.apple.iTunes:"
+
+// parseMP4Atoms processes MP4 raw atoms to get iTunes-specific tags.
+func parseMP4Atoms(rawAtoms map[string][]string, tags map[string][]string) {
+ // Process all atoms and add them to tags
+ for key, values := range rawAtoms {
+ // Strip iTunes prefix and convert to lowercase
+ normalizedKey := strings.TrimPrefix(key, iTunesKeyPrefix)
+ normalizedKey = strings.ToLower(normalizedKey)
+
+ // Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
+ if _, exists := tags[normalizedKey]; !exists {
+ tags[normalizedKey] = values
+ }
+ }
+}
+
+// parseASFAttributes processes ASF raw attributes to get WMA-specific tags.
+func parseASFAttributes(rawAttrs map[string][]string, tags map[string][]string) {
+ // Process all attributes and add them to tags
+ for key, values := range rawAttrs {
+ normalizedKey := strings.ToLower(key)
+
+ // Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
+ if _, exists := tags[normalizedKey]; !exists {
+ tags[normalizedKey] = values
+ }
+ }
+}
+
+// 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",
+}
+
+// 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.SplitSeq(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("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
+ return &extractor{fsys}
+ })
+ conf.AddHook(func() {
+ log.Debug("go-taglib version", "version", extractor{}.Version())
+ })
+}
diff --git a/adapters/taglib/taglib_suite_test.go b/adapters/gotaglib/gotaglib_suite_test.go
similarity index 74%
rename from adapters/taglib/taglib_suite_test.go
rename to adapters/gotaglib/gotaglib_suite_test.go
index 2b26612cf..cc7ddc471 100644
--- a/adapters/taglib/taglib_suite_test.go
+++ b/adapters/gotaglib/gotaglib_suite_test.go
@@ -1,4 +1,4 @@
-package taglib
+package gotaglib
import (
"testing"
@@ -9,9 +9,9 @@ import (
. "github.com/onsi/gomega"
)
-func TestTagLib(t *testing.T) {
+func TestGoTagLib(t *testing.T) {
tests.Init(t, true)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
- RunSpecs(t, "TagLib Suite")
+ RunSpecs(t, "GoTagLib Suite")
}
diff --git a/adapters/taglib/taglib_test.go b/adapters/gotaglib/gotaglib_test.go
similarity index 86%
rename from adapters/taglib/taglib_test.go
rename to adapters/gotaglib/gotaglib_test.go
index 37b012763..05924914d 100644
--- a/adapters/taglib/taglib_test.go
+++ b/adapters/gotaglib/gotaglib_test.go
@@ -1,10 +1,11 @@
-package taglib
+package gotaglib
import (
"io/fs"
"os"
"strings"
+ "github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -14,7 +15,7 @@ var _ = Describe("Extractor", func() {
var e *extractor
BeforeEach(func() {
- e = &extractor{}
+ e = &extractor{fs: os.DirFS(".")}
})
Describe("Parse", func() {
@@ -80,12 +81,11 @@ var _ = Describe("Extractor", func() {
Expect(err).To(BeNil())
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
- // TabLib 1.12 returns 18, previous versions return 39.
+ // 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.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.HasPicture).To(BeTrue())
})
@@ -106,7 +106,7 @@ var _ = Describe("Extractor", func() {
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
- HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{albumGain}),
+ HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
))
Expect(m.Tags).To(Or(
@@ -128,6 +128,17 @@ 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(
@@ -174,12 +185,15 @@ var _ = Describe("Extractor", func() {
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=1100:duration=1" -c:a libopus test.opus (tags added via mutagen)
+ Entry("correctly parses opus tags (#4998)", "test.opus", "1s", 1, 48000, 0, "+5.12 dB", "0.11345678", "+5.12 dB", "0.11345678", 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, false),
+ 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),
@@ -200,6 +214,9 @@ 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")
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
@@ -212,20 +229,25 @@ var _ = Describe("Extractor", func() {
})
It("correctly handle unreadable file due to insufficient read permission", func() {
- _, err := e.extractMetadata(accessForbiddenFile)
+ // Strip leading slash for DirFS rooted at "/"
+ _, err := e.extractMetadata(accessForbiddenFile[1:])
Expect(err).To(MatchError(os.ErrPermission))
})
It("skips the file if it cannot be read", func() {
+ // Get current working directory to construct paths relative to root
+ cwd, err := os.Getwd()
+ Expect(err).ToNot(HaveOccurred())
+ // Strip leading slash for DirFS rooted at "/"
files := []string{
- "tests/fixtures/test.mp3",
- "tests/fixtures/test.ogg",
- accessForbiddenFile,
+ cwd[1:] + "/tests/fixtures/test.mp3",
+ cwd[1:] + "/tests/fixtures/test.ogg",
+ accessForbiddenFile[1:],
}
mds, err := e.Parse(files...)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
- Expect(mds).ToNot(HaveKey(accessForbiddenFile))
+ Expect(mds).ToNot(HaveKey(accessForbiddenFile[1:]))
})
})
})
diff --git a/core/agents/lastfm/agent.go b/adapters/lastfm/agent.go
similarity index 63%
rename from core/agents/lastfm/agent.go
rename to adapters/lastfm/agent.go
index 3f5f44d20..02c198120 100644
--- a/core/agents/lastfm/agent.go
+++ b/adapters/lastfm/agent.go
@@ -26,18 +26,25 @@ const (
sessionKeyProperty = "LastFMSessionKey"
)
-var ignoredBiographies = []string{
- // Unknown Artist
+var ignoredContent = []string{
+ // Empty Artist/Album
`Read more on Last\.fm\.?`)
+
+func cleanContent(content string) string {
+ return strings.TrimSpace(lastFMReadMoreRegex.ReplaceAllString(content, ""))
+}
+
type lastfmAgent struct {
ds model.DataStore
sessionKeys *agents.SessionKeys
apiKey string
secret string
- lang string
+ languages []string
client *client
+ httpClient httpDoer
getInfoMutex sync.Mutex
}
@@ -47,7 +54,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
}
l := &lastfmAgent{
ds: ds,
- lang: conf.Server.LastFM.Language,
+ languages: conf.Server.LastFM.Languages,
apiKey: conf.Server.LastFM.ApiKey,
secret: conf.Server.LastFM.Secret,
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
@@ -56,7 +63,8 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
- l.client = newClient(l.apiKey, l.secret, l.lang, chc)
+ l.httpClient = chc
+ l.client = newClient(l.apiKey, l.secret, chc)
return l
}
@@ -66,22 +74,54 @@ func (l *lastfmAgent) AgentName() string {
var imageRegex = regexp.MustCompile(`u\/(\d+)`)
+// isValidContent checks if content is non-empty and not in the ignored list
+func isValidContent(content string) bool {
+ content = strings.TrimSpace(content)
+ if content == "" {
+ return false
+ }
+ for _, ign := range ignoredContent {
+ if strings.HasPrefix(content, ign) {
+ return false
+ }
+ }
+ return true
+}
+
func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
- a, err := l.callAlbumGetInfo(ctx, name, artist, mbid)
+ var a *Album
+ var resp agents.AlbumInfo
+ for _, lang := range l.languages {
+ var err error
+ a, err = l.callAlbumGetInfo(ctx, name, artist, mbid, lang)
+ if err != nil {
+ return nil, err
+ }
+ resp.Name = a.Name
+ resp.MBID = a.MBID
+ resp.URL = a.URL
+ if isValidContent(a.Description.Summary) {
+ resp.Description = cleanContent(a.Description.Summary)
+ return &resp, nil
+ }
+ log.Debug(ctx, "LastFM/album.getInfo returned empty/ignored description, trying next language", "album", name, "artist", artist, "lang", lang)
+ }
+ // This condition should not be hit (languages default to ["en"]), but just in case
+ if a == nil {
+ return nil, agents.ErrNotFound
+ }
+ return &resp, nil
+}
+
+func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
+ a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0])
if err != nil {
return nil, err
}
- response := agents.AlbumInfo{
- Name: a.Name,
- MBID: a.MBID,
- Description: a.Description.Summary,
- URL: a.URL,
- Images: make([]agents.ExternalImage, 0),
- }
-
// Last.fm can return duplicate sizes.
seenSizes := map[int]bool{}
+ images := make([]agents.ExternalImage, 0)
// This assumes that Last.fm returns images with size small, medium, and large.
// This is true as of December 29, 2022
@@ -92,27 +132,24 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
log.Trace(ctx, "LastFM/albuminfo image URL does not match expected regex or is empty", "url", img.URL, "size", img.Size)
continue
}
-
numericSize, err := strconv.Atoi(size[0][2:])
if err != nil {
log.Error(ctx, "LastFM/albuminfo image URL does not match expected regex", "url", img.URL, "size", img.Size, err)
return nil, err
- } else {
- if _, exists := seenSizes[numericSize]; !exists {
- response.Images = append(response.Images, agents.ExternalImage{
- Size: numericSize,
- URL: img.URL,
- })
- seenSizes[numericSize] = true
- }
+ }
+ if _, exists := seenSizes[numericSize]; !exists {
+ images = append(images, agents.ExternalImage{
+ Size: numericSize,
+ URL: img.URL,
+ })
+ seenSizes[numericSize] = true
}
}
-
- return &response, nil
+ return images, nil
}
func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
- a, err := l.callArtistGetInfo(ctx, name)
+ a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
if err != nil {
return "", err
}
@@ -123,7 +160,7 @@ func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string)
}
func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
- a, err := l.callArtistGetInfo(ctx, name)
+ a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
if err != nil {
return "", err
}
@@ -134,20 +171,17 @@ func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (
}
func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
- a, err := l.callArtistGetInfo(ctx, name)
- if err != nil {
- return "", err
- }
- a.Bio.Summary = strings.TrimSpace(a.Bio.Summary)
- if a.Bio.Summary == "" {
- return "", agents.ErrNotFound
- }
- for _, ign := range ignoredBiographies {
- if strings.HasPrefix(a.Bio.Summary, ign) {
- return "", nil
+ for _, lang := range l.languages {
+ a, err := l.callArtistGetInfo(ctx, name, lang)
+ if err != nil {
+ return "", err
}
+ if isValidContent(a.Bio.Summary) {
+ return cleanContent(a.Bio.Summary), nil
+ }
+ log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang)
}
- return a.Bio.Summary, nil
+ return "", agents.ErrNotFound
}
func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
@@ -186,14 +220,34 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi
return res, nil
}
-var artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
+func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
+ resp, err := l.callTrackGetSimilar(ctx, name, artist, count)
+ if err != nil {
+ return nil, err
+ }
+ if len(resp) == 0 {
+ return nil, agents.ErrNotFound
+ }
+ res := make([]agents.Song, 0, len(resp))
+ for _, t := range resp {
+ res = append(res, agents.Song{
+ Name: t.Name,
+ MBID: t.MBID,
+ Artist: t.Artist.Name,
+ ArtistMBID: t.Artist.MBID,
+ })
+ }
+ return res, nil
+}
+
+var (
+ artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
+ artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
+)
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
- hc := http.Client{
- Timeout: consts.DefaultHttpClientTimeOut,
- }
- a, err := l.callArtistGetInfo(ctx, name)
+ a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
if err != nil {
return nil, fmt.Errorf("get artist info: %w", err)
}
@@ -201,7 +255,7 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
if err != nil {
return nil, fmt.Errorf("create artist image request: %w", err)
}
- resp, err := hc.Do(req)
+ resp, err := l.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("get artist url: %w", err)
}
@@ -218,24 +272,29 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
return res, nil
}
for _, attr := range n.Attr {
- if attr.Key == "content" {
- res = []agents.ExternalImage{
- {URL: attr.Val},
- }
- break
+ if attr.Key != "content" {
+ continue
+ }
+ if strings.Contains(attr.Val, artistIgnoredImage) {
+ log.Debug(ctx, "Artist image is ignored default image", "name", name, "url", attr.Val)
+ return res, nil
+ }
+
+ res = []agents.ExternalImage{
+ {URL: attr.Val},
}
}
return res, nil
}
-func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) {
- a, err := l.client.albumGetInfo(ctx, name, artist, mbid)
+func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) {
+ a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang)
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && (isLastFMError && lfErr.Code == 6) {
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
- return l.callAlbumGetInfo(ctx, name, artist, "")
+ return l.callAlbumGetInfo(ctx, name, artist, "", lang)
}
if err != nil {
@@ -249,11 +308,11 @@ func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid s
return a, nil
}
-func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string) (*Artist, error) {
+func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
l.getInfoMutex.Lock()
defer l.getInfoMutex.Unlock()
- a, err := l.client.artistGetInfo(ctx, name)
+ a, err := l.client.artistGetInfo(ctx, name, lang)
if err != nil {
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
return nil, err
@@ -279,20 +338,36 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
return t.Track, nil
}
-func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
+func (l *lastfmAgent) callTrackGetSimilar(ctx context.Context, name, artist string, count int) ([]SimilarTrack, error) {
+ s, err := l.client.trackGetSimilar(ctx, name, artist, count)
+ if err != nil {
+ log.Error(ctx, "Error calling LastFM/track.getSimilar", "track", name, "artist", artist, err)
+ return nil, err
+ }
+ return s.Track, nil
+}
+
+func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
+ if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
+ return track.Participants[role][0].Name
+ }
+ return displayName
+}
+
+func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return scrobbler.ErrNotAuthorized
}
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
- artist: track.Artist,
+ artist: l.getArtistForScrobble(track, model.RoleArtist, track.Artist),
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
mbid: track.MbzRecordingID,
duration: int(track.Duration),
- albumArtist: track.AlbumArtist,
+ albumArtist: l.getArtistForScrobble(track, model.RoleAlbumArtist, track.AlbumArtist),
})
if err != nil {
log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err)
@@ -312,13 +387,13 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
return nil
}
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
- artist: s.Artist,
+ artist: l.getArtistForScrobble(&s.MediaFile, model.RoleArtist, s.Artist),
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,
mbid: s.MbzRecordingID,
duration: int(s.Duration),
- albumArtist: s.AlbumArtist,
+ albumArtist: l.getArtistForScrobble(&s.MediaFile, model.RoleAlbumArtist, s.AlbumArtist),
timestamp: s.TimeStamp,
})
if err == nil {
@@ -341,6 +416,10 @@ 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 {
diff --git a/core/agents/lastfm/agent_test.go b/adapters/lastfm/agent_test.go
similarity index 52%
rename from core/agents/lastfm/agent_test.go
rename to adapters/lastfm/agent_test.go
index de4fac6d6..94788b8bd 100644
--- a/core/agents/lastfm/agent_test.go
+++ b/adapters/lastfm/agent_test.go
@@ -6,6 +6,7 @@ import (
"errors"
"io"
"net/http"
+ "net/url"
"os"
"strconv"
"time"
@@ -38,12 +39,12 @@ var _ = Describe("lastfmAgent", func() {
})
Describe("lastFMConstructor", func() {
When("Agent is properly configured", func() {
- It("uses configured api key and language", func() {
- conf.Server.LastFM.Language = "pt"
+ It("uses configured api key and languages", func() {
+ conf.Server.LastFM.Languages = []string{"pt", "en"}
agent := lastFMConstructor(ds)
Expect(agent.apiKey).To(Equal("123"))
Expect(agent.secret).To(Equal("secret"))
- Expect(agent.lang).To(Equal("pt"))
+ Expect(agent.languages).To(Equal([]string{"pt", "en"}))
})
})
When("Agent is disabled", func() {
@@ -71,7 +72,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
- client := newClient("API_KEY", "SECRET", "pt", httpClient)
+ client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
@@ -79,7 +80,7 @@ var _ = Describe("lastfmAgent", func() {
It("returns the biography", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
- Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente. Read more on Last.fm"))
+ Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente."))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
@@ -101,12 +102,129 @@ var _ = Describe("lastfmAgent", func() {
})
})
+ Describe("Language Fallback", func() {
+ Describe("GetArtistBiography", func() {
+ var agent *lastfmAgent
+ var httpClient *langAwareHttpClient
+
+ BeforeEach(func() {
+ httpClient = newLangAwareHttpClient()
+ })
+
+ It("returns content in first language when available (1 API call)", func() {
+ conf.Server.LastFM.Languages = []string{"pt", "en"}
+ agent = lastFMConstructor(ds)
+ agent.client = newClient("API_KEY", "SECRET", httpClient)
+
+ // Portuguese biography available
+ f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ httpClient.responses["pt"] = http.Response{Body: f, StatusCode: 200}
+
+ bio, err := agent.GetArtistBiography(ctx, "123", "U2", "")
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(bio).To(ContainSubstring("U2 é uma das mais importantes bandas de rock"))
+ Expect(httpClient.requestCount).To(Equal(1))
+ Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("pt"))
+ })
+
+ It("falls back to second language when first returns empty (2 API calls)", func() {
+ conf.Server.LastFM.Languages = []string{"ja", "en"}
+ agent = lastFMConstructor(ds)
+ agent.client = newClient("API_KEY", "SECRET", httpClient)
+
+ // Japanese returns empty/ignored biography (actual Last.fm response with just "Read more" link)
+ fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
+ httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
+ // English returns full biography
+ fEn, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.en.json")
+ httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
+
+ bio, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(bio).To(ContainSubstring("Legião Urbana was a Brazilian post-punk band"))
+ Expect(httpClient.requestCount).To(Equal(2))
+ Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
+ Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
+ })
+
+ It("returns ErrNotFound when all languages return empty", func() {
+ conf.Server.LastFM.Languages = []string{"ja", "xx"}
+ agent = lastFMConstructor(ds)
+ agent.client = newClient("API_KEY", "SECRET", httpClient)
+
+ // Both languages return empty/ignored biography (using actual Last.fm response format)
+ fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
+ httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
+ // Second language also returns empty
+ fXx, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
+ httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
+
+ _, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
+
+ Expect(err).To(MatchError(agents.ErrNotFound))
+ Expect(httpClient.requestCount).To(Equal(2))
+ })
+ })
+
+ Describe("GetAlbumInfo", func() {
+ var agent *lastfmAgent
+ var httpClient *langAwareHttpClient
+
+ BeforeEach(func() {
+ httpClient = newLangAwareHttpClient()
+ })
+
+ It("falls back to second language when first returns empty description (2 API calls)", func() {
+ conf.Server.LastFM.Languages = []string{"ja", "en"}
+ agent = lastFMConstructor(ds)
+ agent.client = newClient("API_KEY", "SECRET", httpClient)
+
+ // Japanese returns album without wiki/description (actual Last.fm response)
+ fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
+ httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
+ // English returns album with description
+ fEn, _ := os.Open("tests/fixtures/lastfm.album.getinfo.en.json")
+ httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
+
+ albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(albumInfo.Name).To(Equal("Dois"))
+ Expect(albumInfo.Description).To(ContainSubstring("segundo álbum de estúdio"))
+ Expect(httpClient.requestCount).To(Equal(2))
+ Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
+ Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
+ })
+
+ It("returns album without description when all languages return empty", func() {
+ conf.Server.LastFM.Languages = []string{"ja", "xx"}
+ agent = lastFMConstructor(ds)
+ agent.client = newClient("API_KEY", "SECRET", httpClient)
+
+ // Both languages return album without description
+ fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
+ httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
+ fXx, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
+ httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
+
+ albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(albumInfo.Name).To(Equal("Dois"))
+ Expect(albumInfo.Description).To(BeEmpty())
+ Expect(httpClient.requestCount).To(Equal(2))
+ })
+ })
+ })
+
Describe("GetSimilarArtists", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
- client := newClient("API_KEY", "SECRET", "pt", httpClient)
+ client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
@@ -144,7 +262,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
- client := newClient("API_KEY", "SECRET", "pt", httpClient)
+ client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
@@ -177,6 +295,54 @@ var _ = Describe("lastfmAgent", func() {
})
})
+ Describe("GetSimilarSongsByTrack", func() {
+ var agent *lastfmAgent
+ var httpClient *tests.FakeHttpClient
+ BeforeEach(func() {
+ httpClient = &tests.FakeHttpClient{}
+ client := newClient("API_KEY", "SECRET", httpClient)
+ agent = lastFMConstructor(ds)
+ agent.client = client
+ })
+
+ It("returns similar songs", 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", 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"))
+ Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Depeche Mode"))
+ })
+
+ It("returns ErrNotFound when no similar songs found", func() {
+ f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ _, err := agent.GetSimilarSongsByTrack(ctx, "123", "UnknownTrack", "UnknownArtist", "", 3)
+ Expect(err).To(MatchError(agents.ErrNotFound))
+ Expect(httpClient.RequestCount).To(Equal(1))
+ })
+
+ It("returns an error if Last.fm call fails", func() {
+ httpClient.Err = errors.New("error")
+ _, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ })
+
+ It("returns an error if Last.fm call returns an error", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
+ _, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ })
+ })
+
Describe("Scrobbling", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
@@ -184,7 +350,7 @@ var _ = Describe("lastfmAgent", func() {
BeforeEach(func() {
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{}
- client := newClient("API_KEY", "SECRET", "en", httpClient)
+ client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
track = &model.MediaFile{
@@ -196,6 +362,16 @@ var _ = Describe("lastfmAgent", func() {
TrackNumber: 1,
Duration: 180,
MbzRecordingID: "mbz-123",
+ Participants: map[model.Role]model.ParticipantList{
+ model.RoleArtist: []model.Participant{
+ {Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
+ {Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
+ },
+ model.RoleAlbumArtist: []model.Participant{
+ {Artist: model.Artist{ID: "ar-1", Name: "First Album Artist"}},
+ {Artist: model.Artist{ID: "ar-2", Name: "Second Album Artist"}},
+ },
+ },
}
})
@@ -203,11 +379,12 @@ var _ = Describe("lastfmAgent", func() {
It("calls Last.fm with correct params", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
- err := agent.NowPlaying(ctx, "user-1", track)
+ err := agent.NowPlaying(ctx, "user-1", track, 0)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
- sentParams := httpClient.SavedRequest.URL.Query()
+ body, _ := io.ReadAll(httpClient.SavedRequest.Body)
+ sentParams, _ := url.ParseQuery(string(body))
Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying"))
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
Expect(sentParams.Get("track")).To(Equal(track.Title))
@@ -220,9 +397,27 @@ var _ = Describe("lastfmAgent", func() {
})
It("returns ErrNotAuthorized if user is not linked", func() {
- err := agent.NowPlaying(ctx, "user-2", track)
+ err := agent.NowPlaying(ctx, "user-2", track, 0)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
+
+ When("ScrobbleFirstArtistOnly is true", func() {
+ BeforeEach(func() {
+ conf.Server.LastFM.ScrobbleFirstArtistOnly = true
+ })
+
+ It("uses only the first artist", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
+
+ err := agent.NowPlaying(ctx, "user-1", track, 0)
+
+ Expect(err).ToNot(HaveOccurred())
+ body, _ := io.ReadAll(httpClient.SavedRequest.Body)
+ sentParams, _ := url.ParseQuery(string(body))
+ Expect(sentParams.Get("artist")).To(Equal("First Artist"))
+ Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
+ })
+ })
})
Describe("scrobble", func() {
@@ -234,7 +429,8 @@ var _ = Describe("lastfmAgent", func() {
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
- sentParams := httpClient.SavedRequest.URL.Query()
+ body, _ := io.ReadAll(httpClient.SavedRequest.Body)
+ sentParams, _ := url.ParseQuery(string(body))
Expect(sentParams.Get("method")).To(Equal("track.scrobble"))
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
Expect(sentParams.Get("track")).To(Equal(track.Title))
@@ -247,6 +443,25 @@ var _ = Describe("lastfmAgent", func() {
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
})
+ When("ScrobbleFirstArtistOnly is true", func() {
+ BeforeEach(func() {
+ conf.Server.LastFM.ScrobbleFirstArtistOnly = true
+ })
+
+ It("uses only the first artist", func() {
+ ts := time.Now()
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
+
+ err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
+
+ Expect(err).ToNot(HaveOccurred())
+ body, _ := io.ReadAll(httpClient.SavedRequest.Body)
+ sentParams, _ := url.ParseQuery(string(body))
+ Expect(sentParams.Get("artist")).To(Equal("First Artist"))
+ Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
+ })
+ })
+
It("skips songs with less than 31 seconds", func() {
track.Duration = 29
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
@@ -309,7 +524,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
- client := newClient("API_KEY", "SECRET", "pt", httpClient)
+ client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
@@ -320,26 +535,8 @@ var _ = Describe("lastfmAgent", func() {
Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{
Name: "Believe",
MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62",
- Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob Read more on Last.fm.",
+ Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob",
URL: "https://www.last.fm/music/Cher/Believe",
- Images: []agents.ExternalImage{
- {
- URL: "https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png",
- Size: 34,
- },
- {
- URL: "https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png",
- Size: 64,
- },
- {
- URL: "https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png",
- Size: 174,
- },
- {
- URL: "https://lastfm.freetls.fastly.net/i/u/300x300/3b54885952161aaea4ce2965b2db1638.png",
- Size: 300,
- },
- },
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62"))
@@ -349,9 +546,8 @@ var _ = Describe("lastfmAgent", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty_urls.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetAlbumInfo(ctx, "The Definitive Less Damage And More Joy", "The Jesus and Mary Chain", "")).To(Equal(&agents.AlbumInfo{
- Name: "The Definitive Less Damage And More Joy",
- URL: "https://www.last.fm/music/The+Jesus+and+Mary+Chain/The+Definitive+Less+Damage+And+More+Joy",
- Images: []agents.ExternalImage{},
+ Name: "The Definitive Less Damage And More Joy",
+ URL: "https://www.last.fm/music/The+Jesus+and+Mary+Chain/The+Definitive+Less+Damage+And+More+Joy",
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("The Definitive Less Damage And More Joy"))
@@ -389,4 +585,101 @@ var _ = Describe("lastfmAgent", func() {
})
})
})
+
+ Describe("GetArtistImages", func() {
+ var agent *lastfmAgent
+ var apiClient *tests.FakeHttpClient
+ var httpClient *tests.FakeHttpClient
+
+ BeforeEach(func() {
+ apiClient = &tests.FakeHttpClient{}
+ httpClient = &tests.FakeHttpClient{}
+ client := newClient("API_KEY", "SECRET", apiClient)
+ agent = lastFMConstructor(ds)
+ agent.client = client
+ agent.httpClient = httpClient
+ })
+
+ It("returns the artist image from the page", func() {
+ fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
+
+ fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.html")
+ httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
+
+ images, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(images).To(HaveLen(1))
+ Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
+ })
+
+ It("returns empty list if image is the ignored default image", func() {
+ fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
+
+ fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.ignored.html")
+ httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
+
+ images, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(images).To(BeEmpty())
+ })
+
+ It("returns empty list if page has no meta tags", func() {
+ fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
+
+ fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
+ httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
+
+ images, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(images).To(BeEmpty())
+ })
+
+ It("returns error if API call fails", func() {
+ apiClient.Err = errors.New("api error")
+ _, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("get artist info"))
+ })
+
+ It("returns error if scraper call fails", func() {
+ fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
+
+ httpClient.Err = errors.New("scraper error")
+ _, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("get artist url"))
+ })
+ })
})
+
+// langAwareHttpClient is a mock HTTP client that returns different responses based on the lang parameter
+type langAwareHttpClient struct {
+ responses map[string]http.Response
+ requests []*http.Request
+ requestCount int
+}
+
+func newLangAwareHttpClient() *langAwareHttpClient {
+ return &langAwareHttpClient{
+ responses: make(map[string]http.Response),
+ requests: make([]*http.Request, 0),
+ }
+}
+
+func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
+ c.requestCount++
+ c.requests = append(c.requests, req)
+ lang := req.URL.Query().Get("lang")
+ if resp, ok := c.responses[lang]; ok {
+ return &resp, nil
+ }
+ // Return default empty response if no specific response is configured
+ return &http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{}`)),
+ }, nil
+}
diff --git a/core/agents/lastfm/auth_router.go b/adapters/lastfm/auth_router.go
similarity index 83%
rename from core/agents/lastfm/auth_router.go
rename to adapters/lastfm/auth_router.go
index 290caaad3..499863e28 100644
--- a/core/agents/lastfm/auth_router.go
+++ b/adapters/lastfm/auth_router.go
@@ -44,7 +44,7 @@ func NewRouter(ds model.DataStore) *Router {
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
- r.client = newClient(r.apiKey, r.secret, "en", hc)
+ r.client = newClient(r.apiKey, r.secret, hc)
return r
}
@@ -65,7 +65,7 @@ func (s *Router) routes() http.Handler {
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
- resp := map[string]interface{}{
+ resp := map[string]any{
"apiKey": s.apiKey,
}
u, _ := request.UserFrom(r.Context())
@@ -77,6 +77,13 @@ 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)
}
@@ -97,11 +104,17 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
return
}
- uid, err := p.String("uid")
+ linkToken, 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
@@ -110,7 +123,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
if err != nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx)))
+ _, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) //nolint:gosec
return
}
diff --git a/adapters/lastfm/auth_router_test.go b/adapters/lastfm/auth_router_test.go
new file mode 100644
index 000000000..4cbbd4298
--- /dev/null
+++ b/adapters/lastfm/auth_router_test.go
@@ -0,0 +1,218 @@
+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"))
+ })
+ })
+})
diff --git a/core/agents/lastfm/client.go b/adapters/lastfm/client.go
similarity index 84%
rename from core/agents/lastfm/client.go
rename to adapters/lastfm/client.go
index 6a24ac80a..726df1360 100644
--- a/core/agents/lastfm/client.go
+++ b/adapters/lastfm/client.go
@@ -34,24 +34,23 @@ type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
-func newClient(apiKey string, secret string, lang string, hc httpDoer) *client {
- return &client{apiKey, secret, lang, hc}
+func newClient(apiKey string, secret string, hc httpDoer) *client {
+ return &client{apiKey, secret, hc}
}
type client struct {
apiKey string
secret string
- lang string
hc httpDoer
}
-func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string) (*Album, error) {
+func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
params := url.Values{}
params.Add("method", "album.getInfo")
params.Add("album", name)
params.Add("artist", artist)
params.Add("mbid", mbid)
- params.Add("lang", c.lang)
+ params.Add("lang", lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
return nil, err
@@ -59,11 +58,11 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
return &response.Album, nil
}
-func (c *client) artistGetInfo(ctx context.Context, name string) (*Artist, error) {
+func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
params := url.Values{}
params.Add("method", "artist.getInfo")
params.Add("artist", name)
- params.Add("lang", c.lang)
+ params.Add("lang", lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
return nil, err
@@ -95,6 +94,19 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int)
return &response.TopTracks, nil
}
+func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
+ params := url.Values{}
+ params.Add("method", "track.getSimilar")
+ params.Add("track", name)
+ params.Add("artist", artist)
+ params.Add("limit", strconv.Itoa(limit))
+ response, err := c.makeRequest(ctx, http.MethodGet, params, false)
+ if err != nil {
+ return nil, err
+ }
+ return &response.SimilarTracks, nil
+}
+
func (c *client) GetToken(ctx context.Context) (string, error) {
params := url.Values{}
params.Add("method", "auth.getToken")
@@ -185,8 +197,15 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
c.sign(params)
}
- req, _ := http.NewRequestWithContext(ctx, method, apiBaseUrl, nil)
- req.URL.RawQuery = params.Encode()
+ var req *http.Request
+ if method == http.MethodPost {
+ body := strings.NewReader(params.Encode())
+ req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, body)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ } else {
+ req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, nil)
+ req.URL.RawQuery = params.Encode()
+ }
log.Trace(ctx, fmt.Sprintf("Sending Last.fm %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
diff --git a/core/agents/lastfm/client_test.go b/adapters/lastfm/client_test.go
similarity index 59%
rename from core/agents/lastfm/client_test.go
rename to adapters/lastfm/client_test.go
index 85ec11506..271ae1419 100644
--- a/core/agents/lastfm/client_test.go
+++ b/adapters/lastfm/client_test.go
@@ -22,7 +22,7 @@ var _ = Describe("client", func() {
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
- client = newClient("API_KEY", "SECRET", "pt", httpClient)
+ client = newClient("API_KEY", "SECRET", httpClient)
})
Describe("albumGetInfo", func() {
@@ -30,7 +30,7 @@ var _ = Describe("client", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
- album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234")
+ album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt")
Expect(err).To(BeNil())
Expect(album.Name).To(Equal("Believe"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
@@ -42,7 +42,7 @@ var _ = Describe("client", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
- artist, err := client.artistGetInfo(context.Background(), "U2")
+ artist, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(BeNil())
Expect(artist.Name).To(Equal("U2"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
@@ -54,7 +54,7 @@ var _ = Describe("client", func() {
StatusCode: 500,
}
- _, err := client.artistGetInfo(context.Background(), "U2")
+ _, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError("last.fm http status: (500)"))
})
@@ -64,7 +64,7 @@ var _ = Describe("client", func() {
StatusCode: 400,
}
- _, err := client.artistGetInfo(context.Background(), "U2")
+ _, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"}))
})
@@ -74,14 +74,14 @@ var _ = Describe("client", func() {
StatusCode: 200,
}
- _, err := client.artistGetInfo(context.Background(), "U2")
+ _, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
})
It("fails if HttpClient.Do() returns error", func() {
httpClient.Err = errors.New("generic error")
- _, err := client.artistGetInfo(context.Background(), "U2")
+ _, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError("generic error"))
})
@@ -91,7 +91,7 @@ var _ = Describe("client", func() {
StatusCode: 200,
}
- _, err := client.artistGetInfo(context.Background(), "U2")
+ _, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
})
@@ -121,6 +121,30 @@ var _ = Describe("client", func() {
})
})
+ Describe("trackGetSimilar", func() {
+ It("returns similar tracks for a successful response", func() {
+ f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+
+ similar, err := client.trackGetSimilar(context.Background(), "Just Can't Get Enough", "Depeche Mode", 5)
+ Expect(err).To(BeNil())
+ Expect(len(similar.Track)).To(Equal(5))
+ Expect(similar.Track[0].Name).To(Equal("Dreaming of Me"))
+ Expect(similar.Track[0].Artist.Name).To(Equal("Depeche Mode"))
+ Expect(similar.Track[0].Match).To(Equal(1.0))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
+ })
+
+ It("returns empty list when no similar tracks found", func() {
+ f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+
+ similar, err := client.trackGetSimilar(context.Background(), "UnknownTrack", "UnknownArtist", 3)
+ Expect(err).To(BeNil())
+ Expect(similar.Track).To(BeEmpty())
+ })
+ })
+
Describe("GetToken", func() {
It("returns a token when the request is successful", func() {
httpClient.Res = http.Response{
@@ -154,6 +178,74 @@ var _ = Describe("client", func() {
})
})
+ Describe("scrobble", func() {
+ It("sends parameters in request body for POST", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"scrobbles":{"scrobble":{"ignoredMessage":{"code":"0"}},"@attr":{"accepted":1}}}`)),
+ StatusCode: 200,
+ }
+
+ info := ScrobbleInfo{
+ artist: "U2",
+ track: "One",
+ album: "Achtung Baby",
+ trackNumber: 1,
+ duration: 276,
+ albumArtist: "U2",
+ }
+ err := client.scrobble(context.Background(), "SESSION_KEY", info)
+ Expect(err).To(BeNil())
+
+ req := httpClient.SavedRequest
+ Expect(req.Method).To(Equal(http.MethodPost))
+ Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
+ Expect(req.URL.RawQuery).To(BeEmpty())
+
+ body, _ := io.ReadAll(req.Body)
+ bodyParams, _ := url.ParseQuery(string(body))
+ Expect(bodyParams.Get("method")).To(Equal("track.scrobble"))
+ Expect(bodyParams.Get("artist")).To(Equal("U2"))
+ Expect(bodyParams.Get("track")).To(Equal("One"))
+ Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
+ Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
+ Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
+ })
+ })
+
+ Describe("updateNowPlaying", func() {
+ It("sends parameters in request body for POST", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"nowplaying":{"ignoredMessage":{"code":"0"}}}`)),
+ StatusCode: 200,
+ }
+
+ info := ScrobbleInfo{
+ artist: "U2",
+ track: "One",
+ album: "Achtung Baby",
+ trackNumber: 1,
+ duration: 276,
+ albumArtist: "U2",
+ }
+ err := client.updateNowPlaying(context.Background(), "SESSION_KEY", info)
+ Expect(err).To(BeNil())
+
+ req := httpClient.SavedRequest
+ Expect(req.Method).To(Equal(http.MethodPost))
+ Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
+ Expect(req.URL.RawQuery).To(BeEmpty())
+
+ body, _ := io.ReadAll(req.Body)
+ bodyParams, _ := url.ParseQuery(string(body))
+ Expect(bodyParams.Get("method")).To(Equal("track.updateNowPlaying"))
+ Expect(bodyParams.Get("artist")).To(Equal("U2"))
+ Expect(bodyParams.Get("track")).To(Equal("One"))
+ Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
+ Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
+ Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
+ })
+ })
+
Describe("sign", func() {
It("adds an api_sig param with the signature", func() {
params := url.Values{}
diff --git a/core/agents/lastfm/lastfm_suite_test.go b/adapters/lastfm/lastfm_suite_test.go
similarity index 100%
rename from core/agents/lastfm/lastfm_suite_test.go
rename to adapters/lastfm/lastfm_suite_test.go
diff --git a/adapters/lastfm/link_token.go b/adapters/lastfm/link_token.go
new file mode 100644
index 000000000..fd8ceb3c9
--- /dev/null
+++ b/adapters/lastfm/link_token.go
@@ -0,0 +1,50 @@
+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
+}
diff --git a/core/agents/lastfm/responses.go b/adapters/lastfm/responses.go
similarity index 84%
rename from core/agents/lastfm/responses.go
rename to adapters/lastfm/responses.go
index 1ceebe767..026741672 100644
--- a/core/agents/lastfm/responses.go
+++ b/adapters/lastfm/responses.go
@@ -5,6 +5,7 @@ type Response struct {
SimilarArtists SimilarArtists `json:"similarartists"`
TopTracks TopTracks `json:"toptracks"`
Album Album `json:"album"`
+ SimilarTracks SimilarTracks `json:"similartracks"`
Error int `json:"error"`
Message string `json:"message"`
Token string `json:"token"`
@@ -59,6 +60,28 @@ type TopTracks struct {
Attr Attr `json:"@attr"`
}
+type SimilarTracks struct {
+ Track []SimilarTrack `json:"track"`
+ Attr SimilarAttr `json:"@attr"`
+}
+
+type SimilarTrack struct {
+ Name string `json:"name"`
+ MBID string `json:"mbid"`
+ Match float64 `json:"match"`
+ Artist SimilarTrackArtist `json:"artist"`
+}
+
+type SimilarTrackArtist struct {
+ Name string `json:"name"`
+ MBID string `json:"mbid"`
+}
+
+type SimilarAttr struct {
+ Artist string `json:"artist"`
+ Track string `json:"track"`
+}
+
type Session struct {
Name string `json:"name"`
Key string `json:"key"`
diff --git a/core/agents/lastfm/responses_test.go b/adapters/lastfm/responses_test.go
similarity index 100%
rename from core/agents/lastfm/responses_test.go
rename to adapters/lastfm/responses_test.go
diff --git a/core/agents/lastfm/token_received.html b/adapters/lastfm/token_received.html
similarity index 100%
rename from core/agents/lastfm/token_received.html
rename to adapters/lastfm/token_received.html
diff --git a/core/agents/listenbrainz/agent.go b/adapters/listenbrainz/agent.go
similarity index 54%
rename from core/agents/listenbrainz/agent.go
rename to adapters/listenbrainz/agent.go
index 200e9f63c..826a9672e 100644
--- a/core/agents/listenbrainz/agent.go
+++ b/adapters/listenbrainz/agent.go
@@ -73,7 +73,7 @@ func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
return li
}
-func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
+func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return errors.Join(err, scrobbler.ErrNotAuthorized)
@@ -118,12 +118,133 @@ func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) boo
return err == nil && sk != ""
}
+func (l *listenBrainzAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
+ if mbid == "" {
+ return "", agents.ErrNotFound
+ }
+
+ url, err := l.client.getArtistUrl(ctx, mbid)
+ if err != nil {
+ return "", err
+ }
+ return url, nil
+}
+
+func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
+ resp, err := l.client.getArtistTopSongs(ctx, mbid, count)
+ if err != nil {
+ return nil, err
+ }
+ if len(resp) == 0 {
+ return nil, agents.ErrNotFound
+ }
+
+ 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,
+ Artist: t.ArtistName,
+ ArtistMBID: mbid,
+ Duration: t.DurationMs,
+ Name: t.RecordingName,
+ MBID: t.RecordingMbid,
+ }
+ }
+ return res, nil
+}
+
+func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) {
+ if mbid == "" {
+ return nil, agents.ErrNotFound
+ }
+
+ resp, err := l.client.getSimilarArtists(ctx, mbid, limit)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(resp) == 0 {
+ return nil, agents.ErrNotFound
+ }
+
+ artists := make([]agents.Artist, len(resp))
+ for i, artist := range resp {
+ artists[i] = agents.Artist{
+ MBID: artist.MBID,
+ Name: artist.Name,
+ }
+ }
+
+ return artists, nil
+}
+
+func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id string, name string, artist string, mbid string, limit int) ([]agents.Song, error) {
+ if mbid == "" {
+ return nil, agents.ErrNotFound
+ }
+
+ resp, err := l.client.getSimilarRecordings(ctx, mbid, limit)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(resp) == 0 {
+ return nil, agents.ErrNotFound
+ }
+
+ songs := make([]agents.Song, len(resp))
+ for i, song := range resp {
+ songs[i] = agents.Song{
+ Album: song.ReleaseName,
+ AlbumMBID: song.ReleaseMBID,
+ Artist: song.Artist,
+ MBID: song.MBID,
+ Name: song.Name,
+ }
+ }
+
+ return songs, nil
+}
+
+func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
+ return nil
+}
+
func init() {
conf.AddHook(func() {
if conf.Server.ListenBrainz.Enabled {
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
- return listenBrainzConstructor(ds)
+ // This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
+ // See https://go.dev/doc/faq#nil_error
+ a := listenBrainzConstructor(ds)
+ if a != nil {
+ return a
+ }
+ return nil
+ })
+
+ agents.Register(listenBrainzAgentName, func(ds model.DataStore) agents.Interface {
+ // This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
+ // See https://go.dev/doc/faq#nil_error
+ a := listenBrainzConstructor(ds)
+ if a != nil {
+ return a
+ }
+ return nil
})
}
})
}
+
+var (
+ _ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil)
+ _ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil)
+ _ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil)
+ _ agents.SimilarSongsByTrackRetriever = (*listenBrainzAgent)(nil)
+)
diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go
new file mode 100644
index 000000000..df70ec9c4
--- /dev/null
+++ b/adapters/listenbrainz/agent_test.go
@@ -0,0 +1,443 @@
+package listenbrainz
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/core/agents"
+ "github.com/navidrome/navidrome/core/scrobbler"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ . "github.com/onsi/gomega/gstruct"
+)
+
+var _ = Describe("listenBrainzAgent", func() {
+ var ds model.DataStore
+ var ctx context.Context
+ var agent *listenBrainzAgent
+ var httpClient *tests.FakeHttpClient
+ var track *model.MediaFile
+
+ BeforeEach(func() {
+ ds = &tests.MockDataStore{}
+ ctx = context.Background()
+ _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
+ httpClient = &tests.FakeHttpClient{}
+ agent = listenBrainzConstructor(ds)
+ agent.client = newClient("http://localhost:8080", httpClient)
+ track = &model.MediaFile{
+ ID: "123",
+ Title: "Track Title",
+ Album: "Track Album",
+ Artist: "Track Artist",
+ TrackNumber: 1,
+ MbzRecordingID: "mbz-123",
+ MbzAlbumID: "mbz-456",
+ MbzReleaseGroupID: "mbz-789",
+ Duration: 142.2,
+ Participants: map[model.Role]model.ParticipantList{
+ model.RoleArtist: []model.Participant{
+ {Artist: model.Artist{ID: "ar-1", Name: "Artist 1", MbzArtistID: "mbz-111"}},
+ {Artist: model.Artist{ID: "ar-2", Name: "Artist 2", MbzArtistID: "mbz-222"}},
+ },
+ },
+ }
+ })
+
+ Describe("formatListen", func() {
+ It("constructs the listenInfo properly", func() {
+ lr := agent.formatListen(track)
+ Expect(lr).To(MatchAllFields(Fields{
+ "ListenedAt": Equal(0),
+ "TrackMetadata": MatchAllFields(Fields{
+ "ArtistName": Equal(track.Artist),
+ "TrackName": Equal(track.Title),
+ "ReleaseName": Equal(track.Album),
+ "AdditionalInfo": MatchAllFields(Fields{
+ "SubmissionClient": Equal(consts.AppName),
+ "SubmissionClientVersion": Equal(consts.Version),
+ "TrackNumber": Equal(track.TrackNumber),
+ "RecordingMBID": Equal(track.MbzRecordingID),
+ "ReleaseMBID": Equal(track.MbzAlbumID),
+ "ReleaseGroupMBID": Equal(track.MbzReleaseGroupID),
+ "ArtistNames": ConsistOf("Artist 1", "Artist 2"),
+ "ArtistMBIDs": ConsistOf("mbz-111", "mbz-222"),
+ "DurationMs": Equal(142200),
+ }),
+ }),
+ }))
+ })
+ })
+
+ Describe("NowPlaying", func() {
+ It("updates NowPlaying successfully", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
+
+ err := agent.NowPlaying(ctx, "user-1", track, 0)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("returns ErrNotAuthorized if user is not linked", func() {
+ err := agent.NowPlaying(ctx, "user-2", track, 0)
+ Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
+ })
+ })
+
+ Describe("Scrobble", func() {
+ var sc scrobbler.Scrobble
+
+ BeforeEach(func() {
+ sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}
+ })
+
+ It("sends a Scrobble successfully", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
+
+ err := agent.Scrobble(ctx, "user-1", sc)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("sets the Timestamp properly", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
+
+ err := agent.Scrobble(ctx, "user-1", sc)
+ Expect(err).ToNot(HaveOccurred())
+
+ decoder := json.NewDecoder(httpClient.SavedRequest.Body)
+ var lr listenBrainzRequestBody
+ err = decoder.Decode(&lr)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix())))
+ })
+
+ It("returns ErrNotAuthorized if user is not linked", func() {
+ err := agent.Scrobble(ctx, "user-2", sc)
+ Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
+ })
+
+ It("returns ErrRetryLater on error 503", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)),
+ StatusCode: 503,
+ }
+
+ err := agent.Scrobble(ctx, "user-1", sc)
+ Expect(err).To(MatchError(scrobbler.ErrRetryLater))
+ })
+
+ It("returns ErrRetryLater on error 500", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)),
+ StatusCode: 500,
+ }
+
+ err := agent.Scrobble(ctx, "user-1", sc)
+ Expect(err).To(MatchError(scrobbler.ErrRetryLater))
+ })
+
+ It("returns ErrRetryLater on http errors", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)),
+ StatusCode: 500,
+ }
+
+ err := agent.Scrobble(ctx, "user-1", sc)
+ Expect(err).To(MatchError(scrobbler.ErrRetryLater))
+ })
+
+ It("returns ErrUnrecoverable on other errors", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)),
+ StatusCode: 400,
+ }
+
+ err := agent.Scrobble(ctx, "user-1", sc)
+ Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
+ })
+ })
+
+ Describe("GetArtistUrl", func() {
+ var agent *listenBrainzAgent
+ var httpClient *tests.FakeHttpClient
+ BeforeEach(func() {
+ httpClient = &tests.FakeHttpClient{}
+ client := newClient("BASE_URL", httpClient)
+ agent = listenBrainzConstructor(ds)
+ agent.client = client
+ })
+
+ It("returns artist url when MBID present", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ Expect(agent.GetArtistURL(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")).To(Equal("http://projectmili.com/"))
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
+ })
+
+ It("returns error when url not present", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
+ })
+
+ It("returns error when fetch calls fails", func() {
+ httpClient.Err = errors.New("error")
+ _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
+ })
+
+ It("returns error when ListenBrainz returns an error", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
+ StatusCode: 400,
+ }
+ _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
+ })
+ })
+
+ Describe("GetTopSongs", func() {
+ var agent *listenBrainzAgent
+ var httpClient *tests.FakeHttpClient
+ BeforeEach(func() {
+ httpClient = &tests.FakeHttpClient{}
+ client := newClient("BASE_URL", httpClient)
+ agent = listenBrainzConstructor(ds)
+ agent.client = client
+ })
+
+ It("returns error when fetch calls", func() {
+ httpClient.Err = errors.New("error")
+ _, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
+ })
+
+ It("returns an error on listenbrainz error", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
+ StatusCode: 400,
+ }
+ _, err := agent.GetArtistTopSongs(ctx, "", "", "1", 1)
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/1"))
+ })
+
+ It("returns all tracks when asked", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 2)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(data).To(Equal([]agents.Song{
+ {
+ 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",
+ Artist: "Mili",
+ ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
+ Album: "String Theocracy",
+ AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
+ Duration: 174000,
+ },
+ }))
+ })
+
+ It("returns only one track when prompted", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(data).To(Equal([]agents.Song{
+ {
+ 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,
+ },
+ }))
+ })
+ })
+
+ Describe("GetSimilarArtists", func() {
+ var agent *listenBrainzAgent
+ var httpClient *tests.FakeHttpClient
+ baseUrl := "https://labs.api.listenbrainz.org/similar-artists/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids="
+ mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
+
+ BeforeEach(func() {
+ httpClient = &tests.FakeHttpClient{}
+ client := newClient("BASE_URL", httpClient)
+ agent = listenBrainzConstructor(ds)
+ agent.client = client
+ })
+
+ It("returns error when fetch calls", func() {
+ httpClient.Err = errors.New("error")
+ _, err := agent.GetSimilarArtists(ctx, "", "", mbid, 1)
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
+ })
+
+ It("returns an error on listenbrainz error", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
+ StatusCode: 400,
+ }
+ _, err := agent.GetSimilarArtists(ctx, "", "", "1", 1)
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
+ })
+
+ It("returns all data on call", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+
+ resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 2)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
+ Expect(resp).To(Equal([]agents.Artist{
+ {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
+ {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha"},
+ }))
+ })
+
+ It("returns subset of data on call", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+
+ resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
+ Expect(resp).To(Equal([]agents.Artist{
+ {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
+ }))
+ })
+ })
+
+ Describe("GetSimilarTracks", func() {
+ var agent *listenBrainzAgent
+ var httpClient *tests.FakeHttpClient
+ mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
+ baseUrl := "https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&recording_mbids="
+
+ BeforeEach(func() {
+ httpClient = &tests.FakeHttpClient{}
+ client := newClient("BASE_URL", httpClient)
+ agent = listenBrainzConstructor(ds)
+ agent.client = client
+ })
+
+ It("returns error when fetch calls", func() {
+ httpClient.Err = errors.New("error")
+ _, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
+ })
+
+ It("returns an error on listenbrainz error", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
+ StatusCode: 400,
+ }
+ _, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", "1", 1)
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
+ })
+
+ It("returns all data on call", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+
+ resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 2)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ 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: "",
+ 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: "",
+ Artist: "Wham!",
+ ArtistMBID: "",
+ Album: "Make It Big",
+ AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
+ Duration: 0,
+ },
+ }))
+ })
+
+ It("returns subset of data on call", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+
+ resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ 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: "",
+ Artist: "a‐ha",
+ ArtistMBID: "",
+ Album: "Hunting High and Low",
+ AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
+ Duration: 0,
+ },
+ }))
+ })
+ })
+})
diff --git a/core/agents/listenbrainz/auth_router.go b/adapters/listenbrainz/auth_router.go
similarity index 95%
rename from core/agents/listenbrainz/auth_router.go
rename to adapters/listenbrainz/auth_router.go
index 2382aeb73..7cb9eb16a 100644
--- a/core/agents/listenbrainz/auth_router.go
+++ b/adapters/listenbrainz/auth_router.go
@@ -60,7 +60,7 @@ func (s *Router) routes() http.Handler {
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
- resp := map[string]interface{}{}
+ resp := map[string]any{}
u, _ := request.UserFrom(r.Context())
key, err := s.sessionKeys.Get(r.Context(), u.ID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
@@ -107,7 +107,7 @@ func (s *Router) link(w http.ResponseWriter, r *http.Request) {
return
}
- _ = rest.RespondWithJSON(w, http.StatusOK, map[string]interface{}{"status": resp.Valid, "user": resp.UserName})
+ _ = rest.RespondWithJSON(w, http.StatusOK, map[string]any{"status": resp.Valid, "user": resp.UserName})
}
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
diff --git a/core/agents/listenbrainz/auth_router_test.go b/adapters/listenbrainz/auth_router_test.go
similarity index 97%
rename from core/agents/listenbrainz/auth_router_test.go
rename to adapters/listenbrainz/auth_router_test.go
index dc705dbc9..c3861799a 100644
--- a/core/agents/listenbrainz/auth_router_test.go
+++ b/adapters/listenbrainz/auth_router_test.go
@@ -37,7 +37,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
r.getLinkStatus(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
- var parsed map[string]interface{}
+ var parsed map[string]any
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(false))
})
@@ -47,7 +47,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
r.getLinkStatus(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
- var parsed map[string]interface{}
+ var parsed map[string]any
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(true))
})
@@ -80,7 +80,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`))
r.link(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
- var parsed map[string]interface{}
+ var parsed map[string]any
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(true))
Expect(parsed["user"]).To(Equal("ListenBrainzUser"))
diff --git a/adapters/listenbrainz/client.go b/adapters/listenbrainz/client.go
new file mode 100644
index 000000000..708f02f28
--- /dev/null
+++ b/adapters/listenbrainz/client.go
@@ -0,0 +1,378 @@
+package listenbrainz
+
+import (
+ "bytes"
+ "cmp"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "path"
+ "slices"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/log"
+)
+
+const (
+ lbzApiUrl = "https://api.listenbrainz.org/1/"
+ labsBase = "https://labs.api.listenbrainz.org/"
+)
+
+var (
+ ErrorNotFound = errors.New("listenbrainz: not found")
+)
+
+type listenBrainzError struct {
+ Code int
+ Message string
+}
+
+func (e *listenBrainzError) Error() string {
+ return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message)
+}
+
+type httpDoer interface {
+ Do(req *http.Request) (*http.Response, error)
+}
+
+func newClient(baseURL string, hc httpDoer) *client {
+ return &client{baseURL, hc}
+}
+
+type client struct {
+ baseURL string
+ hc httpDoer
+}
+
+type listenBrainzResponse struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Error string `json:"error"`
+ Status string `json:"status"`
+ Valid bool `json:"valid"`
+ UserName string `json:"user_name"`
+}
+
+type listenBrainzRequest struct {
+ ApiKey string //nolint:gosec
+ Body listenBrainzRequestBody
+}
+
+type listenBrainzRequestBody struct {
+ ListenType listenType `json:"listen_type,omitempty"`
+ Payload []listenInfo `json:"payload,omitempty"`
+}
+
+type listenType string
+
+const (
+ Single listenType = "single"
+ PlayingNow listenType = "playing_now"
+)
+
+type listenInfo struct {
+ ListenedAt int `json:"listened_at,omitempty"`
+ TrackMetadata trackMetadata `json:"track_metadata"`
+}
+
+type trackMetadata struct {
+ ArtistName string `json:"artist_name,omitempty"`
+ TrackName string `json:"track_name,omitempty"`
+ ReleaseName string `json:"release_name,omitempty"`
+ AdditionalInfo additionalInfo `json:"additional_info"`
+}
+
+type additionalInfo struct {
+ SubmissionClient string `json:"submission_client,omitempty"`
+ SubmissionClientVersion string `json:"submission_client_version,omitempty"`
+ TrackNumber int `json:"tracknumber,omitempty"`
+ ArtistNames []string `json:"artist_names,omitempty"`
+ ArtistMBIDs []string `json:"artist_mbids,omitempty"`
+ RecordingMBID string `json:"recording_mbid,omitempty"`
+ ReleaseMBID string `json:"release_mbid,omitempty"`
+ ReleaseGroupMBID string `json:"release_group_mbid,omitempty"`
+ DurationMs int `json:"duration_ms,omitempty"`
+}
+
+func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {
+ r := &listenBrainzRequest{
+ ApiKey: apiKey,
+ }
+ response, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "validate-token", r)
+ if err != nil {
+ return nil, err
+ }
+ return response, nil
+}
+
+func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error {
+ r := &listenBrainzRequest{
+ ApiKey: apiKey,
+ Body: listenBrainzRequestBody{
+ ListenType: PlayingNow,
+ Payload: []listenInfo{li},
+ },
+ }
+
+ resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
+ if err != nil {
+ return err
+ }
+ if resp.Status != "ok" {
+ log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status)
+ }
+ return nil
+}
+
+func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) error {
+ r := &listenBrainzRequest{
+ ApiKey: apiKey,
+ Body: listenBrainzRequestBody{
+ ListenType: Single,
+ Payload: []listenInfo{li},
+ },
+ }
+ resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
+ if err != nil {
+ return err
+ }
+ if resp.Status != "ok" {
+ log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status)
+ }
+ return nil
+}
+
+func (c *client) path(endpoint string) (string, error) {
+ u, err := url.Parse(c.baseURL)
+ if err != nil {
+ return "", err
+ }
+ u.Path = path.Join(u.Path, endpoint)
+ return u.String(), nil
+}
+
+func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
+ b, _ := json.Marshal(r.Body)
+ uri, err := c.path(endpoint)
+ if err != nil {
+ return nil, err
+ }
+ req, _ := http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b))
+ req.Header.Add("Content-Type", "application/json; charset=UTF-8")
+
+ if r.ApiKey != "" {
+ req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey))
+ }
+
+ log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
+ resp, err := c.hc.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ defer resp.Body.Close()
+ decoder := json.NewDecoder(resp.Body)
+
+ var response listenBrainzResponse
+ jsonErr := decoder.Decode(&response)
+ if resp.StatusCode != 200 && jsonErr != nil {
+ return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
+ }
+ if jsonErr != nil {
+ return nil, jsonErr
+ }
+ if response.Code != 0 && response.Code != 200 {
+ return &response, &listenBrainzError{Code: response.Code, Message: response.Error}
+ }
+
+ return &response, nil
+}
+
+type lbzHttpError struct {
+ Code int `json:"code"`
+ Error string `json:"error"`
+}
+
+func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint string, params url.Values) (*http.Response, error) {
+ req, _ := http.NewRequestWithContext(ctx, method, lbzApiUrl+endpoint, nil)
+ req.Header.Add("Content-Type", "application/json; charset=UTF-8")
+ req.URL.RawQuery = params.Encode()
+
+ log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
+ resp, err := c.hc.Do(req)
+
+ if err != nil {
+ return nil, err
+ }
+
+ // On a 200 code, there is no code. Decode using using error message if it exists
+ if resp.StatusCode != 200 {
+ defer resp.Body.Close()
+ decoder := json.NewDecoder(resp.Body)
+
+ var lbzError lbzHttpError
+ jsonErr := decoder.Decode(&lbzError)
+
+ if jsonErr != nil {
+ return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
+ }
+
+ return nil, &listenBrainzError{Code: lbzError.Code, Message: lbzError.Error}
+ }
+
+ return resp, err
+}
+
+type artistMetadataResult struct {
+ Rels struct {
+ OfficialHomepage string `json:"official homepage,omitempty"`
+ } `json:"rels,omitzero"`
+}
+
+func (c *client) getArtistUrl(ctx context.Context, mbid string) (string, error) {
+ params := url.Values{}
+ params.Add("artist_mbids", mbid)
+ resp, err := c.makeGenericRequest(ctx, http.MethodGet, "metadata/artist", params)
+ if err != nil {
+ return "", err
+ }
+
+ defer resp.Body.Close()
+ decoder := json.NewDecoder(resp.Body)
+
+ var response []artistMetadataResult
+ jsonErr := decoder.Decode(&response)
+ if jsonErr != nil {
+ return "", fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
+ }
+
+ if len(response) == 0 || response[0].Rels.OfficialHomepage == "" {
+ return "", ErrorNotFound
+ }
+
+ return response[0].Rels.OfficialHomepage, nil
+}
+
+type trackInfo struct {
+ ArtistName string `json:"artist_name"`
+ ArtistMBIDs []string `json:"artist_mbids"`
+ DurationMs uint32 `json:"length"`
+ RecordingName string `json:"recording_name"`
+ RecordingMbid string `json:"recording_mbid"`
+ ReleaseName string `json:"release_name"`
+ ReleaseMBID string `json:"release_mbid"`
+}
+
+func (c *client) getArtistTopSongs(ctx context.Context, mbid string, count int) ([]trackInfo, error) {
+ resp, err := c.makeGenericRequest(ctx, http.MethodGet, "popularity/top-recordings-for-artist/"+mbid, url.Values{})
+ if err != nil {
+ return nil, err
+ }
+
+ defer resp.Body.Close()
+ decoder := json.NewDecoder(resp.Body)
+
+ var response []trackInfo
+ jsonErr := decoder.Decode(&response)
+ if jsonErr != nil {
+ return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
+ }
+
+ if len(response) > count {
+ return response[0:count], nil
+ }
+
+ return response, nil
+}
+
+type artist struct {
+ MBID string `json:"artist_mbid"`
+ Name string `json:"name"`
+ Score int `json:"score"`
+}
+
+func (c *client) getSimilarArtists(ctx context.Context, mbid string, limit int) ([]artist, error) {
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil)
+ req.Header.Add("Content-Type", "application/json; charset=UTF-8")
+ req.URL.RawQuery = url.Values{
+ "artist_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.ArtistAlgorithm},
+ }.Encode()
+
+ log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
+ resp, err := c.hc.Do(req)
+
+ if err != nil {
+ return nil, err
+ }
+
+ defer resp.Body.Close()
+ decoder := json.NewDecoder(resp.Body)
+
+ var artists []artist
+ jsonErr := decoder.Decode(&artists)
+ if jsonErr != nil {
+ return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
+ }
+
+ if len(artists) > limit {
+ return artists[:limit], nil
+ }
+
+ return artists, nil
+}
+
+type recording struct {
+ MBID string `json:"recording_mbid"`
+ Name string `json:"recording_name"`
+ Artist string `json:"artist_credit_name"`
+ ReleaseName string `json:"release_name"`
+ ReleaseMBID string `json:"release_mbid"`
+ Score int `json:"score"`
+}
+
+func (c *client) getSimilarRecordings(ctx context.Context, mbid string, limit int) ([]recording, error) {
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil)
+ req.Header.Add("Content-Type", "application/json; charset=UTF-8")
+ req.URL.RawQuery = url.Values{
+ "recording_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.TrackAlgorithm},
+ }.Encode()
+
+ log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
+ resp, err := c.hc.Do(req)
+
+ if err != nil {
+ return nil, err
+ }
+
+ defer resp.Body.Close()
+ decoder := json.NewDecoder(resp.Body)
+
+ var recordings []recording
+ jsonErr := decoder.Decode(&recordings)
+ if jsonErr != nil {
+ return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
+ }
+
+ // For whatever reason, labs API isn't guaranteed to give results in the proper order
+ // and may also provide duplicates. See listenbrainz.labs.similar-recordings-real-out-of-order.json
+ // generated from https://labs.api.listenbrainz.org/similar-recordings/json?recording_mbids=8f3471b5-7e6a-48da-86a9-c1c07a0f47ae&algorithm=session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30
+ slices.SortFunc(recordings, func(a, b recording) int {
+ return cmp.Or(
+ cmp.Compare(b.Score, a.Score), // Sort by score descending
+ cmp.Compare(a.MBID, b.MBID), // Then by MBID ascending to ensure deterministic order for duplicates
+ )
+ })
+
+ recordings = slices.CompactFunc(recordings, func(a, b recording) bool {
+ return a.MBID == b.MBID
+ })
+
+ if len(recordings) > limit {
+ return recordings[:limit], nil
+ }
+
+ return recordings, nil
+}
diff --git a/adapters/listenbrainz/client_test.go b/adapters/listenbrainz/client_test.go
new file mode 100644
index 000000000..319cf01ab
--- /dev/null
+++ b/adapters/listenbrainz/client_test.go
@@ -0,0 +1,464 @@
+package listenbrainz
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("client", func() {
+ var httpClient *tests.FakeHttpClient
+ var client *client
+ BeforeEach(func() {
+ httpClient = &tests.FakeHttpClient{}
+ client = newClient("BASE_URL/", httpClient)
+ })
+
+ Describe("listenBrainzResponse", func() {
+ It("parses a response properly", func() {
+ var response listenBrainzResponse
+ err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response.Code).To(Equal(200))
+ Expect(response.Message).To(Equal("Message"))
+ Expect(response.UserName).To(Equal("UserName"))
+ Expect(response.Valid).To(BeTrue())
+ Expect(response.Status).To(Equal("ok"))
+ Expect(response.Error).To(Equal("Error"))
+ })
+ })
+
+ Describe("validateToken", func() {
+ BeforeEach(func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
+ StatusCode: 200,
+ }
+ })
+
+ It("formats the request properly", func() {
+ _, err := client.validateToken(context.Background(), "LB-TOKEN")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/validate-token"))
+ Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ })
+
+ It("parses and returns the response", func() {
+ res, err := client.validateToken(context.Background(), "LB-TOKEN")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(res.Valid).To(Equal(true))
+ Expect(res.UserName).To(Equal("ListenBrainzUser"))
+ })
+ })
+
+ Context("with listenInfo", func() {
+ var li listenInfo
+ BeforeEach(func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)),
+ StatusCode: 200,
+ }
+ li = listenInfo{
+ TrackMetadata: trackMetadata{
+ ArtistName: "Track Artist",
+ TrackName: "Track Title",
+ ReleaseName: "Track Album",
+ AdditionalInfo: additionalInfo{
+ TrackNumber: 1,
+ ArtistNames: []string{"Artist 1", "Artist 2"},
+ ArtistMBIDs: []string{"mbz-789", "mbz-012"},
+ RecordingMBID: "mbz-123",
+ ReleaseMBID: "mbz-456",
+ DurationMs: 142200,
+ },
+ },
+ }
+ })
+
+ Describe("updateNowPlaying", func() {
+ It("formats the request properly", func() {
+ Expect(client.updateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
+ Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+
+ body, _ := io.ReadAll(httpClient.SavedRequest.Body)
+ f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json")
+ Expect(body).To(MatchJSON(f))
+ })
+ })
+
+ Describe("scrobble", func() {
+ BeforeEach(func() {
+ li.ListenedAt = 1635000000
+ })
+
+ It("formats the request properly", func() {
+ Expect(client.scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
+ Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+
+ body, _ := io.ReadAll(httpClient.SavedRequest.Body)
+ f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json")
+ Expect(body).To(MatchJSON(f))
+ })
+ })
+ })
+
+ Context("getArtistUrl", func() {
+ baseUrl := "https://api.listenbrainz.org/1/metadata/artist?"
+ It("handles a malformed request with status code", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
+ StatusCode: 400,
+ }
+ _, err := client.getArtistUrl(context.Background(), "1")
+ Expect(err.Error()).To(Equal("ListenBrainz error(400): artist mbid 1 is not valid."))
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ })
+
+ It("handles a malformed request without meaningful body", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(``)),
+ StatusCode: 501,
+ }
+ _, err := client.getArtistUrl(context.Background(), "1")
+ Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (501)"))
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ })
+
+ It("It returns not found when the artist has no official homepage", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ _, err := client.getArtistUrl(context.Background(), "7c2cc610-f998-43ef-a08f-dae3344b8973")
+ Expect(err.Error()).To(Equal("listenbrainz: not found"))
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=7c2cc610-f998-43ef-a08f-dae3344b8973"))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ })
+
+ It("It returns data when the artist has a homepage", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ url, err := client.getArtistUrl(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(url).To(Equal("http://projectmili.com/"))
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ })
+ })
+
+ Context("getArtistTopSongs", func() {
+ baseUrl := "https://api.listenbrainz.org/1/popularity/top-recordings-for-artist/"
+
+ It("handles a malformed request with status code", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
+ StatusCode: 400,
+ }
+ _, err := client.getArtistTopSongs(context.Background(), "1", 50)
+ Expect(err.Error()).To(Equal("ListenBrainz error(400): artist_mbid: '1' is not a valid uuid"))
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ })
+
+ It("handles a malformed request without standard body", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(``)),
+ StatusCode: 500,
+ }
+ _, err := client.getArtistTopSongs(context.Background(), "1", 1)
+ Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (500)"))
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ })
+
+ It("It returns all tracks when given the opportunity", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 5)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(data).To(Equal([]trackInfo{
+ {
+ ArtistName: "Mili",
+ ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
+ DurationMs: 211912,
+ RecordingName: "world.execute(me);",
+ RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
+ ReleaseName: "Miracle Milk",
+ ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
+ },
+ {
+ ArtistName: "Mili",
+ ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
+ DurationMs: 174000,
+ RecordingName: "String Theocracy",
+ RecordingMbid: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
+ ReleaseName: "String Theocracy",
+ ReleaseMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
+ },
+ }))
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ })
+
+ It("It returns a subset of tracks when allowed", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(data).To(Equal([]trackInfo{
+ {
+ ArtistName: "Mili",
+ ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
+ DurationMs: 211912,
+ RecordingName: "world.execute(me);",
+ RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
+ ReleaseName: "Miracle Milk",
+ ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
+ },
+ }))
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ })
+ })
+
+ Context("getSimilarArtists", func() {
+ var algorithm string
+
+ BeforeEach(func() {
+ algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
+ DeferCleanup(configtest.SetupConfig())
+ })
+
+ getUrl := func(mbid string) string {
+ return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-artists/json?algorithm=%s&artist_mbids=%s", algorithm, mbid)
+ }
+
+ mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
+
+ It("handles a malformed request with status code", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
+ StatusCode: 400,
+ }
+ _, err := client.getSimilarArtists(context.Background(), "1", 2)
+ Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ })
+
+ It("handles real data properly", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ Expect(resp).To(Equal([]artist{
+ {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
+ {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
+ }))
+ })
+
+ It("truncates data when requested", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ resp, err := client.getSimilarArtists(context.Background(), "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ Expect(resp).To(Equal([]artist{
+ {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
+ }))
+ })
+
+ It("fetches a different endpoint when algorithm changes", func() {
+ algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
+ conf.Server.ListenBrainz.ArtistAlgorithm = algorithm
+
+ f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ Expect(resp).To(Equal([]artist{
+ {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
+ {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
+ }))
+ })
+ })
+
+ Context("getSimilarRecordings", func() {
+ var algorithm string
+
+ BeforeEach(func() {
+ algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
+ DeferCleanup(configtest.SetupConfig())
+ })
+
+ getUrl := func(mbid string) string {
+ return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=%s&recording_mbids=%s", algorithm, mbid)
+ }
+
+ mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
+
+ It("handles a malformed request with status code", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
+ StatusCode: 400,
+ }
+ _, err := client.getSimilarRecordings(context.Background(), "1", 2)
+ Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ })
+
+ It("handles real data properly", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ resp, err := client.getSimilarRecordings(context.Background(), mbid, 2)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ Expect(resp).To(Equal([]recording{
+ {
+ MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
+ Name: "Take On Me",
+ Artist: "a‐ha",
+ ReleaseName: "Hunting High and Low",
+ ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
+ Score: 124,
+ },
+ {
+ MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
+ Name: "Wake Me Up Before You Go‐Go",
+ Artist: "Wham!",
+ ReleaseName: "Make It Big",
+ ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
+ Score: 65,
+ },
+ }))
+ })
+
+ It("truncates data when requested", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ Expect(resp).To(Equal([]recording{
+ {
+ MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
+ Name: "Take On Me",
+ Artist: "a‐ha",
+ ReleaseName: "Hunting High and Low",
+ ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
+ Score: 124,
+ },
+ }))
+ })
+
+ It("properly sorts by score and truncates duplicates", func() {
+ f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ // There are actually 5 items. The dedup should happen FIRST
+ resp, err := client.getSimilarRecordings(context.Background(), mbid, 4)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ Expect(resp).To(Equal([]recording{
+ {
+ MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
+ Name: "Take On Me",
+ Artist: "a‐ha",
+ ReleaseName: "Hunting High and Low",
+ ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
+ Score: 124,
+ },
+ {
+ MBID: "e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5",
+ Name: "Everybody Wants to Rule the World",
+ Artist: "Tears for Fears",
+ ReleaseName: "Songs From the Big Chair",
+ ReleaseMBID: "21f19b06-81f1-347a-add5-5d0c77696597",
+ Score: 68,
+ },
+ {
+ MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
+ Name: "Wake Me Up Before You Go‐Go",
+ Artist: "Wham!",
+ ReleaseName: "Make It Big",
+ ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
+ Score: 65,
+ },
+ {
+ MBID: "ef4c6855-949e-4e22-b41e-8e0a2d372d5f",
+ Name: "Tainted Love",
+ Artist: "Soft Cell",
+ ReleaseName: "Non-Stop Erotic Cabaret",
+ ReleaseMBID: "1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1",
+ Score: 61,
+ },
+ }))
+ })
+
+ It("uses a different algorithm when configured", func() {
+ algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
+ conf.Server.ListenBrainz.TrackAlgorithm = algorithm
+
+ f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ Expect(resp).To(Equal([]recording{
+ {
+ MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
+ Name: "Take On Me",
+ Artist: "a‐ha",
+ ReleaseName: "Hunting High and Low",
+ ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
+ Score: 124,
+ },
+ }))
+ })
+ })
+})
diff --git a/core/agents/listenbrainz/listenbrainz_suite_test.go b/adapters/listenbrainz/listenbrainz_suite_test.go
similarity index 100%
rename from core/agents/listenbrainz/listenbrainz_suite_test.go
rename to adapters/listenbrainz/listenbrainz_suite_test.go
diff --git a/adapters/taglib/get_filename.go b/adapters/taglib/get_filename.go
deleted file mode 100644
index df7cab860..000000000
--- a/adapters/taglib/get_filename.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build !windows
-
-package taglib
-
-import "C"
-
-func getFilename(s string) *C.char {
- return C.CString(s)
-}
diff --git a/adapters/taglib/get_filename_win.go b/adapters/taglib/get_filename_win.go
deleted file mode 100644
index 2093616c8..000000000
--- a/adapters/taglib/get_filename_win.go
+++ /dev/null
@@ -1,96 +0,0 @@
-//go:build windows
-
-package taglib
-
-// From https://github.com/orofarne/gowchar
-
-/*
-#include
-
-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)
-}
diff --git a/adapters/taglib/taglib.go b/adapters/taglib/taglib.go
deleted file mode 100644
index c89dabf62..000000000
--- a/adapters/taglib/taglib.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package taglib
-
-import (
- "io/fs"
- "path/filepath"
- "strconv"
- "strings"
- "time"
-
- "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{}
- if length, ok := tags["_lengthinmilliseconds"]; ok && len(length) > 0 {
- millis, _ := strconv.Atoi(length[0])
- if millis > 0 {
- ap.Duration = (time.Millisecond * time.Duration(millis)).Round(time.Millisecond * 10)
- }
- delete(tags, "_lengthinmilliseconds")
- }
- parseProp := func(prop string, target *int) {
- if value, ok := tags[prop]; ok && len(value) > 0 {
- *target, _ = strconv.Atoi(value[0])
- delete(tags, prop)
- }
- }
- parseProp("_bitrate", &ap.BitRate)
- parseProp("_channels", &ap.Channels)
- parseProp("_samplerate", &ap.SampleRate)
- parseProp("_bitspersample", &ap.BitDepth)
-
- // 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",
-}
-
-// 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("taglib", func(_ fs.FS, baseDir string) local.Extractor {
- // ignores fs, as taglib extractor only works with local files
- return &extractor{baseDir}
- })
-}
diff --git a/adapters/taglib/taglib_wrapper.cpp b/adapters/taglib/taglib_wrapper.cpp
deleted file mode 100644
index 17c95bfc0..000000000
--- a/adapters/taglib/taglib_wrapper.cpp
+++ /dev/null
@@ -1,249 +0,0 @@
-#include
-#include
-#include
-
-#define TAGLIB_STATIC
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-#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());
-
- if (const auto* apeProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", apeProperties->bitsPerSample());
- if (const auto* asfProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", asfProperties->bitsPerSample());
- else if (const auto* flacProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", flacProperties->bitsPerSample());
- else if (const auto* mp4Properties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", mp4Properties->bitsPerSample());
- else if (const auto* wavePackProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", wavePackProperties->bitsPerSample());
- else if (const auto* aiffProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", aiffProperties->bitsPerSample());
- else if (const auto* wavProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", wavProperties->bitsPerSample());
- else if (const auto* dsfProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", dsfProperties->bitsPerSample());
-
- // Send all properties to the Go map
- TagLib::PropertyMap tags = f.file()->properties();
-
- TagLib::ID3v2::Tag *id3Tags = NULL;
-
- // Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
- TagLib::MPEG::File *mp3File(dynamic_cast(f.file()));
- if (mp3File != NULL) {
- id3Tags = mp3File->ID3v2Tag();
- }
-
- if (id3Tags == NULL) {
- TagLib::RIFF::WAV::File *wavFile(dynamic_cast(f.file()));
- if (wavFile != NULL && wavFile->hasID3v2Tag()) {
- id3Tags = wavFile->ID3v2Tag();
- }
- }
-
- if (id3Tags == NULL) {
- TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast(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(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 = (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(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 = (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 = (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(f.file()));
- if (m4afile != NULL) {
- const auto itemListMap = m4afile->tag()->itemMap();
- for (const auto item: itemListMap) {
- char *key = (char *)item.first.toCString(true);
- for (const auto value: item.second.toStringList()) {
- char *val = (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(f.file()));
- if (asfFile != NULL) {
- const TagLib::ASF::Tag *asfTags{asfFile->tag()};
- const auto itemListMap = asfTags->attributeListMap();
- for (const auto item : itemListMap) {
- tags.insert(item.first, item.second.front().toString());
- }
- }
-
- // Send all collected tags to the Go map
- for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end();
- ++i) {
- char *key = (char *)i->first.toCString(true);
- for (TagLib::StringList::ConstIterator j = i->second.begin();
- j != i->second.end(); ++j) {
- char *val = (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(f.file())}) {
- if (mp3File->ID3v2Tag()) {
- const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()};
- hasCover = !frameListMap["APIC"].isEmpty();
- }
- }
- // ----- FLAC
- else if (TagLib::FLAC::File * flacFile{dynamic_cast(f.file())}) {
- hasCover = !flacFile->pictureList().isEmpty();
- }
- // ----- MP4
- else if (TagLib::MP4::File * mp4File{dynamic_cast(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(f.file())}) {
- hasCover = !vorbisFile->tag()->pictureList().isEmpty();
- }
- // ----- Opus
- else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast(f.file())}) {
- hasCover = !opusFile->tag()->pictureList().isEmpty();
- }
- // ----- WAV
- else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast(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(f.file())}) {
- if (aiffFile->hasID3v2Tag()) {
- const auto& frameListMap{ aiffFile->tag()->frameListMap() };
- hasCover = !frameListMap["APIC"].isEmpty();
- }
- }
- // ----- WMA
- else if (TagLib::ASF::File * asfFile{dynamic_cast(f.file())}) {
- const TagLib::ASF::Tag *tag{ asfFile->tag() };
- hasCover = tag && asfFile->tag()->attributeListMap().contains("WM/Picture");
- }
-
- return hasCover;
-}
diff --git a/adapters/taglib/taglib_wrapper.go b/adapters/taglib/taglib_wrapper.go
deleted file mode 100644
index 4a979920a..000000000
--- a/adapters/taglib/taglib_wrapper.go
+++ /dev/null
@@ -1,157 +0,0 @@
-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
-#include
-#include
-#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}
- }
-}
diff --git a/adapters/taglib/taglib_wrapper.h b/adapters/taglib/taglib_wrapper.h
deleted file mode 100644
index c93f4c14a..000000000
--- a/adapters/taglib/taglib_wrapper.h
+++ /dev/null
@@ -1,24 +0,0 @@
-#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
diff --git a/cmd/backup.go b/cmd/backup.go
index ab73f7537..c02f3a19f 100644
--- a/cmd/backup.go
+++ b/cmd/backup.go
@@ -75,7 +75,7 @@ var (
func runBackup(ctx context.Context) {
if backupDir != "" {
- conf.Server.Backup.Path = backupDir
+ conf.Server.Backup.Path = conf.NewDir(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 = backupDir
+ conf.Server.Backup.Path = conf.NewDir(backupDir)
}
if backupCount != -1 {
diff --git a/cmd/cmd_suite_test.go b/cmd/cmd_suite_test.go
new file mode 100644
index 000000000..f2ddf6a9c
--- /dev/null
+++ b/cmd/cmd_suite_test.go
@@ -0,0 +1,17 @@
+package cmd
+
+import (
+ "testing"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestCmd(t *testing.T) {
+ tests.Init(t, false)
+ log.SetLevel(log.LevelFatal)
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Cmd Suite")
+}
diff --git a/cmd/inspect.go b/cmd/inspect.go
index 9f9270b1e..5e88793cc 100644
--- a/cmd/inspect.go
+++ b/cmd/inspect.go
@@ -32,17 +32,17 @@ var inspectCmd = &cobra.Command{
},
}
-var marshalers = map[string]func(interface{}) ([]byte, error){
+var marshalers = map[string]func(any) ([]byte, error){
"pretty": prettyMarshal,
"toml": toml.Marshal,
"yaml": yaml.Marshal,
"json": json.Marshal,
- "jsonindent": func(v interface{}) ([]byte, error) {
+ "jsonindent": func(v any) ([]byte, error) {
return json.MarshalIndent(v, "", " ")
},
}
-func prettyMarshal(v interface{}) ([]byte, error) {
+func prettyMarshal(v any) ([]byte, error) {
out := v.([]core.InspectOutput)
var res strings.Builder
for i := range out {
diff --git a/cmd/pls.go b/cmd/pls.go
index fc0f22fba..95cbe4eec 100644
--- a/cmd/pls.go
+++ b/cmd/pls.go
@@ -7,14 +7,19 @@ import (
"errors"
"fmt"
"os"
+ "path/filepath"
"strconv"
+ "strings"
"github.com/Masterminds/squirrel"
- "github.com/navidrome/navidrome/core/auth"
- "github.com/navidrome/navidrome/db"
+ "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/persistence"
+ "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"
)
@@ -23,6 +28,7 @@ var (
outputFile string
userID string
outputFormat string
+ syncFlag bool
)
type displayPlaylist struct {
@@ -44,6 +50,15 @@ 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 (
@@ -52,7 +67,7 @@ var (
Short: "Export playlists",
Long: "Export Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
- runExporter()
+ runExporter(cmd.Context())
},
}
@@ -60,89 +75,168 @@ var (
Use: "list",
Short: "List playlists",
Run: func(cmd *cobra.Command, args []string) {
- runList()
+ 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 runExporter() {
- sqlDB := db.Db()
- ds := persistence.New(sqlDB)
- ctx := auth.WithAdminUser(context.Background(), ds)
- playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
+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)
if err != nil && !errors.Is(err, model.ErrNotFound) {
- log.Fatal("Error retrieving playlist", "name", playlistID, err)
+ log.Fatal("Error retrieving playlist", "name", nameOrID, err)
}
if errors.Is(err, model.ErrNotFound) {
- playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}})
+ playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
if err != nil {
- log.Fatal("Error retrieving playlist", "name", playlistID, err)
+ log.Fatal("Error retrieving playlist", "name", nameOrID, 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", playlistID, err)
+ log.Fatal("Error retrieving playlist", "name", nameOrID, err)
}
}
}
if playlist == nil {
- log.Fatal("Playlist not found", "name", playlistID)
+ log.Fatal("Playlist not found", "name", nameOrID)
}
+ 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 runList() {
+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)
}
- sqlDB := db.Db()
- ds := persistence.New(sqlDB)
- ctx := auth.WithAdminUser(context.Background(), ds)
-
- options := model.QueryOptions{Sort: "owner_name"}
-
- if userID != "" {
- user, err := ds.User(ctx).FindByUsername(userID)
-
- if err != nil && !errors.Is(err, model.ErrNotFound) {
- log.Fatal("Error retrieving user by name", "name", userID, err)
- }
-
- if errors.Is(err, model.ErrNotFound) {
- user, err = ds.User(ctx).Get(userID)
- if err != nil {
- log.Fatal("Error retrieving user by id", "id", userID, err)
- }
- }
-
- 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)
- }
+ ds, ctx := getAdminContext(ctx)
+ allPls := fetchPlaylists(ctx, ds, "owner_name")
if outputFormat == "csv" {
w := csv.NewWriter(os.Stdout)
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
- for _, playlist := range playlists {
+ for _, playlist := range allPls {
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
}
w.Flush()
} else {
- display := make(displayPlaylists, len(playlists))
- for idx, playlist := range playlists {
+ display := make(displayPlaylists, len(allPls))
+ for idx, playlist := range allPls {
display[idx].Id = playlist.ID
display[idx].Name = playlist.Name
display[idx].OwnerId = playlist.OwnerID
@@ -154,3 +248,62 @@ func runList() {
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
+}
diff --git a/cmd/root.go b/cmd/root.go
index e1e92228f..08773176a 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -9,7 +9,6 @@ import (
"time"
"github.com/go-chi/chi/v5/middleware"
- _ "github.com/navidrome/navidrome/adapters/taglib"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/db"
@@ -22,6 +21,12 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/sync/errgroup"
+
+ // Import adapters to register them
+ _ "github.com/navidrome/navidrome/adapters/deezer"
+ _ "github.com/navidrome/navidrome/adapters/gotaglib"
+ _ "github.com/navidrome/navidrome/adapters/lastfm"
+ _ "github.com/navidrome/navidrome/adapters/listenbrainz"
)
var (
@@ -82,8 +87,9 @@ func runNavidrome(ctx context.Context) {
g.Go(schedulePeriodicBackup(ctx))
g.Go(startInsightsCollector(ctx))
g.Go(scheduleDBOptimizer(ctx))
+ g.Go(startPluginManager(ctx))
+ g.Go(runInitialScan(ctx))
if conf.Server.Scanner.Enabled {
- g.Go(runInitialScan(ctx))
g.Go(startScanWatcher(ctx))
g.Go(schedulePeriodicScan(ctx))
} else {
@@ -109,7 +115,7 @@ func mainContext(ctx context.Context) (context.Context, context.CancelFunc) {
func startServer(ctx context.Context) func() error {
return func() error {
a := CreateServer()
- a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter())
+ a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter(ctx))
a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter(ctx))
a.MountRouter("Public Endpoints", consts.URLPathPublic, CreatePublicRouter())
if conf.Server.LastFM.Enabled {
@@ -147,7 +153,7 @@ func schedulePeriodicScan(ctx context.Context) func() error {
schedulerInstance := scheduler.GetInstance()
log.Info("Scheduling periodic scan", "schedule", schedule)
- err := schedulerInstance.Add(schedule, func() {
+ _, err := schedulerInstance.Add(schedule, func() {
_, err := s.ScanAll(ctx, false)
if err != nil {
log.Error(ctx, "Error executing periodic scan", err)
@@ -172,6 +178,7 @@ func pidHashChanged(ds model.DataStore) (bool, error) {
return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil
}
+// runInitialScan runs an initial scan of the music library if needed.
func runInitialScan(ctx context.Context) func() error {
return func() error {
ds := CreateDataStore()
@@ -187,10 +194,11 @@ func runInitialScan(ctx context.Context) func() error {
if err != nil {
return err
}
- scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
+ scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
+ scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
if scanNeeded {
- scanner := CreateScanner(ctx)
+ s := CreateScanner(ctx)
switch {
case fullScanRequired == "1":
log.Warn(ctx, "Full scan required after migration")
@@ -204,7 +212,7 @@ func runInitialScan(ctx context.Context) func() error {
log.Info("Executing initial scan")
}
- _, err = scanner.ScanAll(ctx, fullScanRequired == "1")
+ _, err = s.ScanAll(ctx, fullScanRequired == "1")
if err != nil {
log.Error(ctx, "Scan failed", err)
} else {
@@ -243,7 +251,7 @@ func schedulePeriodicBackup(ctx context.Context) func() error {
schedulerInstance := scheduler.GetInstance()
log.Info("Scheduling periodic backup", "schedule", schedule)
- err := schedulerInstance.Add(schedule, func() {
+ _, err := schedulerInstance.Add(schedule, func() {
start := time.Now()
path, err := db.Backup(ctx)
elapsed := time.Since(start)
@@ -271,7 +279,7 @@ func scheduleDBOptimizer(ctx context.Context) func() error {
return func() error {
log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule)
schedulerInstance := scheduler.GetInstance()
- err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
+ _, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
if scanner.IsScanning() {
log.Debug(ctx, "Skipping DB optimization because a scan is in progress")
return
@@ -325,10 +333,23 @@ func startPlaybackServer(ctx context.Context) func() error {
}
}
+// startPluginManager starts the plugin manager, if configured.
+func startPluginManager(ctx context.Context) func() error {
+ return func() error {
+ manager := GetPluginManager(ctx)
+ if !conf.Server.Plugins.Enabled {
+ log.Debug("Plugin system is DISABLED")
+ return nil
+ }
+ log.Info(ctx, "Starting plugin manager")
+ return manager.Start(ctx)
+ }
+}
+
// TODO: Implement some struct tags to map flags to viper
func init() {
cobra.OnInitialize(func() {
- conf.InitConfig(cfgFile)
+ conf.InitConfig(cfgFile, true)
})
rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`)
@@ -356,6 +377,7 @@ func init() {
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
+ rootCmd.Flags().Bool("enabletranscodingcancellation", viper.GetBool("enabletranscodingcancellation"), "enables transcoding context cancellation")
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
@@ -379,6 +401,7 @@ func init() {
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
+ _ = viper.BindPFlag("enabletranscodingcancellation", rootCmd.Flags().Lookup("enabletranscodingcancellation"))
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
}
diff --git a/cmd/scan.go b/cmd/scan.go
index 26eb7d7a2..d8a563396 100644
--- a/cmd/scan.go
+++ b/cmd/scan.go
@@ -1,15 +1,18 @@
package cmd
import (
+ "bufio"
"context"
"encoding/gob"
+ "fmt"
"os"
+ "strings"
"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/db"
"github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/utils/pl"
@@ -19,11 +22,15 @@ import (
var (
fullScan bool
subprocess bool
+ targets []string
+ targetFile string
)
func init() {
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
+ scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
+ scanCmd.Flags().StringVar(&targetFile, "target-file", "", "path to file containing targets (one libraryID:folderPath per line)")
rootCmd.AddCommand(scanCmd)
}
@@ -68,9 +75,27 @@ func runScanner(ctx context.Context) {
sqlDB := db.Db()
defer db.Db().Close()
ds := persistence.New(sqlDB)
- pls := core.NewPlaylists(ds)
+ pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
- progress, err := scanner.CallScan(ctx, ds, artwork.NoopCacheWarmer(), pls, metrics.NewNoopInstance(), fullScan)
+ // Parse targets from command line or file
+ var scanTargets []model.ScanTarget
+ var err error
+
+ if targetFile != "" {
+ scanTargets, err = readTargetsFromFile(targetFile)
+ if err != nil {
+ log.Fatal(ctx, "Failed to read targets from file", err)
+ }
+ log.Info(ctx, "Scanning specific folders from file", "numTargets", len(scanTargets))
+ } else if len(targets) > 0 {
+ scanTargets, err = model.ParseTargets(targets)
+ if err != nil {
+ log.Fatal(ctx, "Failed to parse targets", err)
+ }
+ log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets))
+ }
+
+ progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets)
if err != nil {
log.Fatal(ctx, "Failed to scan", err)
}
@@ -82,3 +107,31 @@ func runScanner(ctx context.Context) {
trackScanInteractively(ctx, progress)
}
}
+
+// readTargetsFromFile reads scan targets from a file, one per line.
+// Each line should be in the format "libraryID:folderPath".
+// Empty lines and lines starting with # are ignored.
+func readTargetsFromFile(filePath string) ([]model.ScanTarget, error) {
+ file, err := os.Open(filePath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open target file: %w", err)
+ }
+ defer file.Close()
+
+ var targetStrings []string
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ // Skip empty lines and comments
+ if line == "" {
+ continue
+ }
+ targetStrings = append(targetStrings, line)
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("failed to read target file: %w", err)
+ }
+
+ return model.ParseTargets(targetStrings)
+}
diff --git a/cmd/scan_test.go b/cmd/scan_test.go
new file mode 100644
index 000000000..beeecca19
--- /dev/null
+++ b/cmd/scan_test.go
@@ -0,0 +1,89 @@
+package cmd
+
+import (
+ "os"
+ "path/filepath"
+
+ "github.com/navidrome/navidrome/model"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("readTargetsFromFile", func() {
+ var tempDir string
+
+ BeforeEach(func() {
+ var err error
+ tempDir, err = os.MkdirTemp("", "navidrome-test-")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ AfterEach(func() {
+ os.RemoveAll(tempDir)
+ })
+
+ It("reads valid targets from file", func() {
+ filePath := filepath.Join(tempDir, "targets.txt")
+ content := "1:Music/Rock\n2:Music/Jazz\n3:Classical\n"
+ err := os.WriteFile(filePath, []byte(content), 0600)
+ Expect(err).ToNot(HaveOccurred())
+
+ targets, err := readTargetsFromFile(filePath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(3))
+ Expect(targets[0]).To(Equal(model.ScanTarget{LibraryID: 1, FolderPath: "Music/Rock"}))
+ Expect(targets[1]).To(Equal(model.ScanTarget{LibraryID: 2, FolderPath: "Music/Jazz"}))
+ Expect(targets[2]).To(Equal(model.ScanTarget{LibraryID: 3, FolderPath: "Classical"}))
+ })
+
+ It("skips empty lines", func() {
+ filePath := filepath.Join(tempDir, "targets.txt")
+ content := "1:Music/Rock\n\n2:Music/Jazz\n\n"
+ err := os.WriteFile(filePath, []byte(content), 0600)
+ Expect(err).ToNot(HaveOccurred())
+
+ targets, err := readTargetsFromFile(filePath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ })
+
+ It("trims whitespace", func() {
+ filePath := filepath.Join(tempDir, "targets.txt")
+ content := " 1:Music/Rock \n\t2:Music/Jazz\t\n"
+ err := os.WriteFile(filePath, []byte(content), 0600)
+ Expect(err).ToNot(HaveOccurred())
+
+ targets, err := readTargetsFromFile(filePath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
+ Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
+ })
+
+ It("returns error for non-existent file", func() {
+ _, err := readTargetsFromFile("/nonexistent/file.txt")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("failed to open target file"))
+ })
+
+ It("returns error for invalid target format", func() {
+ filePath := filepath.Join(tempDir, "targets.txt")
+ content := "invalid-format\n"
+ err := os.WriteFile(filePath, []byte(content), 0600)
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = readTargetsFromFile(filePath)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("handles mixed valid and empty lines", func() {
+ filePath := filepath.Join(tempDir, "targets.txt")
+ content := "\n1:Music/Rock\n\n\n2:Music/Jazz\n\n"
+ err := os.WriteFile(filePath, []byte(content), 0600)
+ Expect(err).ToNot(HaveOccurred())
+
+ targets, err := readTargetsFromFile(filePath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ })
+})
diff --git a/cmd/svc.go b/cmd/svc.go
index e277bd459..cc8d6bb54 100644
--- a/cmd/svc.go
+++ b/cmd/svc.go
@@ -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
+ options["LogDirectory"] = conf.Server.DataFolder.String()
options["SystemdScript"] = systemdScript
if conf.Server.LogFile != "" {
options["LogOutput"] = false
} else {
options["LogOutput"] = true
- options["LogDirectory"] = conf.Server.DataFolder
+ options["LogDirectory"] = conf.Server.DataFolder.String()
}
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)
+ println(" data folder: " + conf.Server.DataFolder.String())
if conf.Server.LogFile != "" {
println(" log file: " + conf.Server.LogFile)
} else {
- println(" logs folder: " + conf.Server.DataFolder)
+ println(" logs folder: " + conf.Server.DataFolder.String())
}
if cfgFile != "" {
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
@@ -248,6 +248,7 @@ ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
TimeoutStopSec=20
RestartSec=120
EnvironmentFile=-/etc/sysconfig/{{.Name}}
+Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
DevicePolicy=closed
NoNewPrivileges=yes
diff --git a/cmd/user.go b/cmd/user.go
new file mode 100644
index 000000000..1abf157b7
--- /dev/null
+++ b/cmd/user.go
@@ -0,0 +1,477 @@
+package cmd
+
+import (
+ "context"
+ "encoding/csv"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/spf13/cobra"
+ "golang.org/x/term"
+)
+
+var (
+ email string
+ libraryIds []int
+ name string
+
+ removeEmail bool
+ removeName bool
+ setAdmin bool
+ setPassword bool
+ setRegularUser bool
+)
+
+func init() {
+ rootCmd.AddCommand(userRoot)
+
+ userCreateCommand.Flags().StringVarP(&userID, "username", "u", "", "username")
+
+ userCreateCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
+ userCreateCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries. If empty, the user can access all libraries. This is incompatible with admin, as admin can always access all libraries")
+
+ userCreateCommand.Flags().BoolVarP(&setAdmin, "admin", "a", false, "If set, make the user an admin. This user will have access to every library")
+ userCreateCommand.Flags().StringVar(&name, "name", "", "New user's name (this is separate from username used to log in)")
+
+ _ = userCreateCommand.MarkFlagRequired("username")
+
+ userRoot.AddCommand(userCreateCommand)
+
+ userDeleteCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
+ _ = userDeleteCommand.MarkFlagRequired("user")
+ userRoot.AddCommand(userDeleteCommand)
+
+ userEditCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
+
+ userEditCommand.Flags().BoolVar(&setAdmin, "set-admin", false, "If set, make the user an admin")
+ userEditCommand.Flags().BoolVar(&setRegularUser, "set-regular", false, "If set, make the user a non-admin")
+ userEditCommand.MarkFlagsMutuallyExclusive("set-admin", "set-regular")
+
+ userEditCommand.Flags().BoolVar(&removeEmail, "remove-email", false, "If set, clear the user's email")
+ userEditCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
+ userEditCommand.MarkFlagsMutuallyExclusive("email", "remove-email")
+
+ userEditCommand.Flags().BoolVar(&removeName, "remove-name", false, "If set, clear the user's name")
+ userEditCommand.Flags().StringVar(&name, "name", "", "New user name (this is separate from username used to log in)")
+ userEditCommand.MarkFlagsMutuallyExclusive("name", "remove-name")
+
+ userEditCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI")
+
+ userEditCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries by id")
+
+ _ = userEditCommand.MarkFlagRequired("user")
+ userRoot.AddCommand(userEditCommand)
+
+ userListCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
+ userRoot.AddCommand(userListCommand)
+}
+
+var (
+ userRoot = &cobra.Command{
+ Use: "user",
+ Short: "Administer users",
+ Long: "Create, delete, list, or update users",
+ }
+
+ userCreateCommand = &cobra.Command{
+ Use: "create",
+ Aliases: []string{"c"},
+ Short: "Create a new user",
+ Run: func(cmd *cobra.Command, args []string) {
+ runCreateUser(cmd.Context())
+ },
+ }
+
+ userDeleteCommand = &cobra.Command{
+ Use: "delete",
+ Aliases: []string{"d"},
+ Short: "Deletes an existing user",
+ Run: func(cmd *cobra.Command, args []string) {
+ runDeleteUser(cmd.Context())
+ },
+ }
+
+ userEditCommand = &cobra.Command{
+ Use: "edit",
+ Aliases: []string{"e"},
+ Short: "Edit a user",
+ Long: "Edit the password, admin status, and/or library access",
+ Run: func(cmd *cobra.Command, args []string) {
+ runUserEdit(cmd.Context())
+ },
+ }
+
+ userListCommand = &cobra.Command{
+ Use: "list",
+ Short: "List users",
+ Run: func(cmd *cobra.Command, args []string) {
+ runUserList(cmd.Context())
+ },
+ }
+)
+
+func promptPassword() string {
+ for {
+ fmt.Print("Enter new password (press enter with no password to cancel): ")
+ // This cast is necessary for some platforms
+ password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
+
+ if err != nil {
+ log.Fatal("Error getting password", err)
+ }
+
+ fmt.Print("\nConfirm new password (press enter with no password to cancel): ")
+ confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
+
+ if err != nil {
+ log.Fatal("Error getting password confirmation", err)
+ }
+
+ // clear the line.
+ fmt.Println()
+
+ pass := string(password)
+ confirm := string(confirmation)
+
+ if pass == "" {
+ return ""
+ }
+
+ if pass == confirm {
+ return pass
+ }
+
+ fmt.Println("Password and password confirmation do not match")
+ }
+}
+
+func libraryError(libraries model.Libraries) error {
+ ids := make([]int, len(libraries))
+ for idx, library := range libraries {
+ ids[idx] = library.ID
+ }
+ return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %v", libraryIds, ids)
+}
+
+func runCreateUser(ctx context.Context) {
+ password := promptPassword()
+ if password == "" {
+ log.Fatal("Empty password provided, user creation cancelled")
+ }
+
+ user := model.User{
+ UserName: userID,
+ Email: email,
+ Name: name,
+ IsAdmin: setAdmin,
+ NewPassword: password,
+ }
+
+ if user.Name == "" {
+ user.Name = userID
+ }
+
+ ds, ctx := getAdminContext(ctx)
+
+ err := ds.WithTx(func(tx model.DataStore) error {
+ existingUser, err := tx.User(ctx).FindByUsername(userID)
+ if existingUser != nil {
+ return fmt.Errorf("existing user '%s'", userID)
+ }
+
+ if err != nil && !errors.Is(err, model.ErrNotFound) {
+ return fmt.Errorf("failed to check existing username: %w", err)
+ }
+
+ if len(libraryIds) > 0 && !setAdmin {
+ user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
+ if err != nil {
+ return err
+ }
+
+ if len(user.Libraries) != len(libraryIds) {
+ return libraryError(user.Libraries)
+ }
+ } else {
+ user.Libraries, err = tx.Library(ctx).GetAll()
+ if err != nil {
+ return err
+ }
+ }
+
+ err = tx.User(ctx).Put(&user)
+ if err != nil {
+ return err
+ }
+
+ updatedIds := make([]int, len(user.Libraries))
+ for idx, lib := range user.Libraries {
+ updatedIds[idx] = lib.ID
+ }
+
+ err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
+ return err
+ })
+
+ if err != nil {
+ log.Fatal(ctx, err)
+ }
+
+ log.Info(ctx, "Successfully created user", "id", user.ID, "username", user.UserName)
+}
+
+func runDeleteUser(ctx context.Context) {
+ ds, ctx := getAdminContext(ctx)
+
+ var err error
+ var user *model.User
+
+ err = ds.WithTx(func(tx model.DataStore) error {
+ count, err := tx.User(ctx).CountAll()
+ if err != nil {
+ return err
+ }
+
+ if count == 1 {
+ return errors.New("refusing to delete the last user")
+ }
+
+ user, err = getUser(ctx, userID, tx)
+ if err != nil {
+ return err
+ }
+
+ return tx.User(ctx).Delete(user.ID)
+ })
+
+ if err != nil {
+ log.Fatal(ctx, "Failed to delete user", err)
+ }
+
+ log.Info(ctx, "Deleted user", "username", user.UserName)
+}
+
+func runUserEdit(ctx context.Context) {
+ ds, ctx := getAdminContext(ctx)
+
+ var err error
+ var user *model.User
+ changes := []string{}
+
+ err = ds.WithTx(func(tx model.DataStore) error {
+ var newLibraries model.Libraries
+
+ user, err = getUser(ctx, userID, tx)
+ if err != nil {
+ return err
+ }
+
+ if len(libraryIds) > 0 && !setAdmin {
+ libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
+
+ if err != nil {
+ return err
+ }
+
+ if len(libraries) != len(libraryIds) {
+ return libraryError(libraries)
+ }
+
+ newLibraries = libraries
+ changes = append(changes, "updated library ids")
+ }
+
+ if setAdmin && !user.IsAdmin {
+ libraries, err := tx.Library(ctx).GetAll()
+ if err != nil {
+ return err
+ }
+
+ user.IsAdmin = true
+ user.Libraries = libraries
+ changes = append(changes, "set admin")
+
+ newLibraries = libraries
+ }
+
+ if setRegularUser && user.IsAdmin {
+ user.IsAdmin = false
+ changes = append(changes, "set regular user")
+ }
+
+ if setPassword {
+ password := promptPassword()
+
+ if password != "" {
+ user.NewPassword = password
+ changes = append(changes, "updated password")
+ }
+ }
+
+ if email != "" && email != user.Email {
+ user.Email = email
+ changes = append(changes, "updated email")
+ } else if removeEmail && user.Email != "" {
+ user.Email = ""
+ changes = append(changes, "removed email")
+ }
+
+ if name != "" && name != user.Name {
+ user.Name = name
+ changes = append(changes, "updated name")
+ } else if removeName && user.Name != "" {
+ user.Name = ""
+ changes = append(changes, "removed name")
+ }
+
+ if len(changes) == 0 {
+ return nil
+ }
+
+ err := tx.User(ctx).Put(user)
+ if err != nil {
+ return err
+ }
+
+ if len(newLibraries) > 0 {
+ updatedIds := make([]int, len(newLibraries))
+ for idx, lib := range newLibraries {
+ updatedIds[idx] = lib.ID
+ }
+
+ err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ log.Fatal(ctx, "Failed to update user", err)
+ }
+
+ if len(changes) == 0 {
+ log.Info(ctx, "No changes for user", "user", user.UserName)
+ } else {
+ log.Info(ctx, "Updated user", "user", user.UserName, "changes", strings.Join(changes, ", "))
+ }
+}
+
+type displayLibrary struct {
+ ID int `json:"id"`
+ Path string `json:"path"`
+}
+
+type displayUser struct {
+ Id string `json:"id"`
+ Username string `json:"username"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Admin bool `json:"admin"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ LastAccess *time.Time `json:"lastAccess"`
+ LastLogin *time.Time `json:"lastLogin"`
+ Libraries []displayLibrary `json:"libraries"`
+}
+
+func runUserList(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)
+
+ users, err := ds.User(ctx).ReadAll()
+ if err != nil {
+ log.Fatal(ctx, "Failed to retrieve users", err)
+ }
+
+ userList := users.(model.Users)
+
+ if outputFormat == "csv" {
+ w := csv.NewWriter(os.Stdout)
+ _ = w.Write([]string{
+ "user id",
+ "username",
+ "user's name",
+ "user email",
+ "admin",
+ "created at",
+ "updated at",
+ "last access",
+ "last login",
+ "libraries",
+ })
+ for _, user := range userList {
+ paths := make([]string, len(user.Libraries))
+
+ for idx, library := range user.Libraries {
+ paths[idx] = fmt.Sprintf("%d:%s", library.ID, library.Path)
+ }
+
+ var lastAccess, lastLogin string
+
+ if user.LastAccessAt != nil {
+ lastAccess = user.LastAccessAt.Format(time.RFC3339Nano)
+ } else {
+ lastAccess = "never"
+ }
+
+ if user.LastLoginAt != nil {
+ lastLogin = user.LastLoginAt.Format(time.RFC3339Nano)
+ } else {
+ lastLogin = "never"
+ }
+
+ _ = w.Write([]string{
+ user.ID,
+ user.UserName,
+ user.Name,
+ user.Email,
+ strconv.FormatBool(user.IsAdmin),
+ user.CreatedAt.Format(time.RFC3339Nano),
+ user.UpdatedAt.Format(time.RFC3339Nano),
+ lastAccess,
+ lastLogin,
+ fmt.Sprintf("'%s'", strings.Join(paths, "|")),
+ })
+ }
+ w.Flush()
+ } else {
+ users := make([]displayUser, len(userList))
+ for idx, user := range userList {
+ paths := make([]displayLibrary, len(user.Libraries))
+
+ for idx, library := range user.Libraries {
+ paths[idx].ID = library.ID
+ paths[idx].Path = library.Path
+ }
+
+ users[idx].Id = user.ID
+ users[idx].Username = user.UserName
+ users[idx].Name = user.Name
+ users[idx].Email = user.Email
+ users[idx].Admin = user.IsAdmin
+ users[idx].CreatedAt = user.CreatedAt
+ users[idx].UpdatedAt = user.UpdatedAt
+ users[idx].LastAccess = user.LastAccessAt
+ users[idx].LastLogin = user.LastLoginAt
+ users[idx].Libraries = paths
+ }
+
+ j, _ := json.Marshal(users)
+ fmt.Printf("%s\n", j)
+ }
+}
diff --git a/cmd/utils.go b/cmd/utils.go
new file mode 100644
index 000000000..81d646cf1
--- /dev/null
+++ b/cmd/utils.go
@@ -0,0 +1,42 @@
+package cmd
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/navidrome/navidrome/core/auth"
+ "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"
+)
+
+func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
+ sqlDB := db.Db()
+ ds := persistence.New(sqlDB)
+ ctx = auth.WithAdminUser(ctx, ds)
+ u, _ := request.UserFrom(ctx)
+ if !u.IsAdmin {
+ log.Fatal(ctx, "There must be at least one admin user to run this command.")
+ }
+ return ds, ctx
+}
+
+func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
+ user, err := ds.User(ctx).FindByUsername(id)
+
+ if err != nil && !errors.Is(err, model.ErrNotFound) {
+ return nil, fmt.Errorf("finding user by name: %w", err)
+ }
+
+ if errors.Is(err, model.ErrNotFound) {
+ user, err = ds.User(ctx).Get(id)
+ if err != nil {
+ return nil, fmt.Errorf("finding user by id: %w", err)
+ }
+ }
+
+ return user, nil
+}
diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index d57aadc71..0939eef4d 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -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"
+//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
//go:build !wireinject
// +build !wireinject
@@ -9,19 +9,25 @@ package cmd
import (
"context"
"github.com/google/wire"
+ "github.com/navidrome/navidrome/adapters/lastfm"
+ "github.com/navidrome/navidrome/adapters/listenbrainz"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
- "github.com/navidrome/navidrome/core/agents/lastfm"
- "github.com/navidrome/navidrome/core/agents/listenbrainz"
"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"
+ "github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
@@ -31,7 +37,10 @@ import (
)
import (
- _ "github.com/navidrome/navidrome/adapters/taglib"
+ _ "github.com/navidrome/navidrome/adapters/deezer"
+ _ "github.com/navidrome/navidrome/adapters/gotaglib"
+ _ "github.com/navidrome/navidrome/adapters/lastfm"
+ _ "github.com/navidrome/navidrome/adapters/listenbrainz"
)
// Injectors from wire_injectors.go:
@@ -51,13 +60,29 @@ func CreateServer() *server.Server {
return serverServer
}
-func CreateNativeAPIRouter() *nativeapi.Router {
+func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
- playlists := core.NewPlaylists(dataStore)
+ imageUploadService := core.NewImageUploadService()
+ playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
insights := metrics.GetInstance(dataStore)
- router := nativeapi.New(dataStore, share, playlists, insights)
+ 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)
+ cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
+ modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
+ watcher := scanner.GetWatcher(dataStore, modelScanner)
+ library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
+ user := core.NewUser(dataStore, manager)
+ maintenance := core.NewMaintenance(dataStore)
+ router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService)
return router
}
@@ -66,22 +91,28 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
- agentsAgents := agents.GetAgents(dataStore)
- provider := external.NewProvider(dataStore, agentsAgents)
+ 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 := core.GetTranscodingCache()
- mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
+ transcodingCache := stream.GetTranscodingCache()
+ mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
players := core.NewPlayers(dataStore)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
- broker := events.GetBroker()
- playlists := core.NewPlaylists(dataStore)
- metricsMetrics := metrics.NewPrometheusInstance(dataStore)
- scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
- playTracker := scrobbler.GetPlayTracker(dataStore, broker)
+ imageUploadService := core.NewImageUploadService()
+ playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
+ modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
+ playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
- router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer)
+ lyricsLyrics := lyrics.NewLyrics(manager)
+ transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
+ sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
+ router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
return router
}
@@ -90,11 +121,15 @@ func CreatePublicRouter() *public.Router {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
- agentsAgents := agents.GetAgents(dataStore)
- provider := external.NewProvider(dataStore, agentsAgents)
+ 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 := core.GetTranscodingCache()
- mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
+ transcodingCache := stream.GetTranscodingCache()
+ mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
@@ -125,24 +160,27 @@ func CreateInsights() metrics.Insights {
func CreatePrometheus() metrics.Metrics {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
- metricsMetrics := metrics.NewPrometheusInstance(dataStore)
+ metricsMetrics := metrics.GetPrometheusInstance(dataStore)
return metricsMetrics
}
-func CreateScanner(ctx context.Context) scanner.Scanner {
+func CreateScanner(ctx context.Context) model.Scanner {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
- agentsAgents := agents.GetAgents(dataStore)
- provider := external.NewProvider(dataStore, agentsAgents)
+ 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)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
- broker := events.GetBroker()
- playlists := core.NewPlaylists(dataStore)
- metricsMetrics := metrics.NewPrometheusInstance(dataStore)
- scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
- return scannerScanner
+ imageUploadService := core.NewImageUploadService()
+ playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
+ modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
+ return modelScanner
}
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
@@ -150,15 +188,18 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
- agentsAgents := agents.GetAgents(dataStore)
- provider := external.NewProvider(dataStore, agentsAgents)
+ 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)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
- broker := events.GetBroker()
- playlists := core.NewPlaylists(dataStore)
- metricsMetrics := metrics.NewPrometheusInstance(dataStore)
- scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
- watcher := scanner.NewWatcher(dataStore, scannerScanner)
+ imageUploadService := core.NewImageUploadService()
+ playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
+ modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
+ watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
}
@@ -169,6 +210,21 @@ func GetPlaybackServer() playback.PlaybackServer {
return playbackServer
}
+func getPluginManager() *plugins.Manager {
+ sqlDB := db.Db()
+ dataStore := persistence.New(sqlDB)
+ broker := events.GetBroker()
+ metricsMetrics := metrics.GetPrometheusInstance(dataStore)
+ manager := plugins.GetManager(dataStore, broker, metricsMetrics)
+ return manager
+}
+
// wire_injectors.go:
-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.NewWatcher, metrics.NewPrometheusInstance, db.Db)
+var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
+
+func GetPluginManager(ctx context.Context) *plugins.Manager {
+ manager := getPluginManager()
+ manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
+ return manager
+}
diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go
index c431945dc..bb5c5b5f3 100644
--- a/cmd/wire_injectors.go
+++ b/cmd/wire_injectors.go
@@ -6,15 +6,20 @@ import (
"context"
"github.com/google/wire"
+ "github.com/navidrome/navidrome/adapters/lastfm"
+ "github.com/navidrome/navidrome/adapters/listenbrainz"
"github.com/navidrome/navidrome/core"
- "github.com/navidrome/navidrome/core/agents/lastfm"
- "github.com/navidrome/navidrome/core/agents/listenbrainz"
+ "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"
+ "github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
@@ -35,9 +40,19 @@ var allProviders = wire.NewSet(
listenbrainz.NewRouter,
events.GetBroker,
scanner.New,
- scanner.NewWatcher,
- metrics.NewPrometheusInstance,
+ scanner.GetWatcher,
+ metrics.GetPrometheusInstance,
db.Db,
+ plugins.GetManager,
+ sonic.New,
+ wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
+ wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
+ wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
+ wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)),
+ wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
+ wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
+ wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
+ wire.Bind(new(core.Watcher), new(scanner.Watcher)),
)
func CreateDataStore() model.DataStore {
@@ -52,7 +67,7 @@ func CreateServer() *server.Server {
))
}
-func CreateNativeAPIRouter() *nativeapi.Router {
+func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
panic(wire.Build(
allProviders,
))
@@ -94,7 +109,7 @@ func CreatePrometheus() metrics.Metrics {
))
}
-func CreateScanner(ctx context.Context) scanner.Scanner {
+func CreateScanner(ctx context.Context) model.Scanner {
panic(wire.Build(
allProviders,
))
@@ -111,3 +126,15 @@ func GetPlaybackServer() playback.PlaybackServer {
allProviders,
))
}
+
+func getPluginManager() *plugins.Manager {
+ panic(wire.Build(
+ allProviders,
+ ))
+}
+
+func GetPluginManager(ctx context.Context) *plugins.Manager {
+ manager := getPluginManager()
+ manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
+ return manager
+}
diff --git a/conf/buildtags/buildtags.go b/conf/buildtags/buildtags.go
deleted file mode 100644
index 5fc125087..000000000
--- a/conf/buildtags/buildtags.go
+++ /dev/null
@@ -1,4 +0,0 @@
-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.
diff --git a/conf/buildtags/doc.go b/conf/buildtags/doc.go
new file mode 100644
index 000000000..f637b6355
--- /dev/null
+++ b/conf/buildtags/doc.go
@@ -0,0 +1,6 @@
+// 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
diff --git a/conf/buildtags/netgo.go b/conf/buildtags/netgo.go
index 0062ad2bc..407004703 100644
--- a/conf/buildtags/netgo.go
+++ b/conf/buildtags/netgo.go
@@ -2,10 +2,6 @@
package buildtags
-// 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
+// The `netgo` tag is required when compiling the project. See https://github.com/navidrome/navidrome/issues/700
var NETGO = true
diff --git a/conf/buildtags/sqlite_fts5.go b/conf/buildtags/sqlite_fts5.go
new file mode 100644
index 000000000..1476e04cd
--- /dev/null
+++ b/conf/buildtags/sqlite_fts5.go
@@ -0,0 +1,8 @@
+//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
diff --git a/conf/configtest/configtest.go b/conf/configtest/configtest.go
index b947e6263..cd0ac41ed 100644
--- a/conf/configtest/configtest.go
+++ b/conf/configtest/configtest.go
@@ -2,9 +2,7 @@ package configtest
import "github.com/navidrome/navidrome/conf"
+// TODO Remove this redirection and call SnapshotConfig directly from tests
func SetupConfig() func() {
- oldValues := *conf.Server
- return func() {
- conf.Server = &oldValues
- }
+ return conf.SnapshotConfig()
}
diff --git a/conf/configuration.go b/conf/configuration.go
index 8561f343f..08f12fc94 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -1,21 +1,26 @@
package conf
import (
+ "cmp"
+ "encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
+ "slices"
"strings"
"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/utils/chain"
- "github.com/robfig/cron/v3"
+ "github.com/navidrome/navidrome/scheduler"
+ "github.com/navidrome/navidrome/utils/run"
"github.com/spf13/viper"
)
@@ -24,9 +29,10 @@ type configOptions struct {
Address string
Port int
UnixSocketPerm string
+ EnforceNonRootUser bool
MusicFolder string
- DataFolder string
- CacheFolder string
+ DataFolder Dir
+ CacheFolder Dir
DbPath string
LogLevel string
LogFile string
@@ -43,6 +49,7 @@ type configOptions struct {
EnableTranscodingConfig bool
EnableDownloads bool
EnableExternalServices bool
+ EnableM3UExternalAlbumArt bool
EnableInsightsCollector bool
EnableMediaFileCoverArt bool
TranscodingCacheSize string
@@ -55,7 +62,8 @@ type configOptions struct {
SmartPlaylistRefreshDelay time.Duration
AutoTranscodeDownload bool
DefaultDownsamplingFormat string
- SearchFullString bool
+ Search searchOptions `json:",omitzero"`
+ Matcher matcherOptions `json:",omitzero"`
RecentlyAddedByModTime bool
PreferSortTags bool
IgnoredArticles string
@@ -64,12 +72,18 @@ type configOptions struct {
MPVPath string
MPVCmdTemplate string
CoverArtPriority string
- CoverJpegQuality int
+ CoverArtQuality int
+ EnableWebPEncoding bool
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
@@ -77,51 +91,65 @@ 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
AuthWindowLength time.Duration
PasswordEncryptionKey string
- ReverseProxyUserHeader string
- ReverseProxyWhitelist string
- HTTPSecurityHeaders secureOptions
- Prometheus prometheusOptions
- Scanner scannerOptions
- Jukebox jukeboxOptions
- Backup backupOptions
- PID pidOptions
- Inspect inspectOptions
- Subsonic subsonicOptions
- LyricsPriority string
-
- Agents string
- LastFM lastfmOptions
- Spotify spotifyOptions
- ListenBrainz listenBrainzOptions
- Tags map[string]TagConf
+ ExtAuth extAuthOptions
+ Plugins pluginsOptions
+ HTTPHeaders httpHeaderOptions `json:",omitzero"`
+ Prometheus prometheusOptions `json:",omitzero"`
+ Scanner scannerOptions `json:",omitzero"`
+ Jukebox jukeboxOptions `json:",omitzero"`
+ Backup backupOptions `json:",omitzero"`
+ PID pidOptions `json:",omitzero"`
+ Inspect inspectOptions `json:",omitzero"`
+ Subsonic subsonicOptions `json:",omitzero"`
+ Transcoding transcodingOptions `json:",omitzero"`
+ LastFM lastfmOptions `json:",omitzero"`
+ Deezer deezerOptions `json:",omitzero"`
+ ListenBrainz listenBrainzOptions `json:",omitzero"`
+ EnableScrobbleHistory bool
+ Tags map[string]TagConf `json:",omitempty"`
+ Agents string
// DevFlags. These are used to enable/disable debugging and incomplete features
- DevLogSourceLine bool
- DevLogLevels map[string]string
- DevEnableProfiler bool
- DevAutoCreateAdminPassword string
- DevAutoLoginUsername string
- DevActivityPanel bool
- DevActivityPanelUpdateRate time.Duration
- DevSidebarPlaylists bool
- DevShowArtistPage bool
- DevOffsetOptimize int
- DevArtworkMaxRequests int
- DevArtworkThrottleBacklogLimit int
- DevArtworkThrottleBacklogTimeout time.Duration
- DevArtistInfoTimeToLive time.Duration
- DevAlbumInfoTimeToLive time.Duration
- DevExternalScanner bool
- DevScannerThreads uint
- DevInsightsInitialDelay time.Duration
- DevEnablePlayerInsights bool
+ DevLogLevels map[string]string `json:",omitempty"`
+ DevLogSourceLine bool
+ DevEnableProfiler bool
+ DevAutoCreateAdminPassword string
+ DevAutoLoginUsername string
+ DevActivityPanel bool
+ DevActivityPanelUpdateRate time.Duration
+ DevSidebarPlaylists bool
+ DevShowArtistPage bool
+ DevUIShowConfig bool
+ DevNewEventStream bool
+ DevOffsetOptimize int
+ DevArtworkMaxRequests int
+ DevArtworkThrottleBacklogLimit int
+ DevArtworkThrottleBacklogTimeout time.Duration
+ DevArtworkThrottleBuffered bool
+ DevArtistInfoTimeToLive time.Duration
+ DevAlbumInfoTimeToLive time.Duration
+ DevExternalScanner bool
+ DevScannerThreads uint
+ DevSelectiveWatcher bool
+ DevInsightsInitialDelay time.Duration
+ DevEnablePlayerInsights bool
+ DevEnablePluginsInsights bool
+ DevPluginCompilationTimeout time.Duration
+ DevExternalArtistFetchMultiplier float64
+ DevOptimizeDB bool
+ DevPreserveUnicodeInExternalCalls bool
+ DevEnableMediaFileProbe bool
}
type scannerOptions struct {
@@ -137,47 +165,65 @@ type scannerOptions struct {
PurgeMissing string // Values: "never", "always", "full"
}
+type transcodingOptions struct {
+ MaxConcurrent int
+ MaxConcurrentPerUser int
+ EnableCancellation bool
+}
+
type subsonicOptions struct {
AppendSubtitle bool
+ AppendAlbumVersion bool
ArtistParticipations bool
DefaultReportRealPath bool
+ EnableAverageRating bool
LegacyClients string
+ MinimalClients string
}
type TagConf struct {
- Ignore bool `yaml:"ignore"`
- Aliases []string `yaml:"aliases"`
- Type string `yaml:"type"`
- MaxLength int `yaml:"maxLength"`
- Split []string `yaml:"split"`
- Album bool `yaml:"album"`
+ Ignore bool `yaml:"ignore" json:",omitempty"`
+ Aliases []string `yaml:"aliases" json:",omitempty"`
+ Type string `yaml:"type" json:",omitempty"`
+ MaxLength int `yaml:"maxLength" json:",omitempty"`
+ Split []string `yaml:"split" json:",omitempty"`
+ Album bool `yaml:"album" json:",omitempty"`
}
type lastfmOptions struct {
- Enabled bool
- ApiKey string
- Secret string
- Language string
+ Enabled bool
+ ApiKey string //nolint:gosec
+ Secret string //nolint:gosec
+ Language string
+ ScrobbleFirstArtistOnly bool
+
+ // Computed values
+ Languages []string // Computed from Language, split by comma
}
-type spotifyOptions struct {
- ID string
- Secret string
+type deezerOptions struct {
+ Enabled bool
+ Language string
+
+ // Computed values
+ Languages []string // Computed from Language, split by comma
}
type listenBrainzOptions struct {
- Enabled bool
- BaseURL string
+ Enabled bool
+ BaseURL string
+ ArtistAlgorithm string
+ TrackAlgorithm string
}
-type secureOptions struct {
- CustomFrameOptionsValue string
+type httpHeaderOptions struct {
+ FrameOptions string
}
type prometheusOptions struct {
Enabled bool
MetricsPath string
- Password string
+ Password string //nolint:gosec
}
type AudioDeviceDefinition []string
@@ -191,7 +237,7 @@ type jukeboxOptions struct {
type backupOptions struct {
Count int
- Path string
+ Path Dir
Schedule string
}
@@ -207,66 +253,134 @@ type inspectOptions struct {
BacklogTimeout int
}
+type pluginsOptions struct {
+ Enabled bool
+ Folder Dir
+ CacheSize string
+ AutoReload bool
+ LogLevel string
+}
+
+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 (
Server = &configOptions{}
hooks []func()
)
+// SnapshotConfig returns a function that restores Server to its current state.
+// Uses JSON round-tripping so Dir fields get fresh sync.Once values.
+func SnapshotConfig() func() {
+ snapshot, err := json.Marshal(Server)
+ if err != nil {
+ panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err))
+ }
+ return func() {
+ var restored configOptions
+ if err := json.Unmarshal(snapshot, &restored); err != nil {
+ panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err))
+ }
+ Server = &restored
+ }
+}
+
func LoadFromFile(confFile string) {
viper.SetConfigFile(confFile)
err := viper.ReadInConfig()
if err != nil {
- _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
- os.Exit(1)
+ logFatal("Error reading config file:", err)
}
Load(true)
}
func Load(noConfigDump bool) {
parseIniFileConfiguration()
+ remapEnvVarKeysFromConfig()
- err := viper.Unmarshal(&Server)
+ // 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(","),
+ ),
+ ))
if err != nil {
- _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
- os.Exit(1)
+ logFatal("Error parsing config:", err)
}
- err = os.MkdirAll(Server.DataFolder, os.ModePerm)
- if err != nil {
- _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
- os.Exit(1)
+ // Validate non-root user early, before any filesystem operations
+ if err := validateEnforceNonRootUser(); err != nil {
+ logFatal(err)
}
- if Server.CacheFolder == "" {
- Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
+ if Server.CacheFolder.String() == "" {
+ Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "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)
+ }
}
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
if Server.DbPath == "" {
- 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)
- }
+ Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath)
}
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 {
- _, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
- os.Exit(1)
+ logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
}
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)
@@ -274,21 +388,24 @@ func Load(noConfigDump bool) {
log.SetLogSourceLine(Server.DevLogSourceLine)
log.SetRedacting(Server.EnableLogRedacting)
- err = chain.RunSequentially(
+ err = run.Sequentially(
validateScanSchedule,
validateBackupSchedule,
validatePlaylistsPath,
validatePurgeMissingOption,
+ validateMaxImageUploadSize,
+ validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
)
if err != nil {
- os.Exit(1)
+ logFatal(err)
}
+ Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
+
if Server.BaseURL != "" {
u, err := url.Parse(Server.BaseURL)
if err != nil {
- _, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
- os.Exit(1)
+ logFatal("Invalid BaseURL:", err)
}
Server.BasePath = u.Path
u.Path = ""
@@ -297,9 +414,18 @@ func Load(noConfigDump bool) {
Server.BaseScheme = u.Scheme
}
+ // Log configuration source
+ if Server.ConfigFile != "" {
+ log.Info("Loaded configuration", "file", Server.ConfigFile)
+ } else if hasNDEnvVars() {
+ log.Info("No configuration file found. Loaded configuration only from environment variables")
+ } else {
+ log.Warn("No configuration file found. Using default values. To specify a config file, use the --configfile flag or set the ND_CONFIGFILE environment variable.")
+ }
+
// Print current configuration if log level is Debug
if log.IsGreaterOrEqualTo(log.LevelDebug) && !noConfigDump {
- prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
+ prettyConf := pretty.Sprintf("Configuration: %# v", Server)
if Server.EnableLogRedacting {
prettyConf = log.Redact(prettyConf)
}
@@ -310,13 +436,37 @@ func Load(noConfigDump bool) {
disableExternalServices()
}
- if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
- log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
- Server.Scanner.Extractor = consts.DefaultScannerExtractor
+ // Make sure we don't have empty PIDs
+ Server.PID.Album = cmp.Or(Server.PID.Album, consts.DefaultAlbumPID)
+ Server.PID.Track = cmp.Or(Server.PID.Track, consts.DefaultTrackPID)
+
+ // Parse LastFM.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
+ Server.LastFM.Languages = parseLanguages(Server.LastFM.Language)
+
+ // 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
}
- logDeprecatedOptions("Scanner.GenreSeparators")
- logDeprecatedOptions("Scanner.GroupAlbumReleases")
- logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
// Call init hooks
for _, hook := range hooks {
@@ -324,15 +474,75 @@ func Load(noConfigDump bool) {
}
}
-func logDeprecatedOptions(options ...string) {
+func logDeprecatedOptions(oldName, newName string) {
+ envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_"))
+ newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_"))
+ logWarning := func(oldName, newName string) {
+ if newName != "" {
+ log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName))
+ } else {
+ log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", oldName))
+ }
+ }
+ if os.Getenv(envVar) != "" {
+ logWarning(envVar, newEnvVar)
+ }
+ if viper.InConfig(oldName) {
+ logWarning(oldName, newName)
+ }
+}
+
+// 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, ".", "_"))
- if os.Getenv(envVar) != "" {
- log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", envVar))
+ 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) {
- log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", 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) {
+ if viper.IsSet(legacyName) {
+ viper.Set(newName, viper.Get(legacyName))
}
}
@@ -342,21 +552,18 @@ func logDeprecatedOptions(options ...string) {
func parseIniFileConfiguration() {
cfgFile := viper.ConfigFileUsed()
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
- var iniConfig map[string]interface{}
+ var iniConfig map[string]any
err := viper.Unmarshal(&iniConfig)
if err != nil {
- _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
- os.Exit(1)
+ logFatal("Error parsing config:", err)
}
cfg, ok := iniConfig["default"].(map[string]any)
if !ok {
- _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
- os.Exit(1)
+ logFatal("Error parsing config: missing [default] section:", iniConfig)
}
err = viper.MergeConfigMap(cfg)
if err != nil {
- _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
- os.Exit(1)
+ logFatal("Error parsing config:", err)
}
}
}
@@ -364,8 +571,9 @@ 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 = ""
if Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL {
@@ -374,34 +582,61 @@ func disableExternalServices() {
}
func validatePlaylistsPath() error {
- for _, path := range strings.Split(Server.PlaylistsPath, string(filepath.ListSeparator)) {
+ for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
_, err := doublestar.Match(path, "")
if err != nil {
- log.Error("Invalid PlaylistsPath", "path", path, err)
- return err
+ return fmt.Errorf("invalid PlaylistsPath %q: %w", path, err)
}
}
return nil
}
-func validatePurgeMissingOption() error {
- allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
- valid := false
- for _, v := range allowedValues {
- if v == Server.Scanner.PurgeMissing {
- valid = true
- break
+// parseLanguages parses a comma-separated language string into a slice.
+// It trims whitespace from each entry and ensures at least [DefaultInfoLanguage] is returned.
+func parseLanguages(lang string) []string {
+ var languages []string
+ for l := range strings.SplitSeq(lang, ",") {
+ l = strings.TrimSpace(l)
+ if l != "" {
+ languages = append(languages, l)
}
}
+ if len(languages) == 0 {
+ return []string{consts.DefaultInfoLanguage}
+ }
+ return languages
+}
+
+func validatePurgeMissingOption() error {
+ allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
+ 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())
+ err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
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 = ""
@@ -413,7 +648,7 @@ func validateScanSchedule() error {
}
func validateBackupSchedule() error {
- if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
+ if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
Server.Backup.Schedule = ""
return nil
}
@@ -423,17 +658,58 @@ func validateBackupSchedule() error {
}
func validateSchedule(schedule, field string) (string, error) {
- if _, err := time.ParseDuration(schedule); err == nil {
- schedule = "@every " + schedule
- }
- c := cron.New()
- id, err := c.AddFunc(schedule, func() {})
+ _, err := scheduler.ParseCrontab(schedule)
if err != nil {
- 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, fmt.Errorf("invalid %s %q (see https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format): %w", field, schedule, err)
}
- return schedule, err
+ 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, ".")
}
// AddHook is used to register initialization code that should run as soon as the config is loaded
@@ -441,6 +717,16 @@ func AddHook(hook func()) {
hooks = append(hooks, hook)
}
+// hasNDEnvVars checks if any ND_ prefixed environment variables are set (excluding ND_CONFIGFILE)
+func hasNDEnvVars() bool {
+ for _, env := range os.Environ() {
+ if strings.HasPrefix(env, "ND_") && !strings.HasPrefix(env, "ND_CONFIGFILE=") {
+ return true
+ }
+ }
+ return false
+}
+
func setViperDefaults() {
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
viper.SetDefault("cachefolder", "")
@@ -450,6 +736,7 @@ 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", "")
@@ -468,19 +755,28 @@ 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("searchfullstring", false)
+ viper.SetDefault("search.fullstring", false)
+ viper.SetDefault("search.backend", "fts")
+ viper.SetDefault("matcher.preferstarred", true)
+ viper.SetDefault("matcher.fuzzythreshold", 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("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s")
+ 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("coverjpegquality", 75)
+ viper.SetDefault("coverartquality", 75)
+ viper.SetDefault("enablewebpencoding", false)
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
+ viper.SetDefault("artistimagefolder", "")
+ viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
+ viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
viper.SetDefault("enablegravatar", false)
viper.SetDefault("enablefavourites", true)
viper.SetDefault("enablestarrating", true)
@@ -488,8 +784,14 @@ 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", false)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
@@ -500,8 +802,9 @@ func setViperDefaults() {
viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second)
viper.SetDefault("passwordencryptionkey", "")
- viper.SetDefault("reverseproxyuserheader", "Remote-User")
- viper.SetDefault("reverseproxywhitelist", "")
+ 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", "")
@@ -518,21 +821,31 @@ func setViperDefaults() {
viper.SetDefault("scanner.genreseparators", "")
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("scanner.followsymlinks", true)
- viper.SetDefault("scanner.purgemissing", "never")
+ 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("agents", "lastfm,spotify")
+ viper.SetDefault("subsonic.minimalclients", "SubMusic")
+ viper.SetDefault("transcoding.maxconcurrent", 0)
+ viper.SetDefault("transcoding.maxconcurrentperuser", 0)
+ viper.SetDefault("transcoding.enablecancellation", false)
+ viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
viper.SetDefault("lastfm.enabled", true)
- viper.SetDefault("lastfm.language", "en")
+ viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
viper.SetDefault("lastfm.apikey", "")
viper.SetDefault("lastfm.secret", "")
- viper.SetDefault("spotify.id", "")
- viper.SetDefault("spotify.secret", "")
+ viper.SetDefault("lastfm.scrobblefirstartistonly", false)
+ viper.SetDefault("deezer.enabled", true)
+ viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
viper.SetDefault("listenbrainz.enabled", true)
- viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
- viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
+ viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
+ viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
+ viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
+ viper.SetDefault("enablescrobblehistory", true)
+ viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("backup.path", "")
viper.SetDefault("backup.schedule", "")
viper.SetDefault("backup.count", 0)
@@ -542,7 +855,13 @@ func setViperDefaults() {
viper.SetDefault("inspect.maxrequests", 1)
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
- viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
+ viper.SetDefault("plugins.folder", "")
+ 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)
viper.SetDefault("devenableprofiler", false)
viper.SetDefault("devautocreateadminpassword", "")
@@ -551,25 +870,40 @@ func setViperDefaults() {
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
viper.SetDefault("devsidebarplaylists", true)
viper.SetDefault("devshowartistpage", true)
+ viper.SetDefault("devuishowconfig", true)
+ viper.SetDefault("devneweventstream", true)
viper.SetDefault("devoffsetoptimize", 50000)
- viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
+ viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
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)
viper.SetDefault("devscannerthreads", 5)
+ viper.SetDefault("devselectivewatcher", true)
viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay)
viper.SetDefault("devenableplayerinsights", true)
+ 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() {
setViperDefaults()
}
-func InitConfig(cfgFile string) {
+func InitConfig(cfgFile string, loadEnvVars bool) {
codecRegistry := viper.NewCodecRegistry()
- _ = codecRegistry.RegisterCodec("ini", ini.Codec{})
+ _ = codecRegistry.RegisterCodec("ini", ini.Codec{
+ LoadOptions: ini.LoadOptions{
+ UnescapeValueDoubleQuotes: true,
+ UnescapeValueCommentSymbols: true,
+ },
+ })
viper.SetOptions(viper.WithCodecRegistry(codecRegistry))
cfgFile = getConfigFile(cfgFile)
@@ -583,15 +917,16 @@ func InitConfig(cfgFile string) {
}
_ = viper.BindEnv("port")
- viper.SetEnvPrefix("ND")
- replacer := strings.NewReplacer(".", "_")
- viper.SetEnvKeyReplacer(replacer)
- viper.AutomaticEnv()
+ if loadEnvVars {
+ viper.SetEnvPrefix("ND")
+ replacer := strings.NewReplacer(".", "_")
+ viper.SetEnvKeyReplacer(replacer)
+ viper.AutomaticEnv()
+ }
err := viper.ReadInConfig()
if viper.ConfigFileUsed() != "" && err != nil {
- _, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err)
- os.Exit(1)
+ logFatal("Navidrome could not open config file:", err)
}
}
@@ -603,7 +938,7 @@ func getConfigFile(cfgFile string) string {
}
cfgFile = os.Getenv("ND_CONFIGFILE")
if cfgFile != "" {
- if _, err := os.Stat(cfgFile); err == nil {
+ if _, err := os.Stat(cfgFile); err == nil { //nolint:gosec
return cfgFile
}
}
diff --git a/conf/configuration_test.go b/conf/configuration_test.go
index 5b54e4975..9c25a0d19 100644
--- a/conf/configuration_test.go
+++ b/conf/configuration_test.go
@@ -2,6 +2,7 @@ package conf_test
import (
"fmt"
+ "os"
"path/filepath"
"testing"
@@ -24,6 +25,257 @@ 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() {
+ It("parses single language", func() {
+ Expect(conf.ParseLanguages("en")).To(Equal([]string{"en"}))
+ })
+
+ It("parses multiple comma-separated languages", func() {
+ Expect(conf.ParseLanguages("pt,en")).To(Equal([]string{"pt", "en"}))
+ })
+
+ It("trims whitespace from languages", func() {
+ Expect(conf.ParseLanguages(" pt , en ")).To(Equal([]string{"pt", "en"}))
+ })
+
+ It("returns default 'en' when empty", func() {
+ Expect(conf.ParseLanguages("")).To(Equal([]string{"en"}))
+ })
+
+ It("returns default 'en' when only whitespace", func() {
+ Expect(conf.ParseLanguages(" ")).To(Equal([]string{"en"}))
+ })
+
+ It("handles multiple languages with various spacing", func() {
+ Expect(conf.ParseLanguages("ja, pt, en")).To(Equal([]string{"ja", "pt", "en"}))
+ })
+ })
+
+ 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",
@@ -31,7 +283,7 @@ var _ = Describe("Configuration", func() {
filename := filepath.Join("testdata", "cfg."+format)
// Initialize config with the test file
- conf.InitConfig(filename)
+ conf.InitConfig(filename, false)
// Load the configuration (with noConfigDump=true)
conf.Load(true)
@@ -39,6 +291,10 @@ var _ = Describe("Configuration", func() {
Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format))
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
+ Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"}))
+
+ // Check deprecated option mapping
+ Expect(conf.Server.ExtAuth.UserHeader).To(Equal("X-Auth-User"))
// The config file used should be the one we created
Expect(conf.Server.ConfigFile).To(Equal(filename))
diff --git a/conf/dir.go b/conf/dir.go
new file mode 100644
index 000000000..f7a14b933
--- /dev/null
+++ b/conf/dir.go
@@ -0,0 +1,77 @@
+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
+}
diff --git a/conf/dir_test.go b/conf/dir_test.go
new file mode 100644
index 000000000..79db379d2
--- /dev/null
+++ b/conf/dir_test.go
@@ -0,0 +1,164 @@
+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()
+ })
+ })
+})
diff --git a/conf/export_test.go b/conf/export_test.go
index 1b6daf036..acebca551 100644
--- a/conf/export_test.go
+++ b/conf/export_test.go
@@ -5,3 +5,30 @@ 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 }
+}
diff --git a/conf/testdata/cfg.ini b/conf/testdata/cfg.ini
index cec7d3c70..cc8b2a4a5 100644
--- a/conf/testdata/cfg.ini
+++ b/conf/testdata/cfg.ini
@@ -1,6 +1,8 @@
[default]
MusicFolder = /ini/music
-UIWelcomeMessage = Welcome ini
+UIWelcomeMessage = 'Welcome ini' ; Just a comment to test the LoadOptions
+ReverseProxyUserHeader = 'X-Auth-User'
[Tags]
-Custom.Aliases = ini,test
\ No newline at end of file
+Custom.Aliases = ini,test
+artist.Split = ";" # Should be able to read ; as a separator
\ No newline at end of file
diff --git a/conf/testdata/cfg.json b/conf/testdata/cfg.json
index 37cf74f08..28fb039d2 100644
--- a/conf/testdata/cfg.json
+++ b/conf/testdata/cfg.json
@@ -1,7 +1,11 @@
{
"musicFolder": "/json/music",
"uiWelcomeMessage": "Welcome json",
+ "reverseProxyUserHeader": "X-Auth-User",
"Tags": {
+ "artist": {
+ "split": ";"
+ },
"custom": {
"aliases": [
"json",
diff --git a/conf/testdata/cfg.toml b/conf/testdata/cfg.toml
index 1dc852b18..589e2a100 100644
--- a/conf/testdata/cfg.toml
+++ b/conf/testdata/cfg.toml
@@ -1,5 +1,8 @@
musicFolder = "/toml/music"
uiWelcomeMessage = "Welcome toml"
+ReverseProxyUserHeader = "X-Auth-User"
+
+Tags.artist.Split = ';'
[Tags.custom]
aliases = ["toml", "test"]
diff --git a/conf/testdata/cfg.yaml b/conf/testdata/cfg.yaml
index 38b98d4aa..e44d2ebbb 100644
--- a/conf/testdata/cfg.yaml
+++ b/conf/testdata/cfg.yaml
@@ -1,6 +1,9 @@
musicFolder: "/yaml/music"
uiWelcomeMessage: "Welcome yaml"
+reverseProxyUserHeader: "X-Auth-User"
Tags:
+ artist:
+ split: [";"]
custom:
aliases:
- yaml
diff --git a/conf/testdata/cfg_nd_conflict.toml b/conf/testdata/cfg_nd_conflict.toml
new file mode 100644
index 000000000..2e8b94bc3
--- /dev/null
+++ b/conf/testdata/cfg_nd_conflict.toml
@@ -0,0 +1,2 @@
+ND_ADDRESS = "127.0.0.1"
+Address = "0.0.0.0"
diff --git a/conf/testdata/cfg_nd_keys.toml b/conf/testdata/cfg_nd_keys.toml
new file mode 100644
index 000000000..de441ce66
--- /dev/null
+++ b/conf/testdata/cfg_nd_keys.toml
@@ -0,0 +1,3 @@
+ND_ADDRESS = "127.0.0.1"
+ND_PORT = 4531
+ND_SCANNER_SCHEDULE = "@every 1h"
diff --git a/consts/consts.go b/consts/consts.go
index fbb2c9429..edd8f2b54 100644
--- a/consts/consts.go
+++ b/consts/consts.go
@@ -56,6 +56,8 @@ const (
ServerReadHeaderTimeout = 3 * time.Second
+ DefaultInfoLanguage = "en"
+
ArtistInfoTimeToLive = 24 * time.Hour
AlbumInfoTimeToLive = 7 * 24 * time.Hour
UpdateLastAccessFrequency = time.Minute
@@ -63,20 +65,31 @@ const (
I18nFolder = "i18n"
ScanIgnoreFile = ".ndignore"
+ ArtworkFolder = "artwork"
- PlaceholderArtistArt = "artist-placeholder.webp"
- PlaceholderAlbumArt = "album-placeholder.webp"
- PlaceholderAvatar = "logo-192x192.png"
- UICoverArtSize = 300
- DefaultUIVolume = 100
+ PlaceholderArtistArt = "artist-placeholder.webp"
+ PlaceholderAlbumArt = "album-placeholder.webp"
+ PlaceholderAvatar = "logo-192x192.png"
+ DefaultUIVolume = 100
+ DefaultUISearchDebounceMs = 200
+ DefaultUIPlaybackReportInterval = time.Minute
DefaultHttpClientTimeOut = 10 * time.Second
+ DefaultListenBrainzBaseURL = "https://api.listenbrainz.org/1/"
+ DefaultListenBrainzArtistAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
+ DefaultListenBrainzTrackAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
+
DefaultScannerExtractor = "taglib"
DefaultWatcherWait = 5 * time.Second
Zwsp = string('\u200b')
)
+const (
+ DefaultUICoverArtSize = 300
+ DefaultMaxImageUploadSize = "10MB"
+)
+
// Prometheus options
const (
PrometheusDefaultPath = "/metrics"
@@ -95,6 +108,13 @@ const (
DefaultCacheCleanUpInterval = 10 * time.Minute
)
+// Entity types
+const (
+ EntityArtist = "artist"
+ EntityPlaylist = "playlist"
+ EntityRadio = "radio"
+)
+
const (
AlbumPlayCountModeAbsolute = "absolute"
AlbumPlayCountModeNormalized = "normalized"
@@ -133,23 +153,31 @@ var (
Name: "mp3 audio",
TargetFormat: "mp3",
DefaultBitRate: 192,
- Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
+ Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
},
{
Name: "opus audio",
TargetFormat: "opus",
DefaultBitRate: 128,
- Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
+ Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
},
{
Name: "aac audio",
TargetFormat: "aac",
DefaultBitRate: 256,
- Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
+ Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
+ },
+ {
+ Name: "flac audio",
+ TargetFormat: "flac",
+ DefaultBitRate: 0,
+ Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
},
}
)
+var HTTPUserAgent = "Navidrome" + "/" + Version
+
var (
VariousArtists = "Various Artists"
// TODO This will be dynamic when using disambiguation
diff --git a/context7.json b/context7.json
new file mode 100644
index 000000000..343873063
--- /dev/null
+++ b/context7.json
@@ -0,0 +1,4 @@
+{
+ "url": "https://context7.com/navidrome/navidrome",
+ "public_key": "pk_WqzhKScNKWQ84J4n0oG0J"
+}
diff --git a/core/agents/README.md b/core/agents/README.md
index 1a3a8e96e..cce62889c 100644
--- a/core/agents/README.md
+++ b/core/agents/README.md
@@ -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 `"lastfm,spotify"`). 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 `"deezer,lastfm"`). The order dictates the priority of the agents
For a simple Agent example, look at the [local_agent](local_agent.go) agent source code.
diff --git a/core/agents/agents.go b/core/agents/agents.go
index 50a1e04ad..ead6dacd0 100644
--- a/core/agents/agents.go
+++ b/core/agents/agents.go
@@ -2,6 +2,7 @@ package agents
import (
"context"
+ "slices"
"strings"
"time"
@@ -13,43 +14,110 @@ import (
"github.com/navidrome/navidrome/utils/singleton"
)
-type Agents struct {
- ds model.DataStore
- agents []Interface
+// PluginLoader defines an interface for loading plugins
+type PluginLoader interface {
+ // PluginNames returns the names of all plugins that implement a particular service
+ PluginNames(capability string) []string
+ // LoadMediaAgent loads and returns a media agent plugin
+ LoadMediaAgent(name string) (Interface, bool)
}
-func GetAgents(ds model.DataStore) *Agents {
+// Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order
+// until one returns valid data.
+type Agents struct {
+ ds model.DataStore
+ pluginLoader PluginLoader
+}
+
+// GetAgents returns the singleton instance of Agents
+func GetAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
return singleton.GetInstance(func() *Agents {
- return createAgents(ds)
+ return createAgents(ds, pluginLoader)
})
}
-func createAgents(ds model.DataStore) *Agents {
- var order []string
- if conf.Server.Agents != "" {
- order = strings.Split(conf.Server.Agents, ",")
+// createAgents creates a new Agents instance. Used in tests
+func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
+ return &Agents{
+ ds: ds,
+ pluginLoader: pluginLoader,
}
- order = append(order, LocalAgentName)
- var res []Interface
- var enabled []string
- for _, name := range order {
- init, ok := Map[name]
- if !ok {
- log.Error("Invalid agent. Check `Agents` configuration", "name", name, "conf", conf.Server.Agents)
- continue
- }
+}
- agent := init(ds)
- if agent == nil {
- log.Debug("Agent not available. Missing configuration?", "name", name)
- continue
- }
- enabled = append(enabled, name)
- res = append(res, init(ds))
+// enabledAgent represents an enabled agent with its type information
+type enabledAgent struct {
+ name string
+ isPlugin bool
+}
+
+// getEnabledAgentNames returns the current list of enabled agents, including:
+// 1. Built-in agents and plugins from config (in the specified order)
+// 2. Always include LocalAgentName
+// 3. If config is empty, include ONLY LocalAgentName
+// Each enabledAgent contains the name and whether it's a plugin (true) or built-in (false)
+func (a *Agents) getEnabledAgentNames() []enabledAgent {
+ // If no agents configured, ONLY use the local agent
+ if conf.Server.Agents == "" {
+ return []enabledAgent{{name: LocalAgentName, isPlugin: false}}
}
- log.Debug("List of agents enabled", "names", enabled)
- return &Agents{ds: ds, agents: res}
+ // Get all available plugin names
+ var availablePlugins []string
+ if a.pluginLoader != nil {
+ availablePlugins = a.pluginLoader.PluginNames("MetadataAgent")
+ }
+ log.Trace("Available MetadataAgent plugins", "plugins", availablePlugins)
+
+ configuredAgents := strings.Split(conf.Server.Agents, ",")
+
+ // Always add LocalAgentName if not already included
+ hasLocalAgent := slices.Contains(configuredAgents, LocalAgentName)
+ if !hasLocalAgent {
+ configuredAgents = append(configuredAgents, LocalAgentName)
+ }
+
+ // Filter to only include valid agents (built-in or plugins)
+ var validAgents []enabledAgent
+ for _, name := range configuredAgents {
+ // Check if it's a built-in agent
+ isBuiltIn := Map[name] != nil
+
+ // Check if it's a plugin
+ isPlugin := slices.Contains(availablePlugins, name)
+
+ if isBuiltIn {
+ validAgents = append(validAgents, enabledAgent{name: name, isPlugin: false})
+ } else if isPlugin {
+ validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
+ } else {
+ log.Debug("Unknown agent ignored", "name", name)
+ }
+ }
+ return validAgents
+}
+
+func (a *Agents) getAgent(ea enabledAgent) Interface {
+ if ea.isPlugin {
+ // Try to load WASM plugin agent (if plugin loader is available)
+ if a.pluginLoader != nil {
+ agent, ok := a.pluginLoader.LoadMediaAgent(ea.name)
+ if ok && agent != nil {
+ return agent
+ }
+ }
+ } else {
+ // Try to get built-in agent
+ constructor, ok := Map[ea.name]
+ if ok {
+ agent := constructor(a.ds)
+ if agent != nil {
+ return agent
+ }
+ log.Debug("Built-in agent not available. Missing configuration?", "name", ea.name)
+ }
+ }
+
+ return nil
}
func (a *Agents) AgentName() string {
@@ -63,22 +131,14 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
case consts.VariousArtistsID:
return "", nil
}
- start := time.Now()
- for _, ag := range a.agents {
- if utils.IsCtxDone(ctx) {
- break
- }
- agent, ok := ag.(ArtistMBIDRetriever)
+
+ return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) {
+ retriever, ok := ag.(ArtistMBIDRetriever)
if !ok {
- continue
+ return "", ErrNotFound
}
- mbid, err := agent.GetArtistMBID(ctx, id, name)
- if mbid != "" && err == nil {
- log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start))
- return mbid, nil
- }
- }
- return "", ErrNotFound
+ return retriever.GetArtistMBID(ctx, id, name)
+ })
}
func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
@@ -88,22 +148,14 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
case consts.VariousArtistsID:
return "", nil
}
- start := time.Now()
- for _, ag := range a.agents {
- if utils.IsCtxDone(ctx) {
- break
- }
- agent, ok := ag.(ArtistURLRetriever)
+
+ return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) {
+ retriever, ok := ag.(ArtistURLRetriever)
if !ok {
- continue
+ return "", ErrNotFound
}
- url, err := agent.GetArtistURL(ctx, id, name, mbid)
- if url != "" && err == nil {
- log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start))
- return url, nil
- }
- }
- return "", ErrNotFound
+ return retriever.GetArtistURL(ctx, id, name, mbid)
+ })
}
func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
@@ -113,24 +165,18 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
case consts.VariousArtistsID:
return "", nil
}
- start := time.Now()
- for _, ag := range a.agents {
- if utils.IsCtxDone(ctx) {
- break
- }
- agent, ok := ag.(ArtistBiographyRetriever)
+
+ return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) {
+ retriever, ok := ag.(ArtistBiographyRetriever)
if !ok {
- continue
+ return "", ErrNotFound
}
- bio, err := agent.GetArtistBiography(ctx, id, name, mbid)
- if err == nil {
- log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
- return bio, nil
- }
- }
- return "", ErrNotFound
+ return retriever.GetArtistBiography(ctx, id, name, mbid)
+ })
}
+// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled
+// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items.
func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) {
switch id {
case consts.UnknownArtistID:
@@ -138,16 +184,23 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
case consts.VariousArtistsID:
return nil, nil
}
+
+ overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier)
+
start := time.Now()
- for _, ag := range a.agents {
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
+ if ag == nil {
+ continue
+ }
if utils.IsCtxDone(ctx) {
break
}
- agent, ok := ag.(ArtistSimilarRetriever)
+ retriever, ok := ag.(ArtistSimilarRetriever)
if !ok {
continue
}
- similar, err := agent.GetSimilarArtists(ctx, id, name, mbid, limit)
+ similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit)
if len(similar) > 0 && err == nil {
if log.IsGreaterOrEqualTo(log.LevelTrace) {
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
@@ -167,24 +220,18 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
case consts.VariousArtistsID:
return nil, nil
}
- start := time.Now()
- for _, ag := range a.agents {
- if utils.IsCtxDone(ctx) {
- break
- }
- agent, ok := ag.(ArtistImageRetriever)
+
+ return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) {
+ retriever, ok := ag.(ArtistImageRetriever)
if !ok {
- continue
+ return nil, ErrNotFound
}
- images, err := agent.GetArtistImages(ctx, id, name, mbid)
- if len(images) > 0 && err == nil {
- log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start))
- return images, nil
- }
- }
- return nil, ErrNotFound
+ return retriever.GetArtistImages(ctx, id, name, mbid)
+ })
}
+// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled
+// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items.
func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
switch id {
case consts.UnknownArtistID:
@@ -192,42 +239,130 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
case consts.VariousArtistsID:
return nil, nil
}
- start := time.Now()
- for _, ag := range a.agents {
- if utils.IsCtxDone(ctx) {
- break
- }
- agent, ok := ag.(ArtistTopSongsRetriever)
+
+ overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
+
+ return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
+ retriever, ok := ag.(ArtistTopSongsRetriever)
if !ok {
- continue
+ return nil, ErrNotFound
}
- songs, err := agent.GetArtistTopSongs(ctx, id, artistName, mbid, count)
- if len(songs) > 0 && err == nil {
- log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
- return songs, nil
- }
- }
- return nil, ErrNotFound
+ return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
+ })
}
func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
if name == consts.UnknownAlbum {
return nil, ErrNotFound
}
+
+ return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) {
+ retriever, ok := ag.(AlbumInfoRetriever)
+ if !ok {
+ return nil, ErrNotFound
+ }
+ return retriever.GetAlbumInfo(ctx, name, artist, mbid)
+ })
+}
+
+func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) {
+ if name == consts.UnknownAlbum {
+ return nil, ErrNotFound
+ }
+
+ return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) {
+ retriever, ok := ag.(AlbumImageRetriever)
+ if !ok {
+ return nil, ErrNotFound
+ }
+ return retriever.GetAlbumImages(ctx, name, artist, mbid)
+ })
+}
+
+// GetSimilarSongsByTrack returns similar songs for a given track.
+func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
+ return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) {
+ retriever, ok := ag.(SimilarSongsByTrackRetriever)
+ if !ok {
+ return nil, ErrNotFound
+ }
+ return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count)
+ })
+}
+
+// GetSimilarSongsByAlbum returns similar songs for a given album.
+func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
+ return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) {
+ retriever, ok := ag.(SimilarSongsByAlbumRetriever)
+ if !ok {
+ return nil, ErrNotFound
+ }
+ return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count)
+ })
+}
+
+// GetSimilarSongsByArtist returns similar songs for a given artist.
+func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error) {
+ switch id {
+ case consts.UnknownArtistID:
+ return nil, ErrNotFound
+ case consts.VariousArtistsID:
+ return nil, nil
+ }
+
+ return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) {
+ retriever, ok := ag.(SimilarSongsByArtistRetriever)
+ if !ok {
+ return nil, ErrNotFound
+ }
+ return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count)
+ })
+}
+
+func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) {
+ var zero T
start := time.Now()
- for _, ag := range a.agents {
+ for _, enabledAgent := range agents.getEnabledAgentNames() {
+ ag := agents.getAgent(enabledAgent)
+ if ag == nil {
+ continue
+ }
if utils.IsCtxDone(ctx) {
break
}
- agent, ok := ag.(AlbumInfoRetriever)
- if !ok {
+ result, err := fn(ag)
+ if err != nil {
+ log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
continue
}
- album, err := agent.GetAlbumInfo(ctx, name, artist, mbid)
- if err == nil {
- log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist,
- "mbid", mbid, "elapsed", time.Since(start))
- return album, nil
+
+ if result != zero {
+ log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start))
+ return result, nil
+ }
+ }
+ return zero, ErrNotFound
+}
+
+func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) {
+ start := time.Now()
+ for _, enabledAgent := range agents.getEnabledAgentNames() {
+ ag := agents.getAgent(enabledAgent)
+ if ag == nil {
+ continue
+ }
+ if utils.IsCtxDone(ctx) {
+ break
+ }
+ results, err := fn(ag)
+ if err != nil {
+ log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
+ continue
+ }
+
+ if len(results) > 0 {
+ log.Debug(ctx, "Got results", "method", methodName, "agent", ag.AgentName(), "count", len(results), "elapsed", time.Since(start))
+ return results, nil
}
}
return nil, ErrNotFound
@@ -241,3 +376,7 @@ var _ ArtistSimilarRetriever = (*Agents)(nil)
var _ ArtistImageRetriever = (*Agents)(nil)
var _ ArtistTopSongsRetriever = (*Agents)(nil)
var _ AlbumInfoRetriever = (*Agents)(nil)
+var _ AlbumImageRetriever = (*Agents)(nil)
+var _ SimilarSongsByTrackRetriever = (*Agents)(nil)
+var _ SimilarSongsByAlbumRetriever = (*Agents)(nil)
+var _ SimilarSongsByArtistRetriever = (*Agents)(nil)
diff --git a/core/agents/agents_plugin_test.go b/core/agents/agents_plugin_test.go
new file mode 100644
index 000000000..b2791c00e
--- /dev/null
+++ b/core/agents/agents_plugin_test.go
@@ -0,0 +1,281 @@
+package agents
+
+import (
+ "context"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils/slice"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// MockPluginLoader implements PluginLoader for testing
+type MockPluginLoader struct {
+ pluginNames []string
+ loadedAgents map[string]*MockAgent
+ pluginCallCount map[string]int
+}
+
+func NewMockPluginLoader() *MockPluginLoader {
+ return &MockPluginLoader{
+ pluginNames: []string{},
+ loadedAgents: make(map[string]*MockAgent),
+ pluginCallCount: make(map[string]int),
+ }
+}
+
+func (m *MockPluginLoader) PluginNames(serviceName string) []string {
+ return m.pluginNames
+}
+
+func (m *MockPluginLoader) LoadMediaAgent(name string) (Interface, bool) {
+ m.pluginCallCount[name]++
+ agent, exists := m.loadedAgents[name]
+ return agent, exists
+}
+
+// MockAgent is a mock agent implementation for testing
+type MockAgent struct {
+ name string
+ mbid string
+}
+
+func (m *MockAgent) AgentName() string {
+ return m.name
+}
+
+func (m *MockAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
+ return m.mbid, nil
+}
+
+var _ Interface = (*MockAgent)(nil)
+var _ ArtistMBIDRetriever = (*MockAgent)(nil)
+
+var _ PluginLoader = (*MockPluginLoader)(nil)
+
+var _ = Describe("Agents with Plugin Loading", func() {
+ var mockLoader *MockPluginLoader
+ var agents *Agents
+
+ BeforeEach(func() {
+ mockLoader = NewMockPluginLoader()
+
+ // Create the agents instance with our mock loader
+ agents = createAgents(nil, mockLoader)
+ })
+
+ Context("Dynamic agent discovery", func() {
+ It("should include ONLY local agent when no config is specified", func() {
+ // Ensure no specific agents are configured
+ conf.Server.Agents = ""
+
+ // Add some plugin agents that should be ignored
+ mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent", "another_plugin")
+
+ // Should only include the local agent
+ enabledAgents := agents.getEnabledAgentNames()
+ Expect(enabledAgents).To(HaveLen(1))
+ Expect(enabledAgents[0].name).To(Equal(LocalAgentName))
+ Expect(enabledAgents[0].isPlugin).To(BeFalse()) // LocalAgent is built-in, not plugin
+ })
+
+ It("should NOT include plugin agents when no config is specified", func() {
+ // Ensure no specific agents are configured
+ conf.Server.Agents = ""
+
+ // Add a plugin agent
+ mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
+
+ // Should only include the local agent
+ enabledAgents := agents.getEnabledAgentNames()
+ Expect(enabledAgents).To(HaveLen(1))
+ Expect(enabledAgents[0].name).To(Equal(LocalAgentName))
+ Expect(enabledAgents[0].isPlugin).To(BeFalse()) // LocalAgent is built-in, not plugin
+ })
+
+ It("should include plugin agents in the enabled agents list ONLY when explicitly configured", func() {
+ // Add a plugin agent
+ mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
+
+ // With no config, should not include plugin
+ conf.Server.Agents = ""
+ enabledAgents := agents.getEnabledAgentNames()
+ Expect(enabledAgents).To(HaveLen(1))
+ Expect(enabledAgents[0].name).To(Equal(LocalAgentName))
+
+ // When explicitly configured, should include plugin
+ conf.Server.Agents = "plugin_agent"
+ enabledAgents = agents.getEnabledAgentNames()
+ var agentNames []string
+ var pluginAgentFound bool
+ for _, agent := range enabledAgents {
+ agentNames = append(agentNames, agent.name)
+ if agent.name == "plugin_agent" {
+ pluginAgentFound = true
+ Expect(agent.isPlugin).To(BeTrue()) // plugin_agent is a plugin
+ }
+ }
+ Expect(agentNames).To(ContainElements(LocalAgentName, "plugin_agent"))
+ Expect(pluginAgentFound).To(BeTrue())
+ })
+
+ It("should only include configured plugin agents when config is specified", func() {
+ // Add two plugin agents
+ mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_one", "plugin_two")
+
+ // Configure only one of them
+ conf.Server.Agents = "plugin_one"
+
+ // Verify only the configured one is included
+ enabledAgents := agents.getEnabledAgentNames()
+ var agentNames []string
+ var pluginOneFound bool
+ for _, agent := range enabledAgents {
+ agentNames = append(agentNames, agent.name)
+ if agent.name == "plugin_one" {
+ pluginOneFound = true
+ Expect(agent.isPlugin).To(BeTrue()) // plugin_one is a plugin
+ }
+ }
+ Expect(agentNames).To(ContainElements(LocalAgentName, "plugin_one"))
+ Expect(agentNames).NotTo(ContainElement("plugin_two"))
+ Expect(pluginOneFound).To(BeTrue())
+ })
+
+ It("should load plugin agents on demand", func() {
+ ctx := context.Background()
+
+ // Configure to use our plugin
+ conf.Server.Agents = "plugin_agent"
+
+ // Add a plugin agent
+ mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
+ mockLoader.loadedAgents["plugin_agent"] = &MockAgent{
+ name: "plugin_agent",
+ mbid: "plugin-mbid",
+ }
+
+ // Try to get data from it
+ mbid, err := agents.GetArtistMBID(ctx, "123", "Artist")
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mbid).To(Equal("plugin-mbid"))
+ Expect(mockLoader.pluginCallCount["plugin_agent"]).To(Equal(1))
+ })
+
+ It("should try both built-in and plugin agents", func() {
+ // Create a mock built-in agent
+ Register("built_in", func(ds model.DataStore) Interface {
+ return &MockAgent{
+ name: "built_in",
+ mbid: "built-in-mbid",
+ }
+ })
+ defer func() {
+ delete(Map, "built_in")
+ }()
+
+ // Configure to use both built-in and plugin
+ conf.Server.Agents = "built_in,plugin_agent"
+
+ // Add a plugin agent
+ mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
+ mockLoader.loadedAgents["plugin_agent"] = &MockAgent{
+ name: "plugin_agent",
+ mbid: "plugin-mbid",
+ }
+
+ // Verify that both are in the enabled list
+ enabledAgents := agents.getEnabledAgentNames()
+ var agentNames []string
+ var builtInFound, pluginFound bool
+ for _, agent := range enabledAgents {
+ agentNames = append(agentNames, agent.name)
+ if agent.name == "built_in" {
+ builtInFound = true
+ Expect(agent.isPlugin).To(BeFalse()) // built-in agent
+ }
+ if agent.name == "plugin_agent" {
+ pluginFound = true
+ Expect(agent.isPlugin).To(BeTrue()) // plugin agent
+ }
+ }
+ Expect(agentNames).To(ContainElements("built_in", "plugin_agent", LocalAgentName))
+ Expect(builtInFound).To(BeTrue())
+ Expect(pluginFound).To(BeTrue())
+ })
+
+ It("should respect the order specified in configuration", func() {
+ // Create mock built-in agents
+ Register("agent_a", func(ds model.DataStore) Interface {
+ return &MockAgent{name: "agent_a"}
+ })
+ Register("agent_b", func(ds model.DataStore) Interface {
+ return &MockAgent{name: "agent_b"}
+ })
+ defer func() {
+ delete(Map, "agent_a")
+ delete(Map, "agent_b")
+ }()
+
+ // Add plugin agents
+ mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_x", "plugin_y")
+
+ // Configure specific order - plugin first, then built-ins
+ conf.Server.Agents = "plugin_y,agent_b,plugin_x,agent_a"
+
+ // Get the agent names
+ enabledAgents := agents.getEnabledAgentNames()
+
+ // Extract just the names to verify the order
+ agentNames := slice.Map(enabledAgents, func(a enabledAgent) string { return a.name })
+
+ // Verify the order matches configuration, with LocalAgentName at the end
+ Expect(agentNames).To(HaveExactElements("plugin_y", "agent_b", "plugin_x", "agent_a", LocalAgentName))
+ })
+
+ It("should NOT call LoadMediaAgent for built-in agents", func() {
+ ctx := context.Background()
+
+ // Create a mock built-in agent
+ Register("builtin_agent", func(ds model.DataStore) Interface {
+ return &MockAgent{
+ name: "builtin_agent",
+ mbid: "builtin-mbid",
+ }
+ })
+ defer func() {
+ delete(Map, "builtin_agent")
+ }()
+
+ // Configure to use only built-in agents
+ conf.Server.Agents = "builtin_agent"
+
+ // Call GetArtistMBID which should only use the built-in agent
+ mbid, err := agents.GetArtistMBID(ctx, "123", "Artist")
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mbid).To(Equal("builtin-mbid"))
+
+ // Verify LoadMediaAgent was NEVER called (no plugin loading for built-in agents)
+ Expect(mockLoader.pluginCallCount).To(BeEmpty())
+ })
+
+ It("should NOT call LoadMediaAgent for invalid agent names", func() {
+ ctx := context.Background()
+
+ // Configure with an invalid agent name (not built-in, not a plugin)
+ conf.Server.Agents = "invalid_agent"
+
+ // This should only result in using the local agent (as the invalid one is ignored)
+ _, err := agents.GetArtistMBID(ctx, "123", "Artist")
+
+ // Should get ErrNotFound since only local agent is available and it returns not found for this operation
+ Expect(err).To(MatchError(ErrNotFound))
+
+ // Verify LoadMediaAgent was NEVER called for the invalid agent
+ Expect(mockLoader.pluginCallCount).To(BeEmpty())
+ })
+ })
+})
diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go
index ea12fb746..50285a084 100644
--- a/core/agents/agents_test.go
+++ b/core/agents/agents_test.go
@@ -4,10 +4,10 @@ import (
"context"
"errors"
+ "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
- "github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/conf"
. "github.com/onsi/ginkgo/v2"
@@ -20,6 +20,7 @@ var _ = Describe("Agents", func() {
var ds model.DataStore
var mfRepo *tests.MockMediaFileRepo
BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
ctx, cancel = context.WithCancel(context.Background())
mfRepo = tests.CreateMockMediaFileRepo()
ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
@@ -29,7 +30,7 @@ var _ = Describe("Agents", func() {
var ag *Agents
BeforeEach(func() {
conf.Server.Agents = ""
- ag = createAgents(ds)
+ ag = createAgents(ds, nil)
})
It("calls the placeholder GetArtistImages", func() {
@@ -49,12 +50,18 @@ var _ = Describe("Agents", func() {
Register("disabled", func(model.DataStore) Interface { return nil })
Register("empty", func(model.DataStore) Interface { return &emptyAgent{} })
conf.Server.Agents = "empty,fake,disabled"
- ag = createAgents(ds)
+ ag = createAgents(ds, nil)
Expect(ag.AgentName()).To(Equal("agents"))
})
It("does not register disabled agents", func() {
- ags := slice.Map(ag.agents, func(a Interface) string { return a.AgentName() })
+ var ags []string
+ for _, enabledAgent := range ag.getEnabledAgentNames() {
+ agent := ag.getAgent(enabledAgent)
+ if agent != nil {
+ ags = append(ags, agent.AgentName())
+ }
+ }
// local agent is always appended to the end of the agents list
Expect(ags).To(HaveExactElements("empty", "fake", "local"))
Expect(ags).ToNot(ContainElement("disabled"))
@@ -173,6 +180,42 @@ var _ = Describe("Agents", func() {
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty())
})
+
+ Context("with multiple image agents", func() {
+ var first *testImageAgent
+ var second *testImageAgent
+
+ BeforeEach(func() {
+ first = &testImageAgent{Name: "imgFail", Err: errors.New("fail")}
+ second = &testImageAgent{Name: "imgOk", Images: []ExternalImage{{URL: "ok", Size: 1}}}
+ Register("imgFail", func(model.DataStore) Interface { return first })
+ Register("imgOk", func(model.DataStore) Interface { return second })
+ })
+
+ It("falls back to the next agent on error", func() {
+ conf.Server.Agents = "imgFail,imgOk"
+ ag = createAgents(ds, nil)
+
+ images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(images).To(Equal([]ExternalImage{{URL: "ok", Size: 1}}))
+ Expect(first.Args).To(HaveExactElements("id", "artist", "mbid"))
+ Expect(second.Args).To(HaveExactElements("id", "artist", "mbid"))
+ })
+
+ It("falls back if the first agent returns no images", func() {
+ first.Err = nil
+ first.Images = []ExternalImage{}
+ conf.Server.Agents = "imgFail,imgOk"
+ ag = createAgents(ds, nil)
+
+ images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(images).To(Equal([]ExternalImage{{URL: "ok", Size: 1}}))
+ Expect(first.Args).To(HaveExactElements("id", "artist", "mbid"))
+ Expect(second.Args).To(HaveExactElements("id", "artist", "mbid"))
+ })
+ })
})
Describe("GetSimilarArtists", func() {
@@ -199,6 +242,7 @@ var _ = Describe("Agents", func() {
Describe("GetArtistTopSongs", func() {
It("returns on first match", func() {
+ conf.Server.DevExternalArtistFetchMultiplier = 1
Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
Name: "A Song",
MBID: "mbid444",
@@ -206,6 +250,7 @@ var _ = Describe("Agents", func() {
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
})
It("skips the agent if it returns an error", func() {
+ conf.Server.DevExternalArtistFetchMultiplier = 1
mock.Err = errors.New("error")
_, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
@@ -217,6 +262,14 @@ var _ = Describe("Agents", func() {
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty())
})
+ It("fetches with multiplier", func() {
+ conf.Server.DevExternalArtistFetchMultiplier = 2
+ Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
+ Name: "A Song",
+ MBID: "mbid444",
+ }}))
+ Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 4))
+ })
})
Describe("GetAlbumInfo", func() {
@@ -226,18 +279,6 @@ var _ = Describe("Agents", func() {
MBID: "mbid444",
Description: "A Description",
URL: "External URL",
- Images: []ExternalImage{
- {
- Size: 174,
- URL: "https://lastfm.freetls.fastly.net/i/u/174s/00000000000000000000000000000000.png",
- }, {
- Size: 64,
- URL: "https://lastfm.freetls.fastly.net/i/u/64s/00000000000000000000000000000000.png",
- }, {
- Size: 34,
- URL: "https://lastfm.freetls.fastly.net/i/u/34s/00000000000000000000000000000000.png",
- },
- },
}))
Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid"))
})
@@ -254,11 +295,77 @@ var _ = Describe("Agents", func() {
Expect(mock.Args).To(BeEmpty())
})
})
+
+ Describe("GetSimilarSongsByTrack", func() {
+ It("returns on first match", func() {
+ Expect(ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)).To(Equal([]Song{{
+ Name: "Similar Song",
+ MBID: "mbid555",
+ }}))
+ Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2))
+ })
+ It("skips the agent if it returns an error", func() {
+ mock.Err = errors.New("error")
+ _, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)
+ Expect(err).To(MatchError(ErrNotFound))
+ Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2))
+ })
+ It("interrupts if the context is canceled", func() {
+ cancel()
+ _, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)
+ Expect(err).To(MatchError(ErrNotFound))
+ Expect(mock.Args).To(BeEmpty())
+ })
+ })
+
+ Describe("GetSimilarSongsByAlbum", func() {
+ It("returns on first match", func() {
+ Expect(ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)).To(Equal([]Song{{
+ Name: "Album Similar Song",
+ MBID: "mbid666",
+ }}))
+ Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2))
+ })
+ It("skips the agent if it returns an error", func() {
+ mock.Err = errors.New("error")
+ _, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)
+ Expect(err).To(MatchError(ErrNotFound))
+ Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2))
+ })
+ It("interrupts if the context is canceled", func() {
+ cancel()
+ _, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)
+ Expect(err).To(MatchError(ErrNotFound))
+ Expect(mock.Args).To(BeEmpty())
+ })
+ })
+
+ Describe("GetSimilarSongsByArtist", func() {
+ It("returns on first match", func() {
+ Expect(ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)).To(Equal([]Song{{
+ Name: "Artist Similar Song",
+ MBID: "mbid777",
+ }}))
+ Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2))
+ })
+ It("skips the agent if it returns an error", func() {
+ mock.Err = errors.New("error")
+ _, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)
+ Expect(err).To(MatchError(ErrNotFound))
+ Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2))
+ })
+ It("interrupts if the context is canceled", func() {
+ cancel()
+ _, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)
+ Expect(err).To(MatchError(ErrNotFound))
+ Expect(mock.Args).To(BeEmpty())
+ })
+ })
})
})
type mockAgent struct {
- Args []interface{}
+ Args []any
Err error
}
@@ -267,7 +374,7 @@ func (a *mockAgent) AgentName() string {
}
func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) {
- a.Args = []interface{}{id, name}
+ a.Args = []any{id, name}
if a.Err != nil {
return "", a.Err
}
@@ -275,7 +382,7 @@ func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (st
}
func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (string, error) {
- a.Args = []interface{}{id, name, mbid}
+ a.Args = []any{id, name, mbid}
if a.Err != nil {
return "", a.Err
}
@@ -283,7 +390,7 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri
}
func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) {
- a.Args = []interface{}{id, name, mbid}
+ a.Args = []any{id, name, mbid}
if a.Err != nil {
return "", a.Err
}
@@ -291,7 +398,7 @@ func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string)
}
func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
- a.Args = []interface{}{id, name, mbid}
+ a.Args = []any{id, name, mbid}
if a.Err != nil {
return nil, a.Err
}
@@ -302,7 +409,7 @@ func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([
}
func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, limit int) ([]Artist, error) {
- a.Args = []interface{}{id, name, mbid, limit}
+ a.Args = []any{id, name, mbid, limit}
if a.Err != nil {
return nil, a.Err
}
@@ -313,7 +420,7 @@ func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string,
}
func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid string, count int) ([]Song, error) {
- a.Args = []interface{}{id, artistName, mbid, count}
+ a.Args = []any{id, artistName, mbid, count}
if a.Err != nil {
return nil, a.Err
}
@@ -324,7 +431,7 @@ func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid st
}
func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
- a.Args = []interface{}{name, artist, mbid}
+ a.Args = []any{name, artist, mbid}
if a.Err != nil {
return nil, a.Err
}
@@ -333,21 +440,42 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string)
MBID: "mbid444",
Description: "A Description",
URL: "External URL",
- Images: []ExternalImage{
- {
- Size: 174,
- URL: "https://lastfm.freetls.fastly.net/i/u/174s/00000000000000000000000000000000.png",
- }, {
- Size: 64,
- URL: "https://lastfm.freetls.fastly.net/i/u/64s/00000000000000000000000000000000.png",
- }, {
- Size: 34,
- URL: "https://lastfm.freetls.fastly.net/i/u/34s/00000000000000000000000000000000.png",
- },
- },
}, nil
}
+func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
+ a.Args = []any{id, name, artist, mbid, count}
+ if a.Err != nil {
+ return nil, a.Err
+ }
+ return []Song{{
+ Name: "Similar Song",
+ MBID: "mbid555",
+ }}, nil
+}
+
+func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
+ a.Args = []any{id, name, artist, mbid, count}
+ if a.Err != nil {
+ return nil, a.Err
+ }
+ return []Song{{
+ Name: "Album Similar Song",
+ MBID: "mbid666",
+ }}, nil
+}
+
+func (a *mockAgent) GetSimilarSongsByArtist(_ context.Context, id, name, mbid string, count int) ([]Song, error) {
+ a.Args = []any{id, name, mbid, count}
+ if a.Err != nil {
+ return nil, a.Err
+ }
+ return []Song{{
+ Name: "Artist Similar Song",
+ MBID: "mbid777",
+ }}, nil
+}
+
type emptyAgent struct {
Interface
}
@@ -355,3 +483,17 @@ type emptyAgent struct {
func (e *emptyAgent) AgentName() string {
return "empty"
}
+
+type testImageAgent struct {
+ Name string
+ Images []ExternalImage
+ Err error
+ Args []any
+}
+
+func (t *testImageAgent) AgentName() string { return t.Name }
+
+func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
+ t.Args = []any{id, name, mbid}
+ return t.Images, t.Err
+}
diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go
index 00f75627d..19df91d02 100644
--- a/core/agents/interfaces.go
+++ b/core/agents/interfaces.go
@@ -13,15 +13,16 @@ type Interface interface {
AgentName() string
}
+// AlbumInfo contains album metadata (no images)
type AlbumInfo struct {
Name string
MBID string
Description string
URL string
- Images []ExternalImage
}
type Artist struct {
+ ID string
Name string
MBID string
}
@@ -32,19 +33,31 @@ type ExternalImage struct {
}
type Song struct {
- Name string
- MBID string
+ 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 (
ErrNotFound = errors.New("not found")
)
-// TODO Break up this interface in more specific methods, like artists
+// AlbumInfoRetriever provides album info (no images)
type AlbumInfoRetriever interface {
GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error)
}
+// AlbumImageRetriever provides album images
+type AlbumImageRetriever interface {
+ GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error)
+}
+
type ArtistMBIDRetriever interface {
GetArtistMBID(ctx context.Context, id string, name string) (string, error)
}
@@ -69,6 +82,41 @@ type ArtistTopSongsRetriever interface {
GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error)
}
+// SimilarSongsByTrackRetriever provides similar songs based on a specific track
+type SimilarSongsByTrackRetriever interface {
+ // GetSimilarSongsByTrack returns songs similar to the given track.
+ // Parameters:
+ // - id: local mediafile ID
+ // - name: track title
+ // - artist: artist name
+ // - mbid: MusicBrainz recording ID (may be empty)
+ // - count: maximum number of results
+ GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error)
+}
+
+// SimilarSongsByAlbumRetriever provides similar songs based on an album
+type SimilarSongsByAlbumRetriever interface {
+ // GetSimilarSongsByAlbum returns songs similar to tracks on the given album.
+ // Parameters:
+ // - id: local album ID
+ // - name: album name
+ // - artist: album artist name
+ // - mbid: MusicBrainz release ID (may be empty)
+ // - count: maximum number of results
+ GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error)
+}
+
+// SimilarSongsByArtistRetriever provides similar songs based on an artist
+type SimilarSongsByArtistRetriever interface {
+ // GetSimilarSongsByArtist returns songs similar to the artist's catalog.
+ // Parameters:
+ // - id: local artist ID
+ // - name: artist name
+ // - mbid: MusicBrainz artist ID (may be empty)
+ // - count: maximum number of results
+ GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error)
+}
+
var Map map[string]Constructor
func Register(name string, init Constructor) {
diff --git a/core/agents/listenbrainz/agent_test.go b/core/agents/listenbrainz/agent_test.go
deleted file mode 100644
index 86a95d5bf..000000000
--- a/core/agents/listenbrainz/agent_test.go
+++ /dev/null
@@ -1,165 +0,0 @@
-package listenbrainz
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "io"
- "net/http"
- "time"
-
- "github.com/navidrome/navidrome/consts"
- "github.com/navidrome/navidrome/core/scrobbler"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
- . "github.com/onsi/gomega/gstruct"
-)
-
-var _ = Describe("listenBrainzAgent", func() {
- var ds model.DataStore
- var ctx context.Context
- var agent *listenBrainzAgent
- var httpClient *tests.FakeHttpClient
- var track *model.MediaFile
-
- BeforeEach(func() {
- ds = &tests.MockDataStore{}
- ctx = context.Background()
- _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
- httpClient = &tests.FakeHttpClient{}
- agent = listenBrainzConstructor(ds)
- agent.client = newClient("http://localhost:8080", httpClient)
- track = &model.MediaFile{
- ID: "123",
- Title: "Track Title",
- Album: "Track Album",
- Artist: "Track Artist",
- TrackNumber: 1,
- MbzRecordingID: "mbz-123",
- MbzAlbumID: "mbz-456",
- MbzReleaseGroupID: "mbz-789",
- Duration: 142.2,
- Participants: map[model.Role]model.ParticipantList{
- model.RoleArtist: []model.Participant{
- {Artist: model.Artist{ID: "ar-1", Name: "Artist 1", MbzArtistID: "mbz-111"}},
- {Artist: model.Artist{ID: "ar-2", Name: "Artist 2", MbzArtistID: "mbz-222"}},
- },
- },
- }
- })
-
- Describe("formatListen", func() {
- It("constructs the listenInfo properly", func() {
- lr := agent.formatListen(track)
- Expect(lr).To(MatchAllFields(Fields{
- "ListenedAt": Equal(0),
- "TrackMetadata": MatchAllFields(Fields{
- "ArtistName": Equal(track.Artist),
- "TrackName": Equal(track.Title),
- "ReleaseName": Equal(track.Album),
- "AdditionalInfo": MatchAllFields(Fields{
- "SubmissionClient": Equal(consts.AppName),
- "SubmissionClientVersion": Equal(consts.Version),
- "TrackNumber": Equal(track.TrackNumber),
- "RecordingMBID": Equal(track.MbzRecordingID),
- "ReleaseMBID": Equal(track.MbzAlbumID),
- "ReleaseGroupMBID": Equal(track.MbzReleaseGroupID),
- "ArtistNames": ConsistOf("Artist 1", "Artist 2"),
- "ArtistMBIDs": ConsistOf("mbz-111", "mbz-222"),
- "DurationMs": Equal(142200),
- }),
- }),
- }))
- })
- })
-
- Describe("NowPlaying", func() {
- It("updates NowPlaying successfully", func() {
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
-
- err := agent.NowPlaying(ctx, "user-1", track)
- Expect(err).ToNot(HaveOccurred())
- })
-
- It("returns ErrNotAuthorized if user is not linked", func() {
- err := agent.NowPlaying(ctx, "user-2", track)
- Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
- })
- })
-
- Describe("Scrobble", func() {
- var sc scrobbler.Scrobble
-
- BeforeEach(func() {
- sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}
- })
-
- It("sends a Scrobble successfully", func() {
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
-
- err := agent.Scrobble(ctx, "user-1", sc)
- Expect(err).ToNot(HaveOccurred())
- })
-
- It("sets the Timestamp properly", func() {
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
-
- err := agent.Scrobble(ctx, "user-1", sc)
- Expect(err).ToNot(HaveOccurred())
-
- decoder := json.NewDecoder(httpClient.SavedRequest.Body)
- var lr listenBrainzRequestBody
- err = decoder.Decode(&lr)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix())))
- })
-
- It("returns ErrNotAuthorized if user is not linked", func() {
- err := agent.Scrobble(ctx, "user-2", sc)
- Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
- })
-
- It("returns ErrRetryLater on error 503", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)),
- StatusCode: 503,
- }
-
- err := agent.Scrobble(ctx, "user-1", sc)
- Expect(err).To(MatchError(scrobbler.ErrRetryLater))
- })
-
- It("returns ErrRetryLater on error 500", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)),
- StatusCode: 500,
- }
-
- err := agent.Scrobble(ctx, "user-1", sc)
- Expect(err).To(MatchError(scrobbler.ErrRetryLater))
- })
-
- It("returns ErrRetryLater on http errors", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)),
- StatusCode: 500,
- }
-
- err := agent.Scrobble(ctx, "user-1", sc)
- Expect(err).To(MatchError(scrobbler.ErrRetryLater))
- })
-
- It("returns ErrUnrecoverable on other errors", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)),
- StatusCode: 400,
- }
-
- err := agent.Scrobble(ctx, "user-1", sc)
- Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
- })
- })
-})
diff --git a/core/agents/listenbrainz/client.go b/core/agents/listenbrainz/client.go
deleted file mode 100644
index 168aad549..000000000
--- a/core/agents/listenbrainz/client.go
+++ /dev/null
@@ -1,179 +0,0 @@
-package listenbrainz
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "net/url"
- "path"
-
- "github.com/navidrome/navidrome/log"
-)
-
-type listenBrainzError struct {
- Code int
- Message string
-}
-
-func (e *listenBrainzError) Error() string {
- return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message)
-}
-
-type httpDoer interface {
- Do(req *http.Request) (*http.Response, error)
-}
-
-func newClient(baseURL string, hc httpDoer) *client {
- return &client{baseURL, hc}
-}
-
-type client struct {
- baseURL string
- hc httpDoer
-}
-
-type listenBrainzResponse struct {
- Code int `json:"code"`
- Message string `json:"message"`
- Error string `json:"error"`
- Status string `json:"status"`
- Valid bool `json:"valid"`
- UserName string `json:"user_name"`
-}
-
-type listenBrainzRequest struct {
- ApiKey string
- Body listenBrainzRequestBody
-}
-
-type listenBrainzRequestBody struct {
- ListenType listenType `json:"listen_type,omitempty"`
- Payload []listenInfo `json:"payload,omitempty"`
-}
-
-type listenType string
-
-const (
- Single listenType = "single"
- PlayingNow listenType = "playing_now"
-)
-
-type listenInfo struct {
- ListenedAt int `json:"listened_at,omitempty"`
- TrackMetadata trackMetadata `json:"track_metadata,omitempty"`
-}
-
-type trackMetadata struct {
- ArtistName string `json:"artist_name,omitempty"`
- TrackName string `json:"track_name,omitempty"`
- ReleaseName string `json:"release_name,omitempty"`
- AdditionalInfo additionalInfo `json:"additional_info,omitempty"`
-}
-
-type additionalInfo struct {
- SubmissionClient string `json:"submission_client,omitempty"`
- SubmissionClientVersion string `json:"submission_client_version,omitempty"`
- TrackNumber int `json:"tracknumber,omitempty"`
- ArtistNames []string `json:"artist_names,omitempty"`
- ArtistMBIDs []string `json:"artist_mbids,omitempty"`
- RecordingMBID string `json:"recording_mbid,omitempty"`
- ReleaseMBID string `json:"release_mbid,omitempty"`
- ReleaseGroupMBID string `json:"release_group_mbid,omitempty"`
- DurationMs int `json:"duration_ms,omitempty"`
-}
-
-func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {
- r := &listenBrainzRequest{
- ApiKey: apiKey,
- }
- response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", r)
- if err != nil {
- return nil, err
- }
- return response, nil
-}
-
-func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error {
- r := &listenBrainzRequest{
- ApiKey: apiKey,
- Body: listenBrainzRequestBody{
- ListenType: PlayingNow,
- Payload: []listenInfo{li},
- },
- }
-
- resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
- if err != nil {
- return err
- }
- if resp.Status != "ok" {
- log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status)
- }
- return nil
-}
-
-func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) error {
- r := &listenBrainzRequest{
- ApiKey: apiKey,
- Body: listenBrainzRequestBody{
- ListenType: Single,
- Payload: []listenInfo{li},
- },
- }
- resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
- if err != nil {
- return err
- }
- if resp.Status != "ok" {
- log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status)
- }
- return nil
-}
-
-func (c *client) path(endpoint string) (string, error) {
- u, err := url.Parse(c.baseURL)
- if err != nil {
- return "", err
- }
- u.Path = path.Join(u.Path, endpoint)
- return u.String(), nil
-}
-
-func (c *client) makeRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
- b, _ := json.Marshal(r.Body)
- uri, err := c.path(endpoint)
- if err != nil {
- return nil, err
- }
- req, _ := http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b))
- req.Header.Add("Content-Type", "application/json; charset=UTF-8")
-
- if r.ApiKey != "" {
- req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey))
- }
-
- log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
- resp, err := c.hc.Do(req)
- if err != nil {
- return nil, err
- }
-
- defer resp.Body.Close()
- decoder := json.NewDecoder(resp.Body)
-
- var response listenBrainzResponse
- jsonErr := decoder.Decode(&response)
- if resp.StatusCode != 200 && jsonErr != nil {
- return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
- }
- if jsonErr != nil {
- return nil, jsonErr
- }
- if response.Code != 0 && response.Code != 200 {
- return &response, &listenBrainzError{Code: response.Code, Message: response.Error}
- }
-
- return &response, nil
-}
diff --git a/core/agents/listenbrainz/client_test.go b/core/agents/listenbrainz/client_test.go
deleted file mode 100644
index 680a7d185..000000000
--- a/core/agents/listenbrainz/client_test.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package listenbrainz
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "io"
- "net/http"
- "os"
-
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("client", func() {
- var httpClient *tests.FakeHttpClient
- var client *client
- BeforeEach(func() {
- httpClient = &tests.FakeHttpClient{}
- client = newClient("BASE_URL/", httpClient)
- })
-
- Describe("listenBrainzResponse", func() {
- It("parses a response properly", func() {
- var response listenBrainzResponse
- err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(response.Code).To(Equal(200))
- Expect(response.Message).To(Equal("Message"))
- Expect(response.UserName).To(Equal("UserName"))
- Expect(response.Valid).To(BeTrue())
- Expect(response.Status).To(Equal("ok"))
- Expect(response.Error).To(Equal("Error"))
- })
- })
-
- Describe("validateToken", func() {
- BeforeEach(func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
- StatusCode: 200,
- }
- })
-
- It("formats the request properly", func() {
- _, err := client.validateToken(context.Background(), "LB-TOKEN")
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/validate-token"))
- Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- })
-
- It("parses and returns the response", func() {
- res, err := client.validateToken(context.Background(), "LB-TOKEN")
- Expect(err).ToNot(HaveOccurred())
- Expect(res.Valid).To(Equal(true))
- Expect(res.UserName).To(Equal("ListenBrainzUser"))
- })
- })
-
- Context("with listenInfo", func() {
- var li listenInfo
- BeforeEach(func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)),
- StatusCode: 200,
- }
- li = listenInfo{
- TrackMetadata: trackMetadata{
- ArtistName: "Track Artist",
- TrackName: "Track Title",
- ReleaseName: "Track Album",
- AdditionalInfo: additionalInfo{
- TrackNumber: 1,
- ArtistNames: []string{"Artist 1", "Artist 2"},
- ArtistMBIDs: []string{"mbz-789", "mbz-012"},
- RecordingMBID: "mbz-123",
- ReleaseMBID: "mbz-456",
- DurationMs: 142200,
- },
- },
- }
- })
-
- Describe("updateNowPlaying", func() {
- It("formats the request properly", func() {
- Expect(client.updateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
- Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
-
- body, _ := io.ReadAll(httpClient.SavedRequest.Body)
- f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json")
- Expect(body).To(MatchJSON(f))
- })
- })
-
- Describe("scrobble", func() {
- BeforeEach(func() {
- li.ListenedAt = 1635000000
- })
-
- It("formats the request properly", func() {
- Expect(client.scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
- Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
-
- body, _ := io.ReadAll(httpClient.SavedRequest.Body)
- f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json")
- Expect(body).To(MatchJSON(f))
- })
- })
- })
-})
diff --git a/core/agents/spotify/client.go b/core/agents/spotify/client.go
deleted file mode 100644
index 25b1f9ede..000000000
--- a/core/agents/spotify/client.go
+++ /dev/null
@@ -1,116 +0,0 @@
-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]interface{}{}
- 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 interface{}) 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)
-}
diff --git a/core/agents/spotify/client_test.go b/core/agents/spotify/client_test.go
deleted file mode 100644
index 2782d2122..000000000
--- a/core/agents/spotify/client_test.go
+++ /dev/null
@@ -1,131 +0,0 @@
-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())
-}
diff --git a/core/agents/spotify/responses.go b/core/agents/spotify/responses.go
deleted file mode 100644
index 21166bf74..000000000
--- a/core/agents/spotify/responses.go
+++ /dev/null
@@ -1,30 +0,0 @@
-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"`
-}
diff --git a/core/agents/spotify/responses_test.go b/core/agents/spotify/responses_test.go
deleted file mode 100644
index 704119816..000000000
--- a/core/agents/spotify/responses_test.go
+++ /dev/null
@@ -1,48 +0,0 @@
-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"))
- })
- })
-})
diff --git a/core/agents/spotify/spotify.go b/core/agents/spotify/spotify.go
deleted file mode 100644
index 633c32984..000000000
--- a/core/agents/spotify/spotify.go
+++ /dev/null
@@ -1,96 +0,0 @@
-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)
- })
-}
diff --git a/core/archiver.go b/core/archiver.go
index a15d0d713..5d1c090cd 100644
--- a/core/archiver.go
+++ b/core/archiver.go
@@ -3,6 +3,7 @@ package core
import (
"archive/zip"
"context"
+ "errors"
"fmt"
"io"
"os"
@@ -10,9 +11,11 @@ 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 {
@@ -22,13 +25,13 @@ type Archiver interface {
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
}
-func NewArchiver(ms MediaStreamer, ds model.DataStore, shares Share) Archiver {
+func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver {
return &archiver{ds: ds, ms: ms, shares: shares}
}
type archiver struct {
ds model.DataStore
- ms MediaStreamer
+ ms stream.MediaStreamer
shares Share
}
@@ -58,7 +61,15 @@ 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)
- _ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
+ 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
+ }
}
}
err = z.Close()
@@ -86,7 +97,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
if isMultiDisc {
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
}
- return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file)
+ return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
}
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
@@ -98,7 +109,7 @@ func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error
return model.ErrNotAuthorized
}
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
- return a.zipMediaFiles(ctx, id, s.Format, s.MaxBitRate, out, s.Tracks)
+ return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
}
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
@@ -109,15 +120,45 @@ func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bi
}
mfs := pls.MediaFiles()
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
- return a.zipMediaFiles(ctx, id, format, bitrate, out, mfs)
+ return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
}
-func (a *archiver) zipMediaFiles(ctx context.Context, id string, format string, bitrate int, out io.Writer, mfs model.MediaFiles) error {
+func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
z := createZipWriter(out, format, bitrate)
+
+ zippedMfs := make(model.MediaFiles, len(mfs))
for idx, mf := range mfs {
file := a.playlistFilename(mf, format, idx)
- _ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
+ 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
+ }
+ mf.Path = file
+ zippedMfs[idx] = mf
}
+
+ // Add M3U file if requested
+ if addM3U && len(zippedMfs) > 0 {
+ plsName := str.SanitizeFilename(name)
+ w, err := z.CreateHeader(&zip.FileHeader{
+ Name: plsName + ".m3u",
+ Modified: mfs[0].UpdatedAt,
+ Method: zip.Store,
+ })
+ if err != nil {
+ log.Error(ctx, "Error creating playlist zip entry", err)
+ return err
+ }
+
+ _, err = w.Write([]byte(zippedMfs.ToM3U8(plsName, false)))
+ if err != nil {
+ log.Error(ctx, "Error writing m3u in zip", err)
+ return err
+ }
+ }
+
err := z.Close()
if err != nil {
log.Error(ctx, "Error closing zip file", "id", id, err)
@@ -130,15 +171,32 @@ 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, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
-}
-
-func sanitizeName(target string) string {
- return strings.ReplaceAll(target, "/", "_")
+ return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
}
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,
@@ -149,23 +207,6 @@ 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)
diff --git a/core/archiver_test.go b/core/archiver_test.go
index f1db5520f..f432139d8 100644
--- a/core/archiver_test.go
+++ b/core/archiver_test.go
@@ -9,6 +9,7 @@ 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"
@@ -44,7 +45,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
- ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
+ ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
out := new(bytes.Buffer)
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
@@ -73,7 +74,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
- ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
+ ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
@@ -88,6 +89,32 @@ 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{
@@ -104,7 +131,7 @@ var _ = Describe("Archiver", func() {
}
sh.On("Load", mock.Anything, "1").Return(share, nil)
- ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
+ ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipShare(context.Background(), "1", out)
@@ -136,7 +163,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("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
+ ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
@@ -145,9 +172,21 @@ var _ = Describe("Archiver", func() {
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).To(BeNil())
- Expect(len(zr.File)).To(Equal(2))
+ Expect(len(zr.File)).To(Equal(3))
Expect(zr.File[0].Name).To(Equal("01 - AC_DC - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
+ Expect(zr.File[2].Name).To(Equal("Test Playlist.m3u"))
+
+ // Verify M3U content
+ m3uFile, err := zr.File[2].Open()
+ Expect(err).To(BeNil())
+ defer m3uFile.Close()
+
+ m3uContent, err := io.ReadAll(m3uFile)
+ Expect(err).To(BeNil())
+
+ expectedM3U := "#EXTM3U\n#PLAYLIST:Test Playlist\n#EXTINF:0,AC/DC - track1\n01 - AC_DC - track1.mp3\n#EXTINF:0,Artist 2 - track2\n02 - Artist 2 - track2.mp3\n"
+ Expect(string(m3uContent)).To(Equal(expectedM3U))
})
})
})
@@ -202,15 +241,15 @@ func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists,
type mockMediaStreamer struct {
mock.Mock
- core.MediaStreamer
+ stream.MediaStreamer
}
-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)
+func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) {
+ args := m.Called(ctx, mf, req)
if args.Error(1) != nil {
return nil, args.Error(1)
}
- return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
+ return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
}
type mockShare struct {
diff --git a/core/artwork/animation.go b/core/artwork/animation.go
new file mode 100644
index 000000000..07f493eb4
--- /dev/null
+++ b/core/artwork/animation.go
@@ -0,0 +1,120 @@
+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
+}
diff --git a/core/artwork/animation_test.go b/core/artwork/animation_test.go
new file mode 100644
index 000000000..9000a8511
--- /dev/null
+++ b/core/artwork/animation_test.go
@@ -0,0 +1,161 @@
+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()
+}
diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go
index 2e92b24c8..b8c395c12 100644
--- a/core/artwork/artwork.go
+++ b/core/artwork/artwork.go
@@ -122,6 +122,10 @@ 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
}
diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go
index cfb7850bd..c95371959 100644
--- a/core/artwork/artwork_internal_test.go
+++ b/core/artwork/artwork_internal_test.go
@@ -9,7 +9,9 @@ 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"
@@ -25,7 +27,7 @@ var _ = Describe("Artwork", func() {
var ffmpeg *tests.MockFFmpeg
var folderRepo *fakeFolderRepo
ctx := log.NewContext(context.TODO())
- var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
+ var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album
var arMultipleCovers model.Artist
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
@@ -35,14 +37,20 @@ 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"}}
+ alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}}
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
+ alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
alMultipleCovers = model.Album{
ID: "666",
@@ -142,13 +150,61 @@ 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{{
- Path: "tests/fixtures/artist/an-album",
- ImageFiles: []string{"artist.png"},
+ LibraryPath: testFileLibPath(repoRoot),
+ Path: "tests/fixtures/artist/an-album",
+ ImageFiles: []string{"artist.png"},
}}
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
arMultipleCovers,
@@ -167,7 +223,7 @@ var _ = Describe("Artwork", func() {
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
- Expect(path).To(Equal(expected))
+ Expect(filepath.ToSlash(path)).To(HaveSuffix(expected))
},
Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"),
Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"),
@@ -190,6 +246,7 @@ var _ = Describe("Artwork", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alOnlyEmbed,
alOnlyExternal,
+ alSingleDisc,
})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
mfWithEmbed,
@@ -233,8 +290,137 @@ 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{{
@@ -246,7 +432,7 @@ var _ = Describe("Artwork", func() {
})
})
When("Square is false", func() {
- It("returns a PNG if original image is a PNG", func() {
+ It("returns PNG if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
Expect(err).ToNot(HaveOccurred())
@@ -257,7 +443,7 @@ var _ = Describe("Artwork", func() {
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
})
- It("returns a JPEG if original image is not a PNG", func() {
+ It("returns JPEG if original image is not a PNG", func() {
conf.Server.CoverArtPriority = "cover.jpg"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
Expect(err).ToNot(HaveOccurred())
@@ -273,15 +459,18 @@ var _ = Describe("Artwork", func() {
var alCover model.Album
DescribeTable("resize",
- func(format string, landscape bool, size int) {
- coverFileName := "cover." + format
- dirName := createImage(format, landscape, size)
+ func(srcFormat string, expectedFormat string, landscape bool, size int) {
+ coverFileName := "cover." + srcFormat
+ dirName := createImage(srcFormat, landscape, size)
alCover = model.Album{
ID: "444",
Name: "Only external",
FolderIDs: []string{"tmp"},
}
- folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
+ folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}}
+ rootLibRepo := &tests.MockLibraryRepo{}
+ rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}})
+ ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alCover,
})
@@ -292,16 +481,127 @@ var _ = Describe("Artwork", func() {
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
- Expect(format).To(Equal("png"))
+ Expect(format).To(Equal(expectedFormat))
Expect(img.Bounds().Size().X).To(Equal(size))
Expect(img.Bounds().Size().Y).To(Equal(size))
},
- Entry("portrait png image", "png", false, 200),
- Entry("landscape png image", "png", true, 200),
- Entry("portrait jpg image", "jpg", false, 200),
- Entry("landscape jpg image", "jpg", true, 200),
+ Entry("portrait png image", "png", "png", false, 200),
+ Entry("landscape png image", "png", "png", true, 200),
+ Entry("portrait jpg image", "jpg", "png", false, 200),
+ Entry("landscape jpg image", "jpg", "png", true, 200),
)
})
+ When("EnableWebPEncoding is true and square is false", func() {
+ BeforeEach(func() {
+ conf.Server.EnableWebPEncoding = true
+ })
+ It("returns WebP even if original image is a PNG", func() {
+ conf.Server.CoverArtPriority = "front.png"
+ r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ img, format, err := image.Decode(r)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(format).To(Equal("webp"))
+ Expect(img.Bounds().Size().X).To(Equal(15))
+ Expect(img.Bounds().Size().Y).To(Equal(15))
+ })
+ It("returns WebP if original image is not a PNG", func() {
+ conf.Server.CoverArtPriority = "cover.jpg"
+ r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ img, format, err := image.Decode(r)
+ Expect(format).To(Equal("webp"))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(img.Bounds().Size().X).To(Equal(200))
+ Expect(img.Bounds().Size().Y).To(Equal(200))
+ })
+ })
+ When("EnableWebPEncoding is false and square is false", func() {
+ BeforeEach(func() {
+ conf.Server.EnableWebPEncoding = false
+ })
+ It("returns PNG if original image is a PNG", func() {
+ conf.Server.CoverArtPriority = "front.png"
+ r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ img, format, err := image.Decode(r)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(format).To(Equal("png"))
+ Expect(img.Bounds().Size().X).To(Equal(15))
+ Expect(img.Bounds().Size().Y).To(Equal(15))
+ })
+ It("returns JPEG if original image is a JPG", func() {
+ conf.Server.CoverArtPriority = "cover.jpg"
+ r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ img, format, err := image.Decode(r)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(format).To(Equal("jpeg"))
+ Expect(img.Bounds().Size().X).To(Equal(200))
+ Expect(img.Bounds().Size().Y).To(Equal(200))
+ })
+ })
+ When("EnableWebPEncoding is false and square is true", func() {
+ var alCover model.Album
+
+ BeforeEach(func() {
+ conf.Server.EnableWebPEncoding = false
+ })
+ It("returns PNG for square mode", func() {
+ dirName := createImage("png", false, 200)
+ alCover = model.Album{
+ ID: "444",
+ Name: "Only external",
+ FolderIDs: []string{"tmp"},
+ }
+ folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}}
+ rootLibRepo := &tests.MockLibraryRepo{}
+ rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}})
+ ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo
+ ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover})
+
+ conf.Server.CoverArtPriority = "cover.png"
+ r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), 200, true)
+ Expect(err).ToNot(HaveOccurred())
+
+ img, format, err := image.Decode(r)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(format).To(Equal("png"))
+ Expect(img.Bounds().Size().X).To(Equal(200))
+ Expect(img.Bounds().Size().Y).To(Equal(200))
+ })
+ })
+ When("Requested size is larger than original", func() {
+ It("clamps size to original dimensions", func() {
+ conf.Server.CoverArtPriority = "front.png"
+ // front.png is 16x16, requesting 99999 should return at original size
+ r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ img, _, err := image.Decode(r)
+ Expect(err).ToNot(HaveOccurred())
+ // Should be clamped to original size (16), not 99999
+ Expect(img.Bounds().Size().X).To(Equal(16))
+ Expect(img.Bounds().Size().Y).To(Equal(16))
+ })
+
+ It("clamps square size to original dimensions", func() {
+ conf.Server.CoverArtPriority = "front.png"
+ // front.png is 16x16, requesting 99999 with square should return 16x16 square
+ r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, true)
+ Expect(err).ToNot(HaveOccurred())
+
+ img, _, err := image.Decode(r)
+ Expect(err).ToNot(HaveOccurred())
+ // Should be clamped to original size (16), not 99999
+ Expect(img.Bounds().Size().X).To(Equal(16))
+ Expect(img.Bounds().Size().Y).To(Equal(16))
+ })
+ })
})
})
diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go
index dfd66e5e5..d42d7f3e4 100644
--- a/core/artwork/artwork_suite_test.go
+++ b/core/artwork/artwork_suite_test.go
@@ -1,9 +1,17 @@
package artwork
import (
+ "io/fs"
+ "net/url"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
"testing"
+ "github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -15,3 +23,49 @@ 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
+}
diff --git a/core/artwork/benchmark_decode_test.go b/core/artwork/benchmark_decode_test.go
new file mode 100644
index 000000000..cfbfe5605
--- /dev/null
+++ b/core/artwork/benchmark_decode_test.go
@@ -0,0 +1,37 @@
+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)
+ }
+ }
+ })
+ }
+ }
+}
diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go
new file mode 100644
index 000000000..bf3d435a8
--- /dev/null
+++ b/core/artwork/benchmark_e2e_test.go
@@ -0,0 +1,189 @@
+package artwork
+
+import (
+ "context"
+ "fmt"
+ "image/jpeg"
+ "io"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ "github.com/navidrome/navidrome/utils/cache"
+)
+
+// setupE2EBenchmark creates an artwork instance with a real album cover image on disk,
+// backed by either a real file cache or disabled cache depending on cacheSize.
+// Note: This benchmarks artwork.Get() directly (not the full HTTP handler), which covers
+// the critical path (source selection, decode, resize, encode, cache). This is a deliberate
+// spec deviation — the full HTTP round-trip benchmark requires significant infrastructure
+// (DB, scanner, fake filesystem) and can be added later if HTTP overhead proves significant.
+//
+// Depends on fakeFolderRepo defined in reader_artist_test.go (same package, compiled together).
+func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID, func()) {
+ b.Helper()
+ cleanup := configtest.SetupConfig()
+ b.Cleanup(cleanup)
+
+ tmpDir, err := os.MkdirTemp("", "artwork-bench-*")
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ // Create a realistic cover image on disk
+ coverPath := filepath.Join(tmpDir, "cover.jpg")
+ coverImg := generateGradientImage(1000, 1000)
+ f, err := os.Create(coverPath)
+ if err != nil {
+ b.Fatal(err)
+ }
+ if err := jpeg.Encode(f, coverImg, &jpeg.Options{Quality: 90}); err != nil {
+ f.Close()
+ b.Fatal(err)
+ }
+ f.Close()
+
+ // Configure cache
+ conf.Server.ImageCacheSize = cacheSize
+ conf.Server.CacheFolder = 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()
+ }
+ })
+ }
+ }
+}
diff --git a/core/artwork/benchmark_encode_test.go b/core/artwork/benchmark_encode_test.go
new file mode 100644
index 000000000..d8ab858f5
--- /dev/null
+++ b/core/artwork/benchmark_encode_test.go
@@ -0,0 +1,40 @@
+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")
+ })
+}
diff --git a/core/artwork/benchmark_helpers_test.go b/core/artwork/benchmark_helpers_test.go
new file mode 100644
index 000000000..0076506f3
--- /dev/null
+++ b/core/artwork/benchmark_helpers_test.go
@@ -0,0 +1,47 @@
+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
+}
diff --git a/core/artwork/benchmark_pipeline_test.go b/core/artwork/benchmark_pipeline_test.go
new file mode 100644
index 000000000..23d5954df
--- /dev/null
+++ b/core/artwork/benchmark_pipeline_test.go
@@ -0,0 +1,50 @@
+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")
+ }
+ }
+ })
+ }
+}
diff --git a/core/artwork/benchmark_tag_test.go b/core/artwork/benchmark_tag_test.go
new file mode 100644
index 000000000..fd649beab
--- /dev/null
+++ b/core/artwork/benchmark_tag_test.go
@@ -0,0 +1,38 @@
+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()
+ }
+}
diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go
index 2e60ca00b..5090d638e 100644
--- a/core/artwork/cache_warmer.go
+++ b/core/artwork/cache_warmer.go
@@ -10,7 +10,6 @@ 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"
@@ -24,7 +23,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 in the UICoverArtSize constant.
+// image size, as well as the size defined by the UICoverArtSize config option.
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
// If image cache is disabled, return a NOOP implementation
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
@@ -38,10 +37,11 @@ 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),
+ artwork: artwork,
+ cache: cache,
+ buffer: make(map[model.ArtworkID]struct{}),
+ wakeSignal: make(chan struct{}, 1),
+ coverArtSize: conf.Server.UICoverArtSize,
}
// Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts
@@ -51,11 +51,12 @@ 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{}
+ artwork Artwork
+ buffer map[model.ArtworkID]struct{}
+ mutex sync.Mutex
+ cache cache.FileCache
+ wakeSignal chan struct{}
+ coverArtSize int
}
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
@@ -96,8 +97,11 @@ func (a *cacheWarmer) run(ctx context.Context) {
// If cache not available, keep waiting
if !a.cache.Available(ctx) {
- if len(a.buffer) > 0 {
- log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", len(a.buffer))
+ a.mutex.Lock()
+ bufferLen := len(a.buffer)
+ a.mutex.Unlock()
+ if bufferLen > 0 {
+ log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", bufferLen)
}
continue
}
@@ -129,7 +133,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, 2, input, a.doCacheImage)
+ errs := pl.Sink(ctx, 4, input, a.doCacheImage)
for err := range errs {
log.Debug(ctx, "Error warming cache", err)
}
@@ -139,16 +143,14 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
- r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true)
+ size := a.coverArtSize
+ r, _, err := a.artwork.Get(ctx, id, size, true)
if err != nil {
- return fmt.Errorf("caching id='%s': %w", id, err)
+ return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
}
- defer r.Close()
_, err = io.Copy(io.Discard, r)
- if err != nil {
- return err
- }
- return nil
+ r.Close()
+ return err
}
func NoopCacheWarmer() CacheWarmer {
diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go
index d35fb6e82..a5da2004c 100644
--- a/core/artwork/cache_warmer_test.go
+++ b/core/artwork/cache_warmer_test.go
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"strings"
+ "sync"
"sync/atomic"
"time"
@@ -80,6 +81,7 @@ var _ = Describe("CacheWarmer", func() {
})
It("adds multiple items to buffer", func() {
+ fc.SetReady(false) // Make cache unavailable so items stay in buffer
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-1"))
cw.PreCache(model.MustParseArtworkID("al-2"))
@@ -89,6 +91,7 @@ var _ = Describe("CacheWarmer", func() {
})
It("deduplicates items in buffer", func() {
+ fc.SetReady(false) // Make cache unavailable so items stay in buffer
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-1"))
cw.PreCache(model.MustParseArtworkID("al-1"))
@@ -141,7 +144,7 @@ var _ = Describe("CacheWarmer", func() {
It("processes items in batches", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
- for i := 0; i < 5; i++ {
+ for i := range 5 {
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
}
@@ -171,20 +174,42 @@ 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
+ err error
+ mu sync.Mutex
+ cachedSizes []int
}
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
if m.err != nil {
return nil, time.Time{}, m.err
}
+ m.mu.Lock()
+ m.cachedSizes = append(m.cachedSizes, size)
+ m.mu.Unlock()
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
}
+func (m *mockArtwork) getCachedSizes() []int {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ result := make([]int, len(m.cachedSizes))
+ copy(result, m.cachedSizes)
+ return result
+}
+
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
return m.Get(ctx, model.ArtworkID{}, size, square)
}
@@ -214,3 +239,7 @@ func (f *mockFileCache) SetDisabled(v bool) {
f.disabled.Store(v)
f.ready.Store(true)
}
+
+func (f *mockFileCache) SetReady(v bool) {
+ f.ready.Store(v)
+}
diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go
new file mode 100644
index 000000000..e765e1b1b
--- /dev/null
+++ b/core/artwork/e2e/album_test.go
@@ -0,0 +1,377 @@
+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")))
+ })
+ })
+
+ 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")))
+ })
+ })
+})
diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go
new file mode 100644
index 000000000..d959b1d60
--- /dev/null
+++ b/core/artwork/e2e/artist_test.go
@@ -0,0 +1,167 @@
+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() {
+ // /
+ // └── artwork/
+ // └── artist/
+ // └── _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/ (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() {
+ // /
+ // └── 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]
+}
diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go
new file mode 100644
index 000000000..667079458
--- /dev/null
+++ b/core/artwork/e2e/disc_test.go
@@ -0,0 +1,371 @@
+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")))
+ })
+ })
+})
diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go
new file mode 100644
index 000000000..e3abca097
--- /dev/null
+++ b/core/artwork/e2e/helpers_test.go
@@ -0,0 +1,184 @@
+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 /artwork// 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)
diff --git a/core/artwork/e2e/mediafile_test.go b/core/artwork/e2e/mediafile_test.go
new file mode 100644
index 000000000..1f43a3827
--- /dev/null
+++ b/core/artwork/e2e/mediafile_test.go
@@ -0,0 +1,110 @@
+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]
+}
diff --git a/core/artwork/e2e/playlist_test.go b/core/artwork/e2e/playlist_test.go
new file mode 100644
index 000000000..d28efca8e
--- /dev/null
+++ b/core/artwork/e2e/playlist_test.go
@@ -0,0 +1,158 @@
+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 (/artwork/playlist/)
+// 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() {
+ // /
+ // └── 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() {
+ // /
+ // ├── 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() {
+ // /
+ // ├── 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() {
+ // /
+ // └── 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
+}
diff --git a/core/artwork/e2e/radio_test.go b/core/artwork/e2e/radio_test.go
new file mode 100644
index 000000000..73ee5f377
--- /dev/null
+++ b/core/artwork/e2e/radio_test.go
@@ -0,0 +1,42 @@
+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() {
+ // /
+ // └── 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())
+ })
+ })
+})
diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go
new file mode 100644
index 000000000..733e2e98c
--- /dev/null
+++ b/core/artwork/e2e/suite_test.go
@@ -0,0 +1,106 @@
+package artworke2e_test
+
+import (
+ "context"
+ "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]
+}
diff --git a/core/artwork/e2e/testdata/embedded_art.mp3 b/core/artwork/e2e/testdata/embedded_art.mp3
new file mode 100644
index 000000000..18cb90674
Binary files /dev/null and b/core/artwork/e2e/testdata/embedded_art.mp3 differ
diff --git a/core/artwork/library_fs.go b/core/artwork/library_fs.go
new file mode 100644
index 000000000..ff557294e
--- /dev/null
+++ b/core/artwork/library_fs.go
@@ -0,0 +1,44 @@
+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
+}
diff --git a/core/artwork/library_fs_test.go b/core/artwork/library_fs_test.go
new file mode 100644
index 000000000..acf08fda3
--- /dev/null
+++ b/core/artwork/library_fs_test.go
@@ -0,0 +1,45 @@
+package artwork
+
+import (
+ "context"
+
+ "github.com/navidrome/navidrome/core/storage/storagetest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("loadLibraryView", Ordered, func() {
+ var ctx context.Context
+ var ds *tests.MockDataStore
+
+ BeforeAll(func() {
+ storagetest.Register("fake", &storagetest.FakeFS{})
+ })
+
+ BeforeEach(func() {
+ ctx = GinkgoT().Context()
+ ds = &tests.MockDataStore{MockedLibrary: &tests.MockLibraryRepo{}}
+ })
+
+ It("returns a view for a library backed by registered storage", func() {
+ Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed())
+
+ lib, err := loadLibraryView(ctx, ds, 1)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lib.FS).ToNot(BeNil())
+ Expect(lib.absRoot).To(Equal("fake:///music"))
+ })
+
+ It("returns an error when the library does not exist", func() {
+ _, err := loadLibraryView(ctx, ds, 999)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("returns an error when the library path uses an unregistered scheme", func() {
+ Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed())
+ _, err := loadLibraryView(ctx, ds, 2)
+ Expect(err).To(HaveOccurred())
+ })
+})
diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go
index 55d8b4352..73ba9b5ee 100644
--- a/core/artwork/reader_album.go
+++ b/core/artwork/reader_album.go
@@ -1,31 +1,35 @@
package artwork
import (
+ "cmp"
"context"
"crypto/md5"
+ "errors"
"fmt"
"io"
- "path/filepath"
+ "path"
"slices"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
+ "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils"
+ "github.com/navidrome/navidrome/utils/natural"
)
type albumArtworkReader struct {
cacheKey
- a *artwork
- provider external.Provider
- album model.Album
- updatedAt *time.Time
- imgFiles []string
- rootFolder string
+ a *artwork
+ provider external.Provider
+ album model.Album
+ updatedAt *time.Time
+ imgFiles []string // library-relative, forward-slash, no leading slash
+ lib libraryView
}
func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) {
@@ -37,28 +41,32 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
if err != nil {
return nil, err
}
+ lib, err := loadLibraryView(ctx, artwork.ds, al.LibraryID)
+ if err != nil {
+ return nil, err
+ }
a := &albumArtworkReader{
- a: artwork,
- provider: provider,
- album: *al,
- updatedAt: imagesUpdateAt,
- imgFiles: imgFiles,
- rootFolder: core.AbsolutePath(ctx, artwork.ds, al.LibraryID, ""),
+ a: artwork,
+ provider: provider,
+ album: *al,
+ updatedAt: imagesUpdateAt,
+ imgFiles: imgFiles,
+ lib: lib,
}
a.cacheKey.artID = artID
- if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) {
- a.cacheKey.lastUpdate = *a.updatedAt
- } else {
- a.cacheKey.lastUpdate = al.UpdatedAt
+ a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
+ if imagesUpdateAt != nil {
+ a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt)
}
return a, nil
}
func (a *albumArtworkReader) Key() string {
- var hash [16]byte
+ hashInput := conf.Server.CoverArtPriority
if conf.Server.EnableExternalServices {
- hash = md5.Sum([]byte(conf.Server.Agents + conf.Server.CoverArtPriority))
+ hashInput = conf.Server.Agents + hashInput
}
+ hash := md5.Sum([]byte(hashInput))
return fmt.Sprintf(
"%s.%x.%t",
a.cacheKey.Key(),
@@ -67,7 +75,7 @@ func (a *albumArtworkReader) Key() string {
)
}
func (a *albumArtworkReader) LastUpdated() time.Time {
- return a.album.UpdatedAt
+ return a.lastUpdate
}
func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
@@ -77,16 +85,19 @@ func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string,
func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
var ff []sourceFunc
- for _, pattern := range strings.Split(strings.ToLower(priority), ",") {
+ for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":
- embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath)
- ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath))
+ embedRel := a.album.EmbedArtPath
+ ff = append(ff,
+ fromTag(ctx, a.lib.FS, embedRel),
+ fromFFmpegTag(ctx, ffmpeg, a.lib.Abs(embedRel)),
+ )
case pattern == "external":
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider))
case len(a.imgFiles) > 0:
- ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern))
+ ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, pattern))
}
}
return ff
@@ -101,23 +112,95 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
if err != nil {
return nil, nil, nil, err
}
+
+ folderIDSet := make(map[string]bool, len(folderIDs))
+ for _, id := range folderIDs {
+ folderIDSet[id] = true
+ }
+
+ // Check if all folders share a common parent that is not already included.
+ // This finds cover art in the album root folder (e.g., "Artist/Album/cover.jpg"
+ // when tracks are in disc subfolders like "Artist/Album/CD1/" and "Artist/Album/CD2/").
+ // For single-folder albums, the parent is only included when the folder has no
+ // images of its own (indicating a disc subfolder needing parent artwork).
+ if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" {
+ if len(folders) >= 2 || !anyFolderHasImages(folders) {
+ parentFolder, err := ds.Folder(ctx).Get(commonParentID)
+ if errors.Is(err, model.ErrNotFound) {
+ log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
+ } else if err != nil {
+ return nil, nil, nil, err
+ }
+ if parentFolder != nil && parentFolder.ParentID != "" {
+ folders = append(folders, *parentFolder)
+ }
+ }
+ }
+
var paths []string
var imgFiles []string
var updatedAt time.Time
for _, f := range folders {
- path := f.AbsolutePath()
- paths = append(paths, path)
+ paths = append(paths, f.AbsolutePath())
if f.ImagesUpdatedAt.After(updatedAt) {
updatedAt = f.ImagesUpdatedAt
}
+ rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/")
for _, img := range f.ImageFiles {
- imgFiles = append(imgFiles, filepath.Join(path, img))
+ imgFiles = append(imgFiles, path.Join(rel, img))
}
}
// Sort image files to ensure consistent selection of cover art
- // This prioritizes files from lower-numbered disc folders by sorting the paths
- slices.Sort(imgFiles)
+ // This prioritizes files without numeric suffixes (e.g., cover.jpg over cover.1.jpg)
+ // by comparing base filenames without extensions
+ slices.SortFunc(imgFiles, compareImageFiles)
return paths, imgFiles, &updatedAt, nil
}
+
+func anyFolderHasImages(folders []model.Folder) bool {
+ for _, f := range folders {
+ if len(f.ImageFiles) > 0 {
+ return true
+ }
+ }
+ return false
+}
+
+// commonParentFolder returns the shared parent folder ID when all folders have the
+// same parent and that parent is not already in folderIDSet. Returns "" otherwise.
+func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) string {
+ if len(folders) == 0 {
+ return ""
+ }
+ parentID := folders[0].ParentID
+ if parentID == "" || folderIDSet[parentID] {
+ return ""
+ }
+ for _, f := range folders[1:] {
+ if f.ParentID != parentID {
+ return ""
+ }
+ }
+ return parentID
+}
+
+// compareImageFiles sorts image paths by: base filename (natural order),
+// then path depth (shallower first), then full path (stable tiebreaker).
+func compareImageFiles(a, b string) int {
+ // Case-insensitive comparison
+ a = strings.ToLower(a)
+ b = strings.ToLower(b)
+
+ // Extract base filenames without extensions
+ baseA := strings.TrimSuffix(path.Base(a), path.Ext(a))
+ baseB := strings.TrimSuffix(path.Base(b), path.Ext(b))
+
+ // Compare base names first, then prefer shallower paths, then full path as tiebreaker
+ return cmp.Or(
+ natural.Compare(baseA, baseB),
+ cmp.Compare(strings.Count(a, "/"), strings.Count(b, "/")),
+ natural.Compare(a, b),
+ )
+}
diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go
index 2665632b9..1cf039bee 100644
--- a/core/artwork/reader_album_test.go
+++ b/core/artwork/reader_album_test.go
@@ -2,7 +2,7 @@ package artwork
import (
"context"
- "path/filepath"
+ "errors"
"time"
"github.com/navidrome/navidrome/model"
@@ -27,26 +27,7 @@ var _ = Describe("Album Artwork Reader", func() {
expectedAt = now.Add(5 * time.Minute)
// Set up the test folders with image files
- repo = &fakeFolderRepo{
- result: []model.Folder{
- {
- Path: "Artist/Album/Disc1",
- ImagesUpdatedAt: expectedAt,
- ImageFiles: []string{"cover.jpg", "back.jpg"},
- },
- {
- Path: "Artist/Album/Disc2",
- ImagesUpdatedAt: now,
- ImageFiles: []string{"cover.jpg"},
- },
- {
- Path: "Artist/Album/Disc10",
- ImagesUpdatedAt: now,
- ImageFiles: []string{"cover.jpg"},
- },
- },
- err: nil,
- }
+ repo = &fakeFolderRepo{}
ds = &fakeDataStore{
folderRepo: repo,
}
@@ -58,19 +39,361 @@ var _ = Describe("Album Artwork Reader", func() {
})
It("returns sorted image files", func() {
+ repo.result = []model.Folder{
+ {
+ Path: "Artist/Album/Disc1",
+ ImagesUpdatedAt: expectedAt,
+ ImageFiles: []string{"cover.jpg", "back.jpg", "cover.1.jpg"},
+ },
+ {
+ Path: "Artist/Album/Disc2",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.jpg"},
+ },
+ {
+ Path: "Artist/Album/Disc10",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.jpg"},
+ },
+ }
+
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
- // Check that image files are sorted alphabetically
- Expect(imgFiles).To(HaveLen(4))
+ // Check that image files are sorted by base name (without extension)
+ Expect(imgFiles).To(HaveLen(5))
- // The files should be sorted by full path
- Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg")))
- Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg")))
- Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg")))
- Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg")))
+ // Files should be sorted by base filename without extension, then by full path
+ // "back" < "cover", so back.jpg comes first
+ // Then all cover.jpg files, sorted by path
+ Expect(imgFiles[0]).To(Equal("Artist/Album/Disc1/back.jpg"))
+ Expect(imgFiles[1]).To(Equal("Artist/Album/Disc1/cover.jpg"))
+ Expect(imgFiles[2]).To(Equal("Artist/Album/Disc2/cover.jpg"))
+ Expect(imgFiles[3]).To(Equal("Artist/Album/Disc10/cover.jpg"))
+ Expect(imgFiles[4]).To(Equal("Artist/Album/Disc1/cover.1.jpg"))
+ })
+
+ It("prioritizes files without numeric suffixes", func() {
+ // Test case for issue #4683: cover.jpg should come before cover.1.jpg
+ repo.result = []model.Folder{
+ {
+ Path: "Artist/Album",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"},
+ },
+ }
+
+ _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgFiles).To(HaveLen(3))
+
+ // cover.jpg should come first because "cover" < "cover.1" < "cover.2"
+ Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
+ Expect(imgFiles[1]).To(Equal("Artist/Album/cover.1.jpg"))
+ Expect(imgFiles[2]).To(Equal("Artist/Album/cover.2.jpg"))
+ })
+
+ It("handles case-insensitive sorting", func() {
+ // Test that Cover.jpg and cover.jpg are treated as equivalent
+ repo.result = []model.Folder{
+ {
+ Path: "Artist/Album",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"Folder.jpg", "cover.jpg", "BACK.jpg"},
+ },
+ }
+
+ _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgFiles).To(HaveLen(3))
+
+ // Files should be sorted case-insensitively: BACK, cover, Folder
+ Expect(imgFiles[0]).To(Equal("Artist/Album/BACK.jpg"))
+ Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg"))
+ Expect(imgFiles[2]).To(Equal("Artist/Album/Folder.jpg"))
+ })
+
+ It("includes images from parent folder for multi-disc albums", func() {
+ // Simulates: Artist/Album/cover.jpg with tracks in Artist/Album/CD1/ and Artist/Album/CD2/
+ repo.result = []model.Folder{
+ {
+ ID: "folder1",
+ Path: "Artist/Album",
+ Name: "CD1",
+ ParentID: "parentFolder",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{},
+ },
+ {
+ ID: "folder2",
+ Path: "Artist/Album",
+ Name: "CD2",
+ ParentID: "parentFolder",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{},
+ },
+ }
+ repo.parentResult = &model.Folder{
+ ID: "parentFolder",
+ Path: "Artist",
+ Name: "Album",
+ ParentID: "artistFolder",
+ ImagesUpdatedAt: expectedAt,
+ ImageFiles: []string{"cover.jpg", "back.jpg"},
+ }
+
+ _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(*imagesUpdatedAt).To(Equal(expectedAt))
+ Expect(imgFiles).To(HaveLen(2))
+ Expect(imgFiles[0]).To(Equal("Artist/Album/back.jpg"))
+ Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg"))
+ })
+
+ It("does not query parent when parent ID is already in album folders", func() {
+ // When the parent folder is already one of the album's folders, skip it
+ repo.result = []model.Folder{
+ {
+ ID: "folder1",
+ Path: "Artist",
+ Name: "Album",
+ ParentID: "folder2",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.jpg"},
+ },
+ {
+ ID: "folder2",
+ Path: "",
+ Name: "Artist",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{},
+ },
+ }
+
+ _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgFiles).To(HaveLen(1))
+ Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
+ // Get should not have been called (parent already in folder set)
+ Expect(repo.getCallCount).To(Equal(0))
+ })
+
+ It("does not query parent when folders have different parents", func() {
+ // When album folders span different parents, don't search any parent
+ repo.result = []model.Folder{
+ {
+ ID: "folder1",
+ Path: "Artist1/Album",
+ Name: "part1",
+ ParentID: "parentA",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.jpg"},
+ },
+ {
+ ID: "folder2",
+ Path: "Artist2/Album",
+ Name: "part2",
+ ParentID: "parentB",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{},
+ },
+ }
+
+ _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgFiles).To(HaveLen(1))
+ Expect(imgFiles[0]).To(Equal("Artist1/Album/part1/cover.jpg"))
+ // Get should not have been called (different parents)
+ Expect(repo.getCallCount).To(Equal(0))
+ })
+
+ It("does not include library root parent for multi-folder albums", func() {
+ // Two album parts directly under the library root — parent is the root itself
+ repo.result = []model.Folder{
+ {
+ ID: "folder1",
+ Path: ".",
+ Name: "AlbumPart1",
+ ParentID: "rootFolder",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.jpg"},
+ },
+ {
+ ID: "folder2",
+ Path: ".",
+ Name: "AlbumPart2",
+ ParentID: "rootFolder",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{},
+ },
+ }
+ repo.parentResult = &model.Folder{
+ ID: "rootFolder",
+ Path: "",
+ Name: ".",
+ ParentID: "",
+ ImageFiles: []string{"unrelated.jpg"},
+ }
+
+ _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgFiles).To(HaveLen(1))
+ Expect(imgFiles[0]).To(Equal("AlbumPart1/cover.jpg"))
+ Expect(repo.getCallCount).To(Equal(1))
+ })
+
+ It("includes top-level album folder for multi-disc albums", func() {
+ // Album folder directly under library root, with disc subfolders
+ repo.result = []model.Folder{
+ {
+ ID: "folder1",
+ Path: "Album",
+ Name: "Disc1",
+ ParentID: "albumFolder",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"folder.jpg"},
+ },
+ {
+ ID: "folder2",
+ Path: "Album",
+ Name: "Disc2",
+ ParentID: "albumFolder",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"folder.jpg"},
+ },
+ }
+ repo.parentResult = &model.Folder{
+ ID: "albumFolder",
+ Path: ".",
+ Name: "Album",
+ ParentID: "rootFolder",
+ ImagesUpdatedAt: expectedAt,
+ ImageFiles: []string{"cover.jpg"},
+ }
+
+ _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(*imagesUpdatedAt).To(Equal(expectedAt))
+ Expect(imgFiles).To(HaveLen(3))
+ Expect(imgFiles[0]).To(Equal("Album/cover.jpg"))
+ Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg"))
+ Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.jpg"))
+ Expect(repo.getCallCount).To(Equal(1))
+ })
+
+ It("does not query parent for single-folder albums that already have images", func() {
+ repo.result = []model.Folder{
+ {
+ ID: "folder1",
+ Path: "Artist",
+ Name: "Album",
+ ParentID: "artistFolder",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.jpg"},
+ },
+ }
+
+ _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgFiles).To(HaveLen(1))
+ Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
+ Expect(repo.getCallCount).To(Equal(0))
+ })
+
+ It("includes parent images for single-disc-subfolder albums", func() {
+ repo.result = []model.Folder{
+ {
+ ID: "folder1",
+ Path: "Artist/Album",
+ Name: "disc1",
+ ParentID: "albumFolder",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{},
+ },
+ }
+ repo.parentResult = &model.Folder{
+ ID: "albumFolder",
+ Path: "Artist",
+ Name: "Album",
+ ParentID: "artistFolder",
+ ImagesUpdatedAt: expectedAt,
+ ImageFiles: []string{"cover.jpg"},
+ }
+
+ _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(*imagesUpdatedAt).To(Equal(expectedAt))
+ Expect(imgFiles).To(HaveLen(1))
+ Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
+ Expect(repo.getCallCount).To(Equal(1))
+ })
+
+ It("propagates non-ErrNotFound errors from parent folder lookup", func() {
+ repo.result = []model.Folder{
+ {
+ ID: "folder1",
+ Path: "Artist/Album",
+ Name: "CD1",
+ ParentID: "parentFolder",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.jpg"},
+ },
+ {
+ ID: "folder2",
+ Path: "Artist/Album",
+ Name: "CD2",
+ ParentID: "parentFolder",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{},
+ },
+ }
+ repo.getErr = errors.New("db connection failed")
+
+ _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).To(MatchError("db connection failed"))
+ Expect(repo.getCallCount).To(Equal(1))
+ })
+
+ It("continues gracefully when parent folder is not found", func() {
+ // Parent folder may have been deleted; should log a warning and continue
+ repo.result = []model.Folder{
+ {
+ ID: "folder1",
+ Path: "Artist/Album",
+ Name: "CD1",
+ ParentID: "missingParent",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.jpg"},
+ },
+ {
+ ID: "folder2",
+ Path: "Artist/Album",
+ Name: "CD2",
+ ParentID: "missingParent",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{},
+ },
+ }
+ // parentResult is nil, so Get will return ErrNotFound
+
+ _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgFiles).To(HaveLen(1))
+ Expect(imgFiles[0]).To(Equal("Artist/Album/CD1/cover.jpg"))
+ Expect(repo.getCallCount).To(Equal(1))
})
})
})
diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go
index 487346b4d..37b7b6dee 100644
--- a/core/artwork/reader_artist.go
+++ b/core/artwork/reader_artist.go
@@ -7,7 +7,9 @@ import (
"io"
"io/fs"
"os"
+ "path"
"path/filepath"
+ "slices"
"strings"
"time"
@@ -20,13 +22,21 @@ import (
"github.com/navidrome/navidrome/utils/str"
)
+const (
+ // maxArtistFolderTraversalDepth defines how many directory levels to search
+ // when looking for artist images (artist folder + parent directories)
+ maxArtistFolderTraversalDepth = 3
+)
+
type artistReader struct {
cacheKey
- a *artwork
- provider external.Provider
- artist model.Artist
- artistFolder string
- imgFiles []string
+ a *artwork
+ provider external.Provider
+ artist model.Artist
+ artistFolder string
+ imgFiles []string
+ imgFolderImgPath string // cached path from ArtistImageFolder lookup
+ lib libraryView
}
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
@@ -52,27 +62,46 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A
if err != nil {
return nil, err
}
+ var lib libraryView
+ if len(als) > 0 {
+ lib, err = loadLibraryView(ctx, artwork.ds, als[0].LibraryID)
+ if err != nil {
+ return nil, err
+ }
+ }
a := &artistReader{
a: artwork,
provider: provider,
artist: *ar,
artistFolder: artistFolder,
imgFiles: imgFiles,
+ lib: lib,
}
// TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can
// change _after_ retrieving from external sources, making the key invalid
//a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt
a.cacheKey.lastUpdate = *imagesUpdatedAt
+ if ar.UpdatedAt != nil && ar.UpdatedAt.After(a.cacheKey.lastUpdate) {
+ a.cacheKey.lastUpdate = *ar.UpdatedAt
+ }
if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) {
a.cacheKey.lastUpdate = artistFolderLastUpdate
}
+ if conf.Server.ArtistImageFolder != "" && strings.Contains(strings.ToLower(conf.Server.ArtistArtPriority), "image-folder") {
+ a.imgFolderImgPath = findImageInArtistFolder(conf.Server.ArtistImageFolder, ar.MbzArtistID, ar.Name)
+ if a.imgFolderImgPath != "" {
+ if info, err := os.Stat(a.imgFolderImgPath); err == nil && info.ModTime().After(a.cacheKey.lastUpdate) {
+ a.cacheKey.lastUpdate = info.ModTime()
+ }
+ }
+ }
a.cacheKey.artID = artID
return a, nil
}
func (a *artistReader) Key() string {
- hash := md5.Sum([]byte(conf.Server.Agents + conf.Server.Spotify.ID))
+ hash := md5.Sum([]byte(conf.Server.Agents))
return fmt.Sprintf(
"%s.%t.%x",
a.cacheKey.Key(),
@@ -86,58 +115,128 @@ func (a *artistReader) LastUpdated() time.Time {
}
func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
- var ff = a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)
+ ff := []sourceFunc{a.fromArtistUploadedImage()}
+ ff = append(ff, a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)...)
return selectImageReader(ctx, a.artID, ff...)
}
+func (a *artistReader) fromArtistUploadedImage() sourceFunc {
+ return fromLocalFile(a.artist.UploadedImagePath())
+}
+
func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
var ff []sourceFunc
- for _, pattern := range strings.Split(strings.ToLower(priority), ",") {
+ for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "external":
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider))
+ case pattern == "image-folder":
+ ff = append(ff, a.fromArtistImageFolder(ctx))
case strings.HasPrefix(pattern, "album/"):
- ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
+ if a.lib.FS != nil {
+ ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
+ }
default:
- ff = append(ff, fromArtistFolder(ctx, a.artistFolder, pattern))
+ ff = append(ff, fromArtistFolder(ctx, a.lib.FS, a.lib.absRoot, a.artistFolder, pattern))
}
}
return ff
}
-func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
+// fromArtistFolder walks up from artistFolder toward libPath looking for a
+// file matching pattern. Traversal is bounded by both maxArtistFolderTraversalDepth
+// and the library root: once we reach libPath (or if artistFolder is outside
+// libPath), the walk stops. All reads go through libFS, which keeps artwork
+// resolution scoped to the configured library.
+func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc {
return func() (io.ReadCloser, string, error) {
- fsys := os.DirFS(artistFolder)
- matches, err := fs.Glob(fsys, pattern)
- if err != nil {
- log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", artistFolder)
- return nil, "", err
+ if libFS == nil {
+ return nil, "", fmt.Errorf("artist folder lookup unavailable")
}
- if len(matches) == 0 {
- return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, artistFolder)
+ rel, err := filepath.Rel(libPath, artistFolder)
+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath)
}
- for _, m := range matches {
- filePath := filepath.Join(artistFolder, m)
- if !model.IsImageFile(m) {
- continue
+ // fs.Glob / path.Join below expect forward-slash paths; filepath.Rel may
+ // return backslash separators on Windows.
+ rel = filepath.ToSlash(rel)
+ current := artistFolder
+ for range maxArtistFolderTraversalDepth {
+ reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern)
+ if err == nil {
+ return reader, hit, nil
}
- f, err := os.Open(filePath)
- if err != nil {
- log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
- return nil, "", err
+ if rel == "." {
+ break // reached library root; don't traverse above it
}
- return f, filePath, nil
+ rel = path.Dir(rel)
+ current = filepath.Dir(current)
}
- return nil, "", nil
+ return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder)
}
}
+// findImageInFolder globs libFS at relFolder for pattern and returns the first
+// matching image. absFolder is used only for the returned display path and log
+// messages so callers see absolute-looking paths consistent with the rest of
+// the artwork pipeline.
+func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) {
+ log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", absFolder)
+ globPattern := pattern
+ if relFolder != "." {
+ globPattern = path.Join(escapeGlobLiteral(relFolder), pattern)
+ }
+ matches, err := fs.Glob(libFS, globPattern)
+ if err != nil {
+ log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err)
+ return nil, "", err
+ }
+
+ // Filter to valid image files
+ var imagePaths []string
+ for _, m := range matches {
+ if !model.IsImageFile(m) {
+ continue
+ }
+ imagePaths = append(imagePaths, m)
+ }
+
+ // Sort image files by prioritizing base filenames without numeric
+ // suffixes (e.g., artist.jpg before artist.1.jpg)
+ slices.SortFunc(imagePaths, compareImageFiles)
+
+ for _, p := range imagePaths {
+ f, err := libFS.Open(p)
+ if err != nil {
+ log.Warn(ctx, "Could not open cover art file", "file", p, err)
+ continue
+ }
+ _, name := path.Split(p)
+ return f, filepath.Join(absFolder, name), nil
+ }
+
+ return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder)
+}
+
+func escapeGlobLiteral(s string) string {
+ var b strings.Builder
+ b.Grow(len(s))
+ for _, r := range s {
+ switch r {
+ case '\\', '*', '?', '[', ']':
+ b.WriteByte('\\')
+ }
+ b.WriteRune(r)
+ }
+ return b.String()
+}
+
func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) {
if len(albums) == 0 {
return "", time.Time{}, nil
}
- libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library
+ libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries
folderPath := str.LongestCommonPrefix(paths)
if !strings.HasSuffix(folderPath, string(filepath.Separator)) {
@@ -162,3 +261,51 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu
}
return folderPath, folders[0].ImagesUpdatedAt, nil
}
+
+func (a *artistReader) fromArtistImageFolder(ctx context.Context) sourceFunc {
+ return func() (io.ReadCloser, string, error) {
+ folder := conf.Server.ArtistImageFolder
+ if folder == "" {
+ return nil, "", nil
+ }
+ // Use cached path from newArtistArtworkReader if available,
+ // avoiding a second directory scan.
+ path := a.imgFolderImgPath
+ if path == "" {
+ path = findImageInArtistFolder(folder, a.artist.MbzArtistID, a.artist.Name)
+ }
+ if path == "" {
+ return nil, "", fmt.Errorf("no image found for artist %q in %s", a.artist.Name, folder)
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, "", err
+ }
+ return f, path, nil
+ }
+}
+
+// findImageInArtistFolder scans a folder for an image file matching the artist's MBID or name
+// (case-insensitive). Returns the full path, or empty string if not found.
+func findImageInArtistFolder(folder, mbzArtistID, artistName string) string {
+ entries, err := os.ReadDir(folder)
+ if err != nil {
+ return ""
+ }
+ for _, candidate := range []string{mbzArtistID, artistName} {
+ if candidate == "" {
+ continue
+ }
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ base := strings.TrimSuffix(name, filepath.Ext(name))
+ if strings.EqualFold(base, candidate) && model.IsImageFile(name) {
+ return filepath.Join(folder, name)
+ }
+ }
+ }
+ return ""
+}
diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go
index 294a5db0b..50ca3a2ce 100644
--- a/core/artwork/reader_artist_test.go
+++ b/core/artwork/reader_artist_test.go
@@ -3,9 +3,14 @@ package artwork
import (
"context"
"errors"
+ "io"
+ "io/fs"
+ "os"
"path/filepath"
"time"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
@@ -62,7 +67,7 @@ var _ = Describe("artistArtworkReader", func() {
}
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
Expect(err).ToNot(HaveOccurred())
- Expect(folder).To(Equal("/music/artist"))
+ Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
Expect(upd).To(Equal(expectedUpdTime))
})
})
@@ -88,7 +93,7 @@ var _ = Describe("artistArtworkReader", func() {
}
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
Expect(err).ToNot(HaveOccurred())
- Expect(folder).To(Equal("/music/artist"))
+ Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
Expect(upd).To(Equal(expectedUpdTime))
})
})
@@ -108,18 +113,612 @@ var _ = Describe("artistArtworkReader", func() {
})
})
})
+
+ var _ = Describe("fromArtistFolder", func() {
+ var (
+ ctx context.Context
+ tempDir string
+ libFS fs.FS
+ testFunc sourceFunc
+ )
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ tempDir = GinkgoT().TempDir()
+ libFS = os.DirFS(tempDir)
+ })
+
+ When("artist folder contains matching image", func() {
+ BeforeEach(func() {
+ // Create test structure: /temp/artist/artist.jpg
+ artistDir := filepath.Join(tempDir, "artist")
+ Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
+
+ artistImagePath := filepath.Join(artistDir, "artist.jpg")
+ Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed())
+
+ testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
+ })
+
+ It("finds and returns the image", func() {
+ reader, path, err := testFunc()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reader).ToNot(BeNil())
+ Expect(path).To(ContainSubstring("artist.jpg"))
+
+ // Verify we can read the content
+ data, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("fake image data"))
+ reader.Close()
+ })
+ })
+
+ When("artist folder name contains glob metacharacters", func() {
+ BeforeEach(func() {
+ artistDir := filepath.Join(tempDir, "Artist [Live]")
+ Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
+
+ artistImagePath := filepath.Join(artistDir, "artist.jpg")
+ Expect(os.WriteFile(artistImagePath, []byte("bracketed artist image"), 0600)).To(Succeed())
+
+ testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
+ })
+
+ It("treats the folder path literally when globbing through the library fs", func() {
+ reader, path, err := testFunc()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reader).ToNot(BeNil())
+ Expect(path).To(ContainSubstring("Artist [Live]" + string(filepath.Separator) + "artist.jpg"))
+
+ data, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("bracketed artist image"))
+ reader.Close()
+ })
+ })
+
+ When("artist folder is empty but parent contains image", func() {
+ BeforeEach(func() {
+ // Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/
+ parentDir := filepath.Join(tempDir, "parent")
+ artistDir := filepath.Join(parentDir, "artist")
+ albumDir := filepath.Join(artistDir, "album")
+ Expect(os.MkdirAll(albumDir, 0755)).To(Succeed())
+
+ // Put artist image in parent directory
+ artistImagePath := filepath.Join(parentDir, "artist.jpg")
+ Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed())
+
+ testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
+ })
+
+ It("finds image in parent directory", func() {
+ reader, path, err := testFunc()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reader).ToNot(BeNil())
+ Expect(path).To(ContainSubstring("parent" + string(filepath.Separator) + "artist.jpg"))
+
+ data, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("parent image"))
+ reader.Close()
+ })
+ })
+
+ When("image is two levels up", func() {
+ BeforeEach(func() {
+ // Create test structure: /temp/grandparent/artist.jpg and /temp/grandparent/parent/artist/
+ grandparentDir := filepath.Join(tempDir, "grandparent")
+ parentDir := filepath.Join(grandparentDir, "parent")
+ artistDir := filepath.Join(parentDir, "artist")
+ Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
+
+ // Put artist image in grandparent directory
+ artistImagePath := filepath.Join(grandparentDir, "artist.jpg")
+ Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed())
+
+ testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
+ })
+
+ It("finds image in grandparent directory", func() {
+ reader, path, err := testFunc()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reader).ToNot(BeNil())
+ Expect(path).To(ContainSubstring("grandparent" + string(filepath.Separator) + "artist.jpg"))
+
+ data, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("grandparent image"))
+ reader.Close()
+ })
+ })
+
+ When("images exist at multiple levels", func() {
+ BeforeEach(func() {
+ // Create test structure with images at multiple levels
+ grandparentDir := filepath.Join(tempDir, "grandparent")
+ parentDir := filepath.Join(grandparentDir, "parent")
+ artistDir := filepath.Join(parentDir, "artist")
+ Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
+
+ // Put artist images at all levels
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist level"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed())
+
+ testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
+ })
+
+ It("prioritizes the closest (artist folder) image", func() {
+ reader, path, err := testFunc()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reader).ToNot(BeNil())
+ Expect(path).To(ContainSubstring("artist" + string(filepath.Separator) + "artist.jpg"))
+
+ data, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("artist level"))
+ reader.Close()
+ })
+ })
+
+ When("pattern matches multiple files", func() {
+ BeforeEach(func() {
+ artistDir := filepath.Join(tempDir, "artist")
+ Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
+
+ // Create multiple matching files
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.abc"), []byte("text file"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
+
+ testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
+ })
+
+ It("returns the first valid image file in sorted order", func() {
+ reader, path, err := testFunc()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reader).ToNot(BeNil())
+
+ // Should return an image file,
+ // Files are sorted: jpg comes before png alphabetically.
+ // .abc comes first, but it's not an image.
+ Expect(path).To(ContainSubstring("artist.jpg"))
+ reader.Close()
+ })
+ })
+
+ When("prioritizing files without numeric suffixes", func() {
+ BeforeEach(func() {
+ // Test case for issue #4683: artist.jpg should come before artist.1.jpg
+ artistDir := filepath.Join(tempDir, "artist")
+ Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
+
+ // Create multiple matches with and without numeric suffixes
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.1.jpg"), []byte("artist 1"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed())
+
+ testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
+ })
+
+ It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() {
+ reader, path, err := testFunc()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reader).ToNot(BeNil())
+ Expect(path).To(ContainSubstring("artist.jpg"))
+
+ // Verify it's the main file, not a numbered variant
+ data, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("artist main"))
+ reader.Close()
+ })
+ })
+
+ When("handling case-insensitive sorting", func() {
+ BeforeEach(func() {
+ // Test case to ensure case-insensitive natural sorting
+ artistDir := filepath.Join(tempDir, "artist")
+ Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
+
+ // Create files with mixed case names
+ Expect(os.WriteFile(filepath.Join(artistDir, "Folder.jpg"), []byte("folder"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed())
+
+ testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "*.*")
+ })
+
+ It("sorts case-insensitively", func() {
+ reader, path, err := testFunc()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reader).ToNot(BeNil())
+
+ // Should return artist.jpg first (case-insensitive: "artist" < "back" < "folder")
+ Expect(path).To(ContainSubstring("artist.jpg"))
+
+ data, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("artist"))
+ reader.Close()
+ })
+ })
+
+ When("no matching files exist anywhere", func() {
+ BeforeEach(func() {
+ artistDir := filepath.Join(tempDir, "artist")
+ Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
+
+ // Create non-matching files
+ Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed())
+
+ testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
+ })
+
+ It("returns an error", func() {
+ reader, path, err := testFunc()
+ Expect(err).To(HaveOccurred())
+ Expect(reader).To(BeNil())
+ Expect(path).To(BeEmpty())
+ Expect(err.Error()).To(ContainSubstring("no matches for 'artist.*'"))
+ Expect(err.Error()).To(ContainSubstring("parent directories"))
+ })
+ })
+
+ When("directory traversal reaches filesystem root", func() {
+ BeforeEach(func() {
+ // Start from a shallow directory to test root boundary
+ artistDir := filepath.Join(tempDir, "artist")
+ Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
+
+ testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
+ })
+
+ It("handles root boundary gracefully", func() {
+ reader, path, err := testFunc()
+ Expect(err).To(HaveOccurred())
+ Expect(reader).To(BeNil())
+ Expect(path).To(BeEmpty())
+ // Should not panic or cause infinite loop
+ })
+ })
+
+ When("file exists but cannot be opened", func() {
+ BeforeEach(func() {
+ artistDir := filepath.Join(tempDir, "artist")
+ Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
+
+ // Create a file that cannot be opened (permission denied)
+ restrictedFile := filepath.Join(artistDir, "artist.jpg")
+ Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed())
+
+ testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
+ })
+
+ It("logs warning and continues searching", func() {
+ // This test depends on the ability to restrict file permissions
+ // For now, we'll just ensure it doesn't panic and returns appropriate error
+ reader, _, err := testFunc()
+ // The file should be readable in test environment, so this will succeed
+ // In a real scenario with permission issues, it would continue searching
+ if err == nil {
+ Expect(reader).ToNot(BeNil())
+ reader.Close()
+ }
+ })
+ })
+
+ When("single album artist scenario (original issue)", func() {
+ BeforeEach(func() {
+ // Simulate the exact folder structure from the issue:
+ // /music/artist/album1/ (single album)
+ // /music/artist/artist.jpg (artist image that should be found)
+ artistDir := filepath.Join(tempDir, "music", "artist")
+ albumDir := filepath.Join(artistDir, "album1")
+ Expect(os.MkdirAll(albumDir, 0755)).To(Succeed())
+
+ // Create artist.jpg in the artist folder (this was not being found before)
+ artistImagePath := filepath.Join(artistDir, "artist.jpg")
+ Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed())
+
+ // The fromArtistFolder is called with the artist folder path
+ testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
+ })
+
+ It("finds artist.jpg in artist folder for single album artist", func() {
+ reader, path, err := testFunc()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reader).ToNot(BeNil())
+ Expect(path).To(ContainSubstring("artist.jpg"))
+ Expect(path).To(ContainSubstring("artist"))
+
+ // Verify the content
+ data, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("single album artist image"))
+ reader.Close()
+ })
+ })
+ })
+
+ Describe("fromArtistUploadedImage", func() {
+ var (
+ tempDir string
+ reader *artistReader
+ )
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ tempDir = GinkgoT().TempDir()
+ conf.Server.DataFolder = conf.NewDir(tempDir)
+
+ // Create the artwork/artist directory
+ Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed())
+
+ reader = &artistReader{}
+ })
+
+ When("artist has an uploaded image", func() {
+ It("returns the uploaded image", func() {
+ imgPath := filepath.Join(tempDir, "artwork", "artist", "ar-1_test.jpg")
+ Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed())
+
+ reader.artist = model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"}
+ sf := reader.fromArtistUploadedImage()
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ Expect(path).To(Equal(imgPath))
+
+ data, err := io.ReadAll(r)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("uploaded artist image"))
+ r.Close()
+ })
+ })
+
+ When("artist has no uploaded image", func() {
+ It("returns nil reader (falls through)", func() {
+ reader.artist = model.Artist{ID: "ar-1"}
+ sf := reader.fromArtistUploadedImage()
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).To(BeNil())
+ Expect(path).To(BeEmpty())
+ })
+ })
+ })
+
+ Describe("fromArtistImageFolder", func() {
+ var (
+ ctx context.Context
+ tempDir string
+ ar *artistReader
+ )
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ DeferCleanup(configtest.SetupConfig())
+ tempDir = GinkgoT().TempDir()
+ ar = &artistReader{}
+ })
+
+ When("ArtistImageFolder is not configured", func() {
+ It("returns nil (skips)", func() {
+ conf.Server.ArtistImageFolder = ""
+ ar.artist = model.Artist{Name: "Test Artist"}
+ sf := ar.fromArtistImageFolder(ctx)
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).To(BeNil())
+ Expect(path).To(BeEmpty())
+ })
+ })
+
+ When("image exists matching MBID", func() {
+ It("finds the image by MBID", func() {
+ conf.Server.ArtistImageFolder = tempDir
+ mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
+ imgPath := filepath.Join(tempDir, mbid+".jpg")
+ Expect(os.WriteFile(imgPath, []byte("mbid image"), 0600)).To(Succeed())
+
+ ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
+ sf := ar.fromArtistImageFolder(ctx)
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ Expect(path).To(Equal(imgPath))
+
+ data, err := io.ReadAll(r)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("mbid image"))
+ r.Close()
+ })
+ })
+
+ When("MBID match is case-insensitive", func() {
+ It("finds the image regardless of case", func() {
+ conf.Server.ArtistImageFolder = tempDir
+ mbid := "F27EC8DB-AF05-4F36-916E-3D57F91ECF5E"
+ imgPath := filepath.Join(tempDir, "f27ec8db-af05-4f36-916e-3d57f91ecf5e.png")
+ Expect(os.WriteFile(imgPath, []byte("mbid case image"), 0600)).To(Succeed())
+
+ ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
+ sf := ar.fromArtistImageFolder(ctx)
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ Expect(path).To(Equal(imgPath))
+ r.Close()
+ })
+ })
+
+ When("no MBID file exists but artist name file does", func() {
+ It("falls back to artist name match", func() {
+ conf.Server.ArtistImageFolder = tempDir
+ imgPath := filepath.Join(tempDir, "Test Artist.jpg")
+ Expect(os.WriteFile(imgPath, []byte("name image"), 0600)).To(Succeed())
+
+ ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: "nonexistent-mbid"}
+ sf := ar.fromArtistImageFolder(ctx)
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ Expect(path).To(Equal(imgPath))
+
+ data, err := io.ReadAll(r)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("name image"))
+ r.Close()
+ })
+ })
+
+ When("artist name match is case-insensitive", func() {
+ It("matches regardless of case", func() {
+ conf.Server.ArtistImageFolder = tempDir
+ imgPath := filepath.Join(tempDir, "test artist.jpg")
+ Expect(os.WriteFile(imgPath, []byte("case insensitive"), 0600)).To(Succeed())
+
+ ar.artist = model.Artist{Name: "Test Artist"}
+ sf := ar.fromArtistImageFolder(ctx)
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ Expect(path).To(Equal(imgPath))
+ r.Close()
+ })
+ })
+
+ When("both MBID and name files exist", func() {
+ It("prefers MBID over name match", func() {
+ conf.Server.ArtistImageFolder = tempDir
+ mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
+ mbidPath := filepath.Join(tempDir, mbid+".jpg")
+ namePath := filepath.Join(tempDir, "Test Artist.jpg")
+ Expect(os.WriteFile(mbidPath, []byte("mbid image"), 0600)).To(Succeed())
+ Expect(os.WriteFile(namePath, []byte("name image"), 0600)).To(Succeed())
+
+ ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
+ sf := ar.fromArtistImageFolder(ctx)
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ Expect(path).To(Equal(mbidPath))
+
+ data, err := io.ReadAll(r)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("mbid image"))
+ r.Close()
+ })
+ })
+
+ When("no matching image found", func() {
+ It("returns an error", func() {
+ conf.Server.ArtistImageFolder = tempDir
+ // Create an unrelated file
+ Expect(os.WriteFile(filepath.Join(tempDir, "other.jpg"), []byte("other"), 0600)).To(Succeed())
+
+ ar.artist = model.Artist{Name: "Test Artist"}
+ sf := ar.fromArtistImageFolder(ctx)
+ r, _, err := sf()
+ Expect(err).To(HaveOccurred())
+ Expect(r).To(BeNil())
+ Expect(err.Error()).To(ContainSubstring("no image found"))
+ })
+ })
+
+ When("cached imgFolderImgPath is set", func() {
+ It("uses cached path instead of scanning", func() {
+ conf.Server.ArtistImageFolder = tempDir
+ imgPath := filepath.Join(tempDir, "cached.jpg")
+ Expect(os.WriteFile(imgPath, []byte("cached image"), 0600)).To(Succeed())
+
+ ar.artist = model.Artist{Name: "Test Artist"}
+ ar.imgFolderImgPath = imgPath
+ sf := ar.fromArtistImageFolder(ctx)
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ Expect(path).To(Equal(imgPath))
+
+ data, err := io.ReadAll(r)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("cached image"))
+ r.Close()
+ })
+ })
+ })
+
+ Describe("findImageInArtistFolder", func() {
+ var tempDir string
+
+ BeforeEach(func() {
+ tempDir = GinkgoT().TempDir()
+ })
+
+ When("matching file exists by MBID", func() {
+ It("returns the file path", func() {
+ mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
+ imgPath := filepath.Join(tempDir, mbid+".jpg")
+ Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed())
+
+ path := findImageInArtistFolder(tempDir, mbid, "Test")
+ Expect(path).To(Equal(imgPath))
+ })
+ })
+
+ When("matching file exists by name", func() {
+ It("returns the file path", func() {
+ imgPath := filepath.Join(tempDir, "Test Artist.png")
+ Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed())
+
+ path := findImageInArtistFolder(tempDir, "", "Test Artist")
+ Expect(path).To(Equal(imgPath))
+ })
+ })
+
+ When("no matching file exists", func() {
+ It("returns empty string", func() {
+ path := findImageInArtistFolder(tempDir, "", "Unknown Artist")
+ Expect(path).To(BeEmpty())
+ })
+ })
+
+ When("folder does not exist", func() {
+ It("returns empty string", func() {
+ path := findImageInArtistFolder("/nonexistent/path", "", "Test")
+ Expect(path).To(BeEmpty())
+ })
+ })
+ })
})
type fakeFolderRepo struct {
model.FolderRepository
- result []model.Folder
- err error
+ result []model.Folder
+ parentResult *model.Folder
+ getErr error
+ getCallCount int
+ err error
}
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
return f.result, f.err
}
+func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) {
+ f.getCallCount++
+ if f.getErr != nil {
+ return nil, f.getErr
+ }
+ if f.parentResult != nil {
+ return f.parentResult, nil
+ }
+ return nil, model.ErrNotFound
+}
+
type fakeDataStore struct {
model.DataStore
folderRepo *fakeFolderRepo
diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go
new file mode 100644
index 000000000..0f648c987
--- /dev/null
+++ b/core/artwork/reader_disc.go
@@ -0,0 +1,267 @@
+package artwork
+
+import (
+ "context"
+ "crypto/md5"
+ "fmt"
+ "io"
+ "path"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/core/ffmpeg"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils"
+)
+
+type discArtworkReader struct {
+ cacheKey
+ a *artwork
+ album model.Album
+ discNumber int
+ imgFiles []string // library-relative, forward-slash, no leading slash
+ discFoldersRel map[string]bool // library-relative folder paths
+ isMultiFolder bool
+ firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs
+ lib libraryView
+ updatedAt *time.Time
+}
+
+func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*discArtworkReader, error) {
+ albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID)
+ if err != nil {
+ return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
+ }
+
+ al, err := a.ds.Album(ctx).Get(albumID)
+ if err != nil {
+ return nil, err
+ }
+
+ _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, a.ds, *al)
+ if err != nil {
+ return nil, err
+ }
+
+ // Query mediafiles for this album + disc to find folder associations and first track
+ mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Sort: "track_number",
+ Order: "ASC",
+ Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ lib, err := loadLibraryView(ctx, a.ds, al.LibraryID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Build disc folder set and find first track. mf.Path is already library-relative.
+ var firstTrackRel string
+ allFolderIDs := make(map[string]bool)
+ for _, mf := range mfs {
+ allFolderIDs[mf.FolderID] = true
+ if firstTrackRel == "" {
+ firstTrackRel = filepath.ToSlash(mf.Path)
+ }
+ }
+
+ // Resolve folder IDs to library-relative paths
+ discFoldersRel := make(map[string]bool)
+ if len(allFolderIDs) > 0 {
+ folderIDs := make([]string, 0, len(allFolderIDs))
+ for id := range allFolderIDs {
+ folderIDs = append(folderIDs, id)
+ }
+ folders, err := a.ds.Folder(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"folder.id": folderIDs},
+ })
+ if err != nil {
+ return nil, err
+ }
+ for _, f := range folders {
+ rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/")
+ discFoldersRel[rel] = true
+ }
+ }
+
+ isMultiFolder := len(al.FolderIDs) > 1
+
+ r := &discArtworkReader{
+ a: a,
+ album: *al,
+ discNumber: discNumber,
+ imgFiles: imgFiles,
+ discFoldersRel: discFoldersRel,
+ isMultiFolder: isMultiFolder,
+ firstTrackRel: firstTrackRel,
+ lib: lib,
+ updatedAt: imagesUpdatedAt,
+ }
+ r.cacheKey.artID = artID
+ r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
+ if imagesUpdatedAt != nil {
+ r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt)
+ }
+ return r, nil
+}
+
+func (d *discArtworkReader) Key() string {
+ hash := md5.Sum([]byte(conf.Server.DiscArtPriority))
+ return fmt.Sprintf(
+ "%s.%x",
+ d.cacheKey.Key(),
+ hash,
+ )
+}
+
+func (d *discArtworkReader) LastUpdated() time.Time {
+ return d.lastUpdate
+}
+
+func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
+ var ff = d.fromDiscArtPriority(ctx, d.a.ffmpeg, conf.Server.DiscArtPriority)
+ // Fallback to album cover art
+ albumArtID := model.NewArtworkID(model.KindAlbumArtwork, d.album.ID, &d.album.UpdatedAt)
+ ff = append(ff, fromAlbum(ctx, d.a, albumArtID))
+ return selectImageReader(ctx, d.cacheKey.artID, ff...)
+}
+
+func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
+ var ff []sourceFunc
+ for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
+ pattern = strings.TrimSpace(pattern)
+ switch {
+ case pattern == "embedded":
+ ff = append(ff,
+ fromTag(ctx, d.lib.FS, d.firstTrackRel),
+ fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)),
+ )
+ case pattern == "external":
+ // Not supported for disc art, silently ignore
+ case pattern == "discsubtitle":
+ if subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]); subtitle != "" {
+ ff = append(ff, d.fromDiscSubtitle(ctx, subtitle))
+ }
+ case len(d.imgFiles) > 0:
+ ff = append(ff, d.fromExternalFile(ctx, pattern))
+ }
+ }
+ return ff
+}
+
+// fromDiscSubtitle returns a sourceFunc that matches image files whose stem
+// (filename without extension) equals the disc subtitle (case-insensitive).
+func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc {
+ return func() (io.ReadCloser, string, error) {
+ for _, file := range d.imgFiles {
+ name := path.Base(file)
+ stem := strings.TrimSuffix(name, path.Ext(name))
+ if !strings.EqualFold(stem, subtitle) {
+ continue
+ }
+ f, err := d.lib.FS.Open(file)
+ if err != nil {
+ log.Warn(ctx, "Could not open disc art file", "file", file, err)
+ continue
+ }
+ return f, file, nil
+ }
+ return nil, "", fmt.Errorf("disc %d: no image file matching subtitle %q", d.discNumber, subtitle)
+ }
+}
+
+// globMetaChars holds the substitution metacharacters understood by
+// filepath.Match. The '\' escape character is intentionally excluded:
+// disc art patterns come from user config and never include escaped
+// metachars in practice, and treating '\' as a metachar would misalign
+// the literal-prefix extraction in extractDiscNumber.
+const globMetaChars = "*?["
+
+// extractDiscNumber parses the disc number from a filename matched by a
+// filepath.Match-style glob pattern.
+//
+// Both pattern and filename must already be lowercased by the caller, which
+// is also expected to have verified that filepath.Match(pattern, filename)
+// is true before calling this function.
+func extractDiscNumber(pattern, filename string) (int, bool) {
+ metaIdx := strings.IndexAny(pattern, globMetaChars)
+ if metaIdx < 0 {
+ return 0, false
+ }
+ prefix := pattern[:metaIdx]
+ if !strings.HasPrefix(filename, prefix) {
+ return 0, false
+ }
+
+ start := len(prefix)
+ end := start
+ for end < len(filename) && filename[end] >= '0' && filename[end] <= '9' {
+ end++
+ }
+ if end == start {
+ return 0, false
+ }
+ num, err := strconv.Atoi(filename[start:end])
+ if err != nil {
+ return 0, false
+ }
+ return num, true
+}
+
+// fromExternalFile returns a sourceFunc that matches image files against a glob
+// pattern. A numbered filename whose number equals the target disc wins over
+// any unnumbered candidate; callers must pass a lowercase pattern.
+func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc {
+ isLiteral := !strings.ContainsAny(pattern, globMetaChars)
+ return func() (io.ReadCloser, string, error) {
+ var fallbacks []string
+ for _, file := range d.imgFiles {
+ name := strings.ToLower(path.Base(file))
+ match, err := filepath.Match(pattern, name)
+ if err != nil {
+ log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file)
+ continue
+ }
+ if !match {
+ continue
+ }
+
+ if !isLiteral {
+ if num, hasNum := extractDiscNumber(pattern, name); hasNum {
+ if num != d.discNumber {
+ continue
+ }
+ f, err := d.lib.FS.Open(file)
+ if err != nil {
+ log.Warn(ctx, "Could not open disc art file", "file", file, err)
+ continue
+ }
+ return f, file, nil
+ }
+ }
+
+ if d.isMultiFolder && !d.discFoldersRel[path.Dir(file)] {
+ continue
+ }
+ fallbacks = append(fallbacks, file)
+ }
+
+ for _, file := range fallbacks {
+ f, err := d.lib.FS.Open(file)
+ if err != nil {
+ log.Warn(ctx, "Could not open disc art file", "file", file, err)
+ continue
+ }
+ return f, file, nil
+ }
+ return nil, "", fmt.Errorf("disc %d: pattern '%s' not matched by files", d.discNumber, pattern)
+ }
+}
diff --git a/core/artwork/reader_disc_test.go b/core/artwork/reader_disc_test.go
new file mode 100644
index 000000000..8264ee27b
--- /dev/null
+++ b/core/artwork/reader_disc_test.go
@@ -0,0 +1,492 @@
+package artwork
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+
+ "github.com/navidrome/navidrome/model"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Disc Artwork Reader", func() {
+ Describe("extractDiscNumber", func() {
+ DescribeTable("extracts disc number from filename based on glob pattern",
+ func(pattern, filename string, expectedNum int, expectedOk bool) {
+ num, ok := extractDiscNumber(pattern, filename)
+ Expect(ok).To(Equal(expectedOk))
+ if expectedOk {
+ Expect(num).To(Equal(expectedNum))
+ }
+ },
+ // Standard disc patterns
+ Entry("disc1.jpg", "disc*.*", "disc1.jpg", 1, true),
+ Entry("disc2.png", "disc*.*", "disc2.png", 2, true),
+ Entry("disc01.jpg", "disc*.*", "disc01.jpg", 1, true),
+ Entry("disc02.png", "disc*.*", "disc02.png", 2, true),
+ Entry("disc10.jpg", "disc*.*", "disc10.jpg", 10, true),
+
+ // CD patterns
+ Entry("cd1.jpg", "cd*.*", "cd1.jpg", 1, true),
+ Entry("cd02.png", "cd*.*", "cd02.png", 2, true),
+
+ // No number in filename
+ Entry("disc.jpg has no number", "disc*.*", "disc.jpg", 0, false),
+ Entry("cd.jpg has no number", "cd*.*", "cd.jpg", 0, false),
+
+ // Extra text after number
+ Entry("disc2-bonus.jpg", "disc*.*", "disc2-bonus.jpg", 2, true),
+ Entry("disc01_front.png", "disc*.*", "disc01_front.png", 1, true),
+
+ // Case insensitive (filename already lowered by caller)
+ Entry("Disc1.jpg lowered", "disc*.*", "disc1.jpg", 1, true),
+
+ // HasPrefix guard: filename doesn't share the pattern's literal prefix
+ Entry("cover.jpg with disc*.* (no prefix match)", "disc*.*", "cover.jpg", 0, false),
+
+ // Pattern with no wildcard before dot
+ Entry("front1.jpg with front*.*", "front*.*", "front1.jpg", 1, true),
+
+ // '?' single-char wildcard
+ Entry("disc?.jpg with disc1.jpg", "disc?.jpg", "disc1.jpg", 1, true),
+ Entry("disc?.jpg with disc2.jpg", "disc?.jpg", "disc2.jpg", 2, true),
+ Entry("cd??.jpg with cd07.jpg", "cd??.jpg", "cd07.jpg", 7, true),
+
+ // '[...]' character class wildcard
+ Entry("cd[12].jpg with cd1.jpg", "cd[12].jpg", "cd1.jpg", 1, true),
+ Entry("cd[12].jpg with cd2.jpg", "cd[12].jpg", "cd2.jpg", 2, true),
+ Entry("disc[0-9].jpg with disc5.jpg", "disc[0-9].jpg", "disc5.jpg", 5, true),
+
+ // Literal pattern (no wildcard) returns false
+ Entry("shellac.png literal", "shellac.png", "shellac.png", 0, false),
+ )
+ })
+
+ Describe("fromExternalFile", func() {
+ var (
+ ctx context.Context
+ tmpDir string
+ )
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ tmpDir = GinkgoT().TempDir()
+ })
+
+ // createFile creates the file on disk and returns its library-relative forward-slash path.
+ createFile := func(relPath string) string {
+ fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
+ Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
+ Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
+ return relPath
+ }
+
+ // removeFile removes a library-relative file from disk.
+ removeFile := func(relPath string) {
+ Expect(os.Remove(filepath.Join(tmpDir, filepath.FromSlash(relPath)))).To(Succeed())
+ }
+
+ It("matches file with disc number in single-folder album", func() {
+ f1 := createFile("album/disc1.jpg")
+ f2 := createFile("album/disc2.jpg")
+ reader := &discArtworkReader{
+ discNumber: 1,
+ imgFiles: []string{f1, f2},
+ discFoldersRel: map[string]bool{"album": true},
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ sf := reader.fromExternalFile(ctx, "disc*.*")
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ Expect(path).To(Equal(f1))
+ })
+
+ It("matches file without number in single-folder album (shared disc art)", func() {
+ f1 := createFile("album/cover.png")
+ reader := &discArtworkReader{
+ discNumber: 1,
+ imgFiles: []string{f1},
+ discFoldersRel: map[string]bool{"album": true},
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ sf := reader.fromExternalFile(ctx, "cover.*")
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ Expect(path).To(Equal(f1))
+ })
+
+ It("returns shared disc art for every disc number in single-folder album", func() {
+ f1 := createFile("album/shellac.png")
+ makeReader := func(discNum int) *discArtworkReader {
+ return &discArtworkReader{
+ discNumber: discNum,
+ imgFiles: []string{f1},
+ discFoldersRel: map[string]bool{"album": true},
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+ }
+
+ for _, disc := range []int{1, 2, 5} {
+ sf := makeReader(disc).fromExternalFile(ctx, "shellac.png")
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred(), "disc %d", disc)
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ Expect(path).To(Equal(f1), "disc %d", disc)
+ }
+ })
+
+ It("numbered and unnumbered patterns both resolve against the same reader", func() {
+ f1 := createFile("album/cover.png")
+ f2 := createFile("album/disc1.jpg")
+ f3 := createFile("album/disc2.jpg")
+ reader := &discArtworkReader{
+ discNumber: 2,
+ imgFiles: []string{f1, f2, f3},
+ discFoldersRel: map[string]bool{"album": true},
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ sf := reader.fromExternalFile(ctx, "disc*.*")
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ Expect(path).To(Equal(f3))
+
+ sf = reader.fromExternalFile(ctx, "cover.*")
+ r, path, err = sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ Expect(path).To(Equal(f1))
+ })
+
+ It("respects DiscArtPriority order when both numbered and unnumbered patterns match", func() {
+ f1 := createFile("album/cover.png")
+ f2 := createFile("album/disc1.jpg")
+ reader := &discArtworkReader{
+ discNumber: 1,
+ imgFiles: []string{f1, f2},
+ discFoldersRel: map[string]bool{"album": true},
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*")
+ Expect(ff).To(HaveLen(2))
+ r, path, err := ff[0]()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(path).To(Equal(f2))
+ r.Close()
+
+ ff = reader.fromDiscArtPriority(ctx, nil, "cover.*, disc*.*")
+ Expect(ff).To(HaveLen(2))
+ r, path, err = ff[0]()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(path).To(Equal(f1))
+ r.Close()
+ })
+
+ DescribeTable("numbered match wins over shared fallback within a pattern",
+ func(discNumber, expectedIdx int) {
+ files := []string{
+ createFile("album/disc.jpg"),
+ createFile("album/disc1.jpg"),
+ createFile("album/disc2.jpg"),
+ }
+ reader := &discArtworkReader{
+ discNumber: discNumber,
+ imgFiles: files,
+ discFoldersRel: map[string]bool{"album": true},
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ sf := reader.fromExternalFile(ctx, "disc*.*")
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ Expect(path).To(Equal(files[expectedIdx]))
+ },
+ Entry("disc 2 picks disc2.jpg over the shared disc.jpg", 2, 2),
+ Entry("disc 3 falls back to disc.jpg when no numbered match exists", 3, 0),
+ )
+
+ It("tries the next fallback candidate when the first one cannot be opened", func() {
+ f1 := createFile("album/cover.jpg")
+ f2 := createFile("album/cover.png")
+ // Remove f1 so Open will fail on it; f2 should still win.
+ removeFile(f1)
+ reader := &discArtworkReader{
+ discNumber: 1,
+ imgFiles: []string{f1, f2},
+ discFoldersRel: map[string]bool{"album": true},
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ sf := reader.fromExternalFile(ctx, "cover.*")
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ Expect(path).To(Equal(f2))
+ })
+
+ It("keeps scanning literal-pattern matches so fallback retry still works", func() {
+ // Guards against an 'early break on first literal match' optimization.
+ // Multiple imgFiles entries can share a basename (symlinks, case-variant
+ // duplicates on case-sensitive filesystems). If the loop breaks after
+ // recording just the first, the fallback retry cannot recover when
+ // that first file is unreadable.
+ f1 := createFile("album/stale/cover.png")
+ f2 := createFile("album/cover.png")
+ removeFile(f1)
+ reader := &discArtworkReader{
+ discNumber: 1,
+ imgFiles: []string{f1, f2},
+ discFoldersRel: map[string]bool{
+ "album": true,
+ "album/stale": true,
+ },
+ isMultiFolder: true,
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ sf := reader.fromExternalFile(ctx, "cover.png")
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ Expect(path).To(Equal(f2))
+ })
+
+ DescribeTable("filters by disc number for non-'*' wildcard patterns",
+ func(pattern string, discNumber, expectedIdx int) {
+ files := []string{
+ createFile("album/disc1.jpg"),
+ createFile("album/disc2.jpg"),
+ }
+ reader := &discArtworkReader{
+ discNumber: discNumber,
+ imgFiles: files,
+ discFoldersRel: map[string]bool{"album": true},
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ sf := reader.fromExternalFile(ctx, pattern)
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ Expect(path).To(Equal(files[expectedIdx]))
+ },
+ Entry("disc?.jpg, target disc 1 → disc1.jpg", "disc?.jpg", 1, 0),
+ Entry("disc?.jpg, target disc 2 → disc2.jpg", "disc?.jpg", 2, 1),
+ Entry("disc[0-9].jpg, target disc 1 → disc1.jpg", "disc[0-9].jpg", 1, 0),
+ Entry("disc[0-9].jpg, target disc 2 → disc2.jpg", "disc[0-9].jpg", 2, 1),
+ )
+
+ It("matches file without number in multi-folder album by folder", func() {
+ f1 := createFile("album/cd1/disc.jpg")
+ f2 := createFile("album/cd2/disc.jpg")
+ reader := &discArtworkReader{
+ discNumber: 1,
+ imgFiles: []string{f1, f2},
+ discFoldersRel: map[string]bool{"album/cd1": true},
+ isMultiFolder: true,
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ sf := reader.fromExternalFile(ctx, "disc*.*")
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ Expect(path).To(Equal(f1))
+ })
+
+ It("prefers disc number over folder when number is present", func() {
+ // disc2.jpg in cd1 folder should match disc 2, not disc 1
+ f1 := createFile("album/cd1/disc2.jpg")
+ reader := &discArtworkReader{
+ discNumber: 2,
+ imgFiles: []string{f1},
+ discFoldersRel: map[string]bool{"album/cd1": true},
+ isMultiFolder: true,
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ sf := reader.fromExternalFile(ctx, "disc*.*")
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ Expect(path).To(Equal(f1))
+ })
+
+ It("does not match disc2.jpg when looking for disc 1", func() {
+ f1 := createFile("album/disc2.jpg")
+ reader := &discArtworkReader{
+ discNumber: 1,
+ imgFiles: []string{f1},
+ discFoldersRel: map[string]bool{"album": true},
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ sf := reader.fromExternalFile(ctx, "disc*.*")
+ r, _, _ := sf()
+ Expect(r).To(BeNil())
+ })
+ })
+
+ Describe("fromDiscSubtitle", func() {
+ var (
+ ctx context.Context
+ tmpDir string
+ )
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ tmpDir = GinkgoT().TempDir()
+ })
+
+ createFile := func(relPath string) string {
+ fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
+ Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
+ Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
+ return relPath
+ }
+
+ It("matches image file whose stem equals the disc subtitle (case-insensitive)", func() {
+ f1 := createFile("album/The Blue Disc.jpg")
+ reader := &discArtworkReader{
+ discNumber: 1,
+ imgFiles: []string{f1},
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ Expect(path).To(Equal(f1))
+ })
+
+ It("matches case-insensitively", func() {
+ f1 := createFile("album/bonus tracks.png")
+ reader := &discArtworkReader{
+ discNumber: 2,
+ imgFiles: []string{f1},
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ sf := reader.fromDiscSubtitle(ctx, "Bonus Tracks")
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ Expect(path).To(Equal(f1))
+ })
+
+ It("returns error when no matching file found", func() {
+ f1 := createFile("album/cover.jpg")
+ reader := &discArtworkReader{
+ discNumber: 1,
+ imgFiles: []string{f1},
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
+ _, _, err := sf()
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("matches first file when multiple extensions exist", func() {
+ f1 := createFile("album/The Blue Disc.jpg")
+ f2 := createFile("album/The Blue Disc.png")
+ reader := &discArtworkReader{
+ discNumber: 1,
+ imgFiles: []string{f1, f2},
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+
+ sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ Expect(path).To(Equal(f1))
+ })
+ })
+
+ Describe("discArtworkReader", func() {
+ Describe("fromDiscArtPriority", func() {
+ var (
+ reader *discArtworkReader
+ tmpDir string
+ )
+
+ BeforeEach(func() {
+ tmpDir = GinkgoT().TempDir()
+ reader = &discArtworkReader{
+ discNumber: 2,
+ isMultiFolder: true,
+ discFoldersRel: map[string]bool{"music/album/cd2": true},
+ imgFiles: []string{
+ "music/album/cd1/disc.jpg",
+ "music/album/cd2/disc.jpg",
+ "music/album/cd2/disc2.jpg",
+ },
+ firstTrackRel: "music/album/cd2/track1.flac",
+ lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
+ }
+ })
+
+ It("returns source funcs for glob patterns", func() {
+ ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
+ Expect(ff).To(HaveLen(1))
+ })
+
+ It("returns source funcs for embedded pattern", func() {
+ ff := reader.fromDiscArtPriority(context.Background(), nil, "embedded")
+ Expect(ff).To(HaveLen(2)) // fromTag + fromFFmpegTag
+ })
+
+ It("handles multiple comma-separated patterns", func() {
+ ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*, cd*.*, embedded")
+ Expect(ff).To(HaveLen(4)) // disc*.* + cd*.* + fromTag + fromFFmpegTag
+ })
+
+ It("ignores 'external' pattern silently", func() {
+ ff := reader.fromDiscArtPriority(context.Background(), nil, "external")
+ Expect(ff).To(HaveLen(0))
+ })
+
+ It("returns no source funcs when imgFiles is empty and pattern is not embedded", func() {
+ reader.imgFiles = nil
+ ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
+ Expect(ff).To(HaveLen(0))
+ })
+
+ It("returns source func for discsubtitle pattern", func() {
+ reader.album = model.Album{Discs: model.Discs{2: "Bonus Tracks"}}
+ ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
+ Expect(ff).To(HaveLen(1))
+ })
+
+ It("returns no source func for discsubtitle when disc has no subtitle", func() {
+ reader.album = model.Album{Discs: model.Discs{2: ""}}
+ ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
+ Expect(ff).To(HaveLen(0))
+ })
+ })
+ })
+})
diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go
index c72d9543d..eac3c5e70 100644
--- a/core/artwork/reader_mediafile.go
+++ b/core/artwork/reader_mediafile.go
@@ -15,6 +15,7 @@ type mediafileArtworkReader struct {
a *artwork
mediafile model.MediaFile
album model.Album
+ lib libraryView
}
func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) {
@@ -26,16 +27,27 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode
if err != nil {
return nil, err
}
+ _, _, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al)
+ if err != nil {
+ return nil, err
+ }
+ lib, err := loadLibraryView(ctx, artwork.ds, mf.LibraryID)
+ if err != nil {
+ return nil, err
+ }
a := &mediafileArtworkReader{
a: artwork,
mediafile: *mf,
album: *al,
+ lib: lib,
}
a.cacheKey.artID = artID
- if al.UpdatedAt.After(mf.UpdatedAt) {
+ a.cacheKey.lastUpdate = mf.UpdatedAt
+ if al.UpdatedAt.After(a.cacheKey.lastUpdate) {
a.cacheKey.lastUpdate = al.UpdatedAt
- } else {
- a.cacheKey.lastUpdate = mf.UpdatedAt
+ }
+ if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) {
+ a.cacheKey.lastUpdate = *imagesUpdatedAt
}
return a, nil
}
@@ -54,12 +66,17 @@ func (a *mediafileArtworkReader) LastUpdated() time.Time {
func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
var ff []sourceFunc
if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork {
- path := a.mediafile.AbsolutePath()
ff = []sourceFunc{
- fromTag(ctx, path),
- fromFFmpegTag(ctx, a.a.ffmpeg, path),
+ fromTag(ctx, a.lib.FS, a.mediafile.Path),
+ fromFFmpegTag(ctx, a.a.ffmpeg, a.lib.Abs(a.mediafile.Path)),
}
}
- ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID()))
+ // For multi-disc albums, fall back to disc artwork first; for single-disc albums,
+ // skip disc resolution (it would just fall through to album art anyway).
+ if len(a.album.Discs) > 1 {
+ ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.DiscCoverArtID()))
+ } else {
+ ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID()))
+ }
return selectImageReader(ctx, a.artID, ff...)
}
diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go
index a9f289ad8..09707843d 100644
--- a/core/artwork/reader_playlist.go
+++ b/core/artwork/reader_playlist.go
@@ -8,12 +8,17 @@ import (
"image/draw"
"image/png"
"io"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
"time"
- "github.com/disintegration/imaging"
+ "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
+ xdraw "golang.org/x/image/draw"
)
type playlistArtworkReader struct {
@@ -35,6 +40,24 @@ func newPlaylistArtworkReader(ctx context.Context, artwork *artwork, artID model
}
a.cacheKey.artID = artID
a.cacheKey.lastUpdate = pl.UpdatedAt
+
+ // Check sidecar and ExternalImageURL local file ModTimes for cache invalidation.
+ // If either is newer than the playlist's UpdatedAt, use that instead so the
+ // cache is busted when a user replaces a sidecar image or local file reference.
+ for _, path := range []string{
+ findPlaylistSidecarPath(ctx, pl.Path),
+ pl.ExternalImageURL,
+ } {
+ if path == "" || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
+ continue
+ }
+ if info, err := os.Stat(path); err == nil {
+ if info.ModTime().After(a.cacheKey.lastUpdate) {
+ a.cacheKey.lastUpdate = info.ModTime()
+ }
+ }
+ }
+
return a, nil
}
@@ -43,11 +66,81 @@ func (a *playlistArtworkReader) LastUpdated() time.Time {
}
func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
- ff := []sourceFunc{
+ return selectImageReader(ctx, a.artID,
+ a.fromPlaylistUploadedImage(),
+ a.fromPlaylistSidecar(ctx),
+ a.fromPlaylistExternalImage(ctx),
a.fromGeneratedTiledCover(ctx),
fromAlbumPlaceholder(),
+ )
+}
+
+func (a *playlistArtworkReader) fromPlaylistUploadedImage() sourceFunc {
+ return fromLocalFile(a.pl.UploadedImagePath())
+}
+
+func (a *playlistArtworkReader) fromPlaylistSidecar(ctx context.Context) sourceFunc {
+ return fromLocalFile(findPlaylistSidecarPath(ctx, a.pl.Path))
+}
+
+func (a *playlistArtworkReader) fromPlaylistExternalImage(ctx context.Context) sourceFunc {
+ return func() (io.ReadCloser, string, error) {
+ imgURL := a.pl.ExternalImageURL
+ if imgURL == "" {
+ return nil, "", nil
+ }
+ parsed, err := url.Parse(imgURL)
+ if err != nil {
+ return nil, "", err
+ }
+ if parsed.Scheme == "http" || parsed.Scheme == "https" {
+ if !conf.Server.EnableM3UExternalAlbumArt {
+ return nil, "", nil
+ }
+ return fromURL(ctx, parsed)
+ }
+ return fromLocalFile(imgURL)()
}
- return selectImageReader(ctx, a.artID, ff...)
+}
+
+// fromLocalFile returns a sourceFunc that opens the given local path.
+// Returns (nil, "", nil) if path is empty — signalling "not found, try next source".
+func fromLocalFile(path string) sourceFunc {
+ return func() (io.ReadCloser, string, error) {
+ if path == "" {
+ return nil, "", nil
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, "", err
+ }
+ return f, path, nil
+ }
+}
+
+// findPlaylistSidecarPath scans the directory of the playlist file for a sidecar
+// image file with the same base name (case-insensitive). Returns empty string if
+// no matching image is found or if plsPath is empty.
+func findPlaylistSidecarPath(ctx context.Context, plsPath string) string {
+ if plsPath == "" {
+ return ""
+ }
+ dir := filepath.Dir(plsPath)
+ base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath))
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ log.Warn(ctx, "Could not read directory for playlist sidecar", "dir", dir, err)
+ return ""
+ }
+ for _, entry := range entries {
+ name := entry.Name()
+ nameBase := strings.TrimSuffix(name, filepath.Ext(name))
+ if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) {
+ return filepath.Join(dir, name)
+ }
+ }
+ return ""
}
func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc {
@@ -107,7 +200,7 @@ func (a *playlistArtworkReader) createTile(_ context.Context, r io.ReadCloser) (
if err != nil {
return nil, err
}
- return imaging.Fill(img, tileSize/2, tileSize/2, imaging.Center, imaging.Lanczos), nil
+ return fillCenter(img, tileSize/2, tileSize/2), nil
}
func (a *playlistArtworkReader) createTiledImage(_ context.Context, tiles []image.Image) (io.ReadCloser, error) {
@@ -145,3 +238,32 @@ func rect(pos int) image.Rectangle {
r.Max.Y = r.Min.Y + tileSize/2
return r
}
+
+// fillCenter crops the source image from the center and scales it to fill dstW x dstH exactly,
+// equivalent to imaging.Fill with Center anchor.
+func fillCenter(src image.Image, dstW, dstH int) image.Image {
+ srcBounds := src.Bounds()
+ srcW := srcBounds.Dx()
+ srcH := srcBounds.Dy()
+
+ // Calculate crop rectangle (center crop to match destination aspect ratio)
+ srcAspect := float64(srcW) / float64(srcH)
+ dstAspect := float64(dstW) / float64(dstH)
+
+ var cropRect image.Rectangle
+ if srcAspect > dstAspect {
+ // Source is wider — crop horizontally
+ cropW := int(float64(srcH) * dstAspect)
+ cropX := (srcW - cropW) / 2
+ cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y)
+ } else {
+ // Source is taller — crop vertically
+ cropH := int(float64(srcW) / dstAspect)
+ cropY := (srcH - cropH) / 2
+ cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH)
+ }
+
+ dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
+ xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil)
+ return dst
+}
diff --git a/core/artwork/reader_radio.go b/core/artwork/reader_radio.go
new file mode 100644
index 000000000..22db6e302
--- /dev/null
+++ b/core/artwork/reader_radio.go
@@ -0,0 +1,40 @@
+package artwork
+
+import (
+ "context"
+ "io"
+ "time"
+
+ "github.com/navidrome/navidrome/model"
+)
+
+type radioArtworkReader struct {
+ cacheKey
+ a *artwork
+ radio model.Radio
+}
+
+func newRadioArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*radioArtworkReader, error) {
+ r, err := artwork.ds.Radio(ctx).Get(artID.ID)
+ if err != nil {
+ return nil, err
+ }
+ a := &radioArtworkReader{a: artwork, radio: *r}
+ a.cacheKey.artID = artID
+ a.cacheKey.lastUpdate = r.UpdatedAt
+ return a, nil
+}
+
+func (a *radioArtworkReader) LastUpdated() time.Time {
+ return a.lastUpdate
+}
+
+func (a *radioArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
+ return selectImageReader(ctx, a.artID,
+ a.fromRadioUploadedImage(),
+ )
+}
+
+func (a *radioArtworkReader) fromRadioUploadedImage() sourceFunc {
+ return fromLocalFile(a.radio.UploadedImagePath())
+}
diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go
new file mode 100644
index 000000000..37ce1d827
--- /dev/null
+++ b/core/artwork/reader_radio_test.go
@@ -0,0 +1,84 @@
+package artwork
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/model"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("radioArtworkReader", func() {
+ var (
+ tempDir string
+ reader *radioArtworkReader
+ )
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ tempDir = GinkgoT().TempDir()
+ conf.Server.DataFolder = conf.NewDir(tempDir)
+
+ Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed())
+
+ reader = &radioArtworkReader{}
+ })
+
+ Describe("fromRadioUploadedImage", func() {
+ When("radio has an uploaded image", func() {
+ It("returns the uploaded image", func() {
+ imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg")
+ Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
+
+ reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
+ sf := reader.fromRadioUploadedImage()
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ Expect(path).To(Equal(imgPath))
+ r.Close()
+ })
+ })
+
+ When("radio has no uploaded image", func() {
+ It("returns nil reader (falls through)", func() {
+ reader.radio = model.Radio{ID: "rd-1"}
+ sf := reader.fromRadioUploadedImage()
+ r, path, err := sf()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).To(BeNil())
+ Expect(path).To(BeEmpty())
+ })
+ })
+ })
+
+ Describe("Reader", func() {
+ When("radio has an uploaded image", func() {
+ It("returns the image reader", func() {
+ imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg")
+ Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
+
+ reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
+ reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"}
+ r, _, err := reader.Reader(context.Background())
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ r.Close()
+ })
+ })
+
+ When("radio has no uploaded image", func() {
+ It("returns ErrUnavailable", func() {
+ reader.radio = model.Radio{ID: "rd-1"}
+ reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"}
+ r, _, err := reader.Reader(context.Background())
+ Expect(err).To(MatchError(ErrUnavailable))
+ Expect(r).To(BeNil())
+ })
+ })
+ })
+})
diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go
index 83e6e25c2..85a19a4c3 100644
--- a/core/artwork/reader_resized.go
+++ b/core/artwork/reader_resized.go
@@ -5,17 +5,36 @@ import (
"context"
"fmt"
"image"
+ "image/draw"
"image/jpeg"
"image/png"
"io"
+ "sync"
"time"
- "github.com/disintegration/imaging"
+ "github.com/gen2brain/webp"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ xdraw "golang.org/x/image/draw"
)
+func init() {
+ conf.AddHook(func() {
+ if err := webp.Dynamic(); err != nil {
+ log.Debug("Using WASM WebP encoder/decoder", "reason", err)
+ } else {
+ log.Debug("Using native libwebp for WebP encoding/decoding")
+ }
+ })
+}
+
+var bufPool = sync.Pool{
+ New: func() any {
+ return new(bytes.Buffer)
+ },
+}
+
type resizedArtworkReader struct {
artID model.ArtworkID
cacheKey string
@@ -46,7 +65,7 @@ func (a *resizedArtworkReader) Key() string {
if a.square {
return baseKey + ".square"
}
- return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverJpegQuality)
+ return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverArtQuality)
}
func (a *resizedArtworkReader) LastUpdated() time.Time {
@@ -61,7 +80,7 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
}
defer orig.Close()
- resized, origSize, err := resizeImage(orig, a.size, a.square)
+ resized, origSize, err := a.resizeImage(ctx, orig)
if resized == nil {
log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square)
} else {
@@ -75,11 +94,40 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
orig, _, err = a.a.Get(ctx, a.artID, 0, false)
return orig, "", err
}
+ // Preserve ReadCloser semantics if the resized reader already supports Close
+ // (e.g., ffmpeg pipe), otherwise wrap with NopCloser
+ if rc, ok := resized.(io.ReadCloser); ok {
+ return rc, fmt.Sprintf("%s@%d", a.artID, a.size), nil
+ }
return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil
}
-func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) {
- original, format, err := image.Decode(reader)
+func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader) (io.Reader, int, error) {
+ data, err := io.ReadAll(reader)
+ if err != nil {
+ return nil, 0, fmt.Errorf("reading image data: %w", err)
+ }
+
+ // Preserve animation for animated images
+ if isAnimatedGIF(data) {
+ if a.a.ffmpeg.IsAvailable() {
+ // Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
+ r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality)
+ if err == nil {
+ return r, 0, nil
+ }
+ log.Warn(ctx, "Could not convert animated GIF, falling back to static", err)
+ }
+ } else if isAnimatedWebP(data) || isAnimatedPNG(data) {
+ // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these)
+ return bytes.NewReader(data), 0, nil
+ }
+
+ return resizeStaticImage(data, a.size, a.square)
+}
+
+func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
+ original, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, 0, err
}
@@ -87,30 +135,52 @@ func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error
bounds := original.Bounds()
originalSize := max(bounds.Max.X, bounds.Max.Y)
+ // Clamp size to original dimensions - upscaling wastes resources and adds no information
+ if size > originalSize {
+ size = originalSize
+ }
+
if originalSize <= size && !square {
return nil, originalSize, nil
}
- var resized image.Image
- if originalSize >= size {
- resized = imaging.Fit(original, size, size, imaging.Lanczos)
- } else {
- if bounds.Max.Y < bounds.Max.X {
- resized = imaging.Resize(original, size, 0, imaging.Lanczos)
- } else {
- resized = imaging.Resize(original, 0, size, imaging.Lanczos)
- }
- }
- if square {
- bg := image.NewRGBA(image.Rect(0, 0, size, size))
- resized = imaging.OverlayCenter(bg, resized, 1)
- }
+ // Calculate aspect-fit dimensions
+ srcW, srcH := bounds.Dx(), bounds.Dy()
+ scale := float64(size) / float64(max(srcW, srcH))
+ dstW := int(float64(srcW) * scale)
+ dstH := int(float64(srcH) * scale)
- buf := new(bytes.Buffer)
- if format == "png" || square {
- err = png.Encode(buf, resized)
+ var dst *image.NRGBA
+ var dstRect image.Rectangle
+ if square {
+ // Square canvas with image centered (transparent padding via zero-initialized NRGBA)
+ dst = image.NewNRGBA(image.Rect(0, 0, size, size))
+ offsetX := (size - dstW) / 2
+ offsetY := (size - dstH) / 2
+ dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH)
} else {
- err = jpeg.Encode(buf, resized, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
+ // Tight-fit canvas
+ dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
+ dstRect = dst.Bounds()
}
- return buf, originalSize, err
+ xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil)
+
+ buf := bufPool.Get().(*bytes.Buffer)
+ buf.Reset()
+ if conf.Server.EnableWebPEncoding {
+ err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
+ } else if format == "png" || square {
+ err = png.Encode(buf, dst)
+ } else {
+ err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
+ }
+ if err != nil {
+ bufPool.Put(buf)
+ return nil, originalSize, err
+ }
+ // Copy bytes before returning buffer to pool (pool may reuse the buffer)
+ encoded := make([]byte, buf.Len())
+ copy(encoded, buf.Bytes())
+ bufPool.Put(buf)
+ return bytes.NewReader(encoded), originalSize, nil
}
diff --git a/core/artwork/reader_resized_test.go b/core/artwork/reader_resized_test.go
new file mode 100644
index 000000000..7c14f5e44
--- /dev/null
+++ b/core/artwork/reader_resized_test.go
@@ -0,0 +1,176 @@
+package artwork
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+
+ "github.com/navidrome/navidrome/core/ffmpeg"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("resizeImage", func() {
+ var mockFF *tests.MockFFmpeg
+ var r *resizedArtworkReader
+
+ BeforeEach(func() {
+ mockFF = tests.NewMockFFmpeg("converted-animated-data")
+ r = &resizedArtworkReader{
+ size: 300,
+ square: false,
+ a: &artwork{ffmpeg: mockFF},
+ }
+ })
+
+ Describe("animated GIF handling", func() {
+ It("converts animated GIF via ffmpeg when available", func() {
+ data := createAnimatedGIF(3)
+ result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).ToNot(BeNil())
+
+ // Should have been processed by ffmpeg (mock returns "converted-animated-data")
+ output, err := io.ReadAll(result)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(output).To(Equal(data)) // MockFFmpeg echoes input back
+ })
+
+ It("falls back to static resize when ffmpeg fails for animated GIF", func() {
+ mockFF.Error = errors.New("ffmpeg failed")
+ // Use size smaller than image so static resize actually produces output
+ r.size = 1
+ data := createAnimatedGIF(3)
+ result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
+ // Should fall through to static resize successfully (no ffmpeg error propagated)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).ToNot(BeNil())
+
+ // Verify it's a static image (WebP encoded), not the ffmpeg error
+ output, err := io.ReadAll(result)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(output)).To(BeNumerically(">", 0))
+ })
+
+ It("preserves animation for square thumbnails with animated GIF", func() {
+ r.square = true
+ data := createAnimatedGIF(3)
+ result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).ToNot(BeNil())
+
+ // Should have been processed by ffmpeg (mock returns input data)
+ output, err := io.ReadAll(result)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(output).To(Equal(data))
+ })
+ })
+
+ Describe("animated WebP handling", func() {
+ It("returns animated WebP data as-is when not square", func() {
+ data := createAnimatedWebPBytes()
+ result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).ToNot(BeNil())
+
+ // Should return original data unchanged
+ output, err := io.ReadAll(result)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(output).To(Equal(data))
+ })
+
+ It("preserves animated WebP for square thumbnails", func() {
+ r.square = true
+ data := createAnimatedWebPBytes()
+ result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).ToNot(BeNil())
+
+ // Should return original data unchanged
+ output, err := io.ReadAll(result)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(output).To(Equal(data))
+ })
+ })
+
+ Describe("animated PNG handling", func() {
+ It("returns animated PNG data as-is when not square", func() {
+ data := createAPNGBytes()
+ result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).ToNot(BeNil())
+
+ // Should return original data unchanged
+ output, err := io.ReadAll(result)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(output).To(Equal(data))
+ })
+
+ It("preserves animated PNG for square thumbnails", func() {
+ r.square = true
+ data := createAPNGBytes()
+ result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).ToNot(BeNil())
+
+ // Should return original data unchanged
+ output, err := io.ReadAll(result)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(output).To(Equal(data))
+ })
+ })
+
+ Describe("static image handling", func() {
+ It("resizes a static PNG normally", func() {
+ data := createStaticPNGBytes()
+ result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
+ // Static PNG is 2x2, size 300 is larger, so should return nil (no upscale)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(BeNil())
+ })
+ })
+
+ Describe("ReadCloser preservation", func() {
+ It("preserves Close semantics from ffmpeg ReadCloser", func() {
+ // Create a trackable ReadCloser
+ tracker := &closeTracker{Reader: bytes.NewReader([]byte("test data"))}
+ mockFF2 := &mockFFmpegWithCloser{tracker: tracker}
+ r.a = &artwork{ffmpeg: mockFF2}
+
+ data := createAnimatedGIF(3)
+ result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
+ Expect(err).ToNot(HaveOccurred())
+
+ // The result should be an io.ReadCloser (the tracker)
+ rc, ok := result.(io.ReadCloser)
+ Expect(ok).To(BeTrue())
+ Expect(rc.Close()).ToNot(HaveOccurred())
+ Expect(tracker.closed).To(BeTrue())
+ })
+ })
+})
+
+// closeTracker is an io.ReadCloser that tracks whether Close was called.
+type closeTracker struct {
+ io.Reader
+ closed bool
+}
+
+func (c *closeTracker) Close() error {
+ c.closed = true
+ return nil
+}
+
+// mockFFmpegWithCloser is a minimal FFmpeg mock that returns a specific ReadCloser
+// for ConvertAnimatedImage, allowing us to verify Close propagation.
+type mockFFmpegWithCloser struct {
+ ffmpeg.FFmpeg
+ tracker *closeTracker
+}
+
+func (m *mockFFmpegWithCloser) IsAvailable() bool { return true }
+func (m *mockFFmpegWithCloser) ConvertAnimatedImage(_ context.Context, _ io.Reader, _ int, _ int) (io.ReadCloser, error) {
+ return m.tracker, nil
+}
diff --git a/core/artwork/sources.go b/core/artwork/sources.go
index 121e6c38b..04a9257fb 100644
--- a/core/artwork/sources.go
+++ b/core/artwork/sources.go
@@ -5,9 +5,9 @@ import (
"context"
"fmt"
"io"
+ "io/fs"
"net/http"
"net/url"
- "os"
"path/filepath"
"reflect"
"regexp"
@@ -15,13 +15,13 @@ import (
"strings"
"time"
- "github.com/dhowden/tag"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
+ "go.senan.xyz/taglib"
)
func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) {
@@ -53,7 +53,7 @@ func (f sourceFunc) String() string {
return name
}
-func fromExternalFile(ctx context.Context, files []string, pattern string) sourceFunc {
+func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc {
return func() (io.ReadCloser, string, error) {
for _, file := range files {
_, name := filepath.Split(file)
@@ -65,12 +65,12 @@ func fromExternalFile(ctx context.Context, files []string, pattern string) sourc
if !match {
continue
}
- f, err := os.Open(file)
+ f, err := libFS.Open(file)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", file, err)
continue
}
- return f, file, err
+ return f, file, nil
}
return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files)
}
@@ -83,51 +83,66 @@ var picTypeRegexes = []*regexp.Regexp{
regexp.MustCompile(`(?i).*cover.*`),
}
-func fromTag(ctx context.Context, path string) sourceFunc {
+func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc {
return func() (io.ReadCloser, string, error) {
- if path == "" {
+ if relPath == "" {
return nil, "", nil
}
- f, err := os.Open(path)
+ f, err := libFS.Open(relPath)
if err != nil {
return nil, "", err
}
+ rs, ok := f.(io.ReadSeeker)
+ if !ok {
+ f.Close()
+ return nil, "", fmt.Errorf("FS file %s is not seekable; cannot read tags", relPath)
+ }
+ tf, err := taglib.OpenStream(rs,
+ taglib.WithReadStyle(taglib.ReadStyleFast),
+ taglib.WithFilename(relPath),
+ )
+ if err != nil {
+ f.Close()
+ return nil, "", err
+ }
+ // Close in LIFO order: tf first (it holds rs internally), then f.
defer f.Close()
+ defer tf.Close()
- m, err := tag.ReadFrom(f)
- if err != nil {
- return nil, "", err
+ images := tf.Properties().Images
+ if len(images) == 0 {
+ return nil, "", fmt.Errorf("no embedded image found in %s", relPath)
}
- types := m.PictureTypes()
- if len(types) == 0 {
- return nil, "", fmt.Errorf("no embedded image found in %s", path)
+ imageIndex := findBestImageIndex(ctx, images, relPath)
+ data, err := tf.Image(imageIndex)
+ if err != nil || len(data) == 0 {
+ return nil, "", fmt.Errorf("could not load embedded image from %s", relPath)
}
-
- var picture *tag.Picture
- for _, regex := range picTypeRegexes {
- for _, t := range types {
- if regex.MatchString(t) {
- log.Trace(ctx, "Found embedded image", "type", t, "path", path)
- picture = m.Pictures(t)
- break
- }
- }
- if picture != nil {
- break
- }
- }
- if picture == nil {
- log.Trace(ctx, "Could not find a front image. Getting the first one", "type", types[0], "path", path)
- picture = m.Picture()
- }
- if picture == nil {
- return nil, "", fmt.Errorf("could not load embedded image from %s", path)
- }
- return io.NopCloser(bytes.NewReader(picture.Data)), path, nil
+ return io.NopCloser(bytes.NewReader(data)), relPath, nil
}
}
+func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path string) int {
+ for _, regex := range picTypeRegexes {
+ for i, img := range images {
+ if regex.MatchString(img.Type) {
+ log.Trace(ctx, "Found embedded image", "type", img.Type, "path", path)
+ return i
+ }
+ }
+ }
+ log.Trace(ctx, "Could not find a front image. Getting the first one", "type", images[0].Type, "path", path)
+ return 0
+}
+
+// fromFFmpegTag is intentionally absolute-path-based. ffmpeg is a subprocess
+// and cannot read from arbitrary fs.FS implementations; piping via stdin is a
+// non-trivial refactor with stream/seek implications.
+//
+// TODO(artwork-musicfs): when the storage backing the library is not local
+// (e.g. a future S3 backend, or FakeFS in tests), short-circuit this source
+// func to return (nil, "", nil) so callers fall through cleanly.
func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {
@@ -137,10 +152,25 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
if err != nil {
return nil, "", err
}
- return r, path, nil
+ // Validate that the stream actually contains image data by reading the first byte.
+ // ffmpeg.ExtractImage returns a pipe reader that may fail asynchronously if the
+ // file has no video/image stream (e.g., an MP3 without embedded art).
+ buf := make([]byte, 1)
+ n, err := r.Read(buf)
+ if n == 0 || err != nil {
+ r.Close()
+ return nil, "", fmt.Errorf("ffmpeg produced no image data for %s: %w", path, err)
+ }
+ return readCloser{Reader: io.MultiReader(bytes.NewReader(buf[:n]), r), Closer: r}, path, nil
}
}
+// readCloser combines a Reader and a Closer into an io.ReadCloser.
+type readCloser struct {
+ io.Reader
+ io.Closer
+}
+
func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc {
return func() (io.ReadCloser, string, error) {
r, _, err := a.Get(ctx, id, 0, false)
@@ -182,13 +212,14 @@ func fromAlbumExternalSource(ctx context.Context, al model.Album, provider exter
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
hc := http.Client{Timeout: 5 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
- resp, err := hc.Do(req)
+ req.Header.Set("User-Agent", consts.HTTPUserAgent)
+ resp, err := hc.Do(req) //nolint:gosec
if err != nil {
return nil, "", err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
- return nil, "", fmt.Errorf("error retrieveing artwork from %s: %s", imageUrl, resp.Status)
+ return nil, "", fmt.Errorf("error retrieving artwork from %s: %s", imageUrl, resp.Status)
}
return resp.Body, imageUrl.String(), nil
}
diff --git a/core/artwork/sources_internal_test.go b/core/artwork/sources_internal_test.go
new file mode 100644
index 000000000..4282575a5
--- /dev/null
+++ b/core/artwork/sources_internal_test.go
@@ -0,0 +1,92 @@
+package artwork
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "io/fs"
+ "os"
+ "testing/fstest"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("fromExternalFile", func() {
+ It("opens a matching file via the library FS", func() {
+ fsys := fstest.MapFS{
+ "Artist/Album/cover.jpg": &fstest.MapFile{Data: []byte("cover-bytes")},
+ }
+ f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/cover.jpg"}, "cover.*")
+ r, path, err := f()
+ Expect(err).ToNot(HaveOccurred())
+ defer r.Close()
+ b, _ := io.ReadAll(r)
+ Expect(b).To(Equal([]byte("cover-bytes")))
+ Expect(path).To(Equal("Artist/Album/cover.jpg"))
+ })
+
+ It("returns an error when no file matches", func() {
+ fsys := fstest.MapFS{
+ "Artist/Album/something.txt": &fstest.MapFile{Data: []byte("x")},
+ }
+ f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/something.txt"}, "cover.*")
+ _, _, err := f()
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("skips files that fail to open and tries the next match", func() {
+ fsys := fstest.MapFS{
+ "a/cover.jpg": &fstest.MapFile{Data: []byte("a")},
+ }
+ // "missing/cover.jpg" is in candidates but not in the FS — should be skipped.
+ f := fromExternalFile(GinkgoT().Context(), fsys, []string{"missing/cover.jpg", "a/cover.jpg"}, "cover.*")
+ r, path, err := f()
+ Expect(err).ToNot(HaveOccurred())
+ defer r.Close()
+ b, _ := io.ReadAll(r)
+ Expect(b).To(Equal([]byte("a")))
+ Expect(path).To(Equal("a/cover.jpg"))
+ })
+})
+
+var _ = Describe("fromTag", func() {
+ It("opens an embedded image via fs.FS", func() {
+ fsys := os.DirFS("tests/fixtures/artist/an-album")
+ f := fromTag(GinkgoT().Context(), fsys, "test.mp3")
+ r, path, err := f()
+ Expect(err).ToNot(HaveOccurred())
+ defer r.Close()
+ Expect(path).To(Equal("test.mp3"))
+ b, _ := io.ReadAll(r)
+ Expect(b).ToNot(BeEmpty())
+ })
+
+ It("returns nil reader when the relative path is empty", func() {
+ f := fromTag(GinkgoT().Context(), os.DirFS("."), "")
+ r, _, err := f()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).To(BeNil())
+ })
+
+ It("errors when the FS file is not seekable", func() {
+ fsys := nonSeekableFS{data: []byte("garbage")}
+ f := fromTag(GinkgoT().Context(), fsys, "x.mp3")
+ _, _, err := f()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("not seekable"))
+ })
+})
+
+// nonSeekableFS is a single-file fs.FS whose Open returns a non-seekable file.
+type nonSeekableFS struct{ data []byte }
+
+func (n nonSeekableFS) Open(name string) (fs.File, error) {
+ return &nonSeekableFile{r: bytes.NewReader(n.data)}, nil
+}
+
+type nonSeekableFile struct{ r *bytes.Reader }
+
+func (n *nonSeekableFile) Read(p []byte) (int, error) { return n.r.Read(p) }
+func (n *nonSeekableFile) Close() error { return nil }
+func (n *nonSeekableFile) Stat() (fs.FileInfo, error) { return nil, errors.New("not implemented") }
diff --git a/core/auth/auth.go b/core/auth/auth.go
index fd2b670a4..7b3511bdf 100644
--- a/core/auth/auth.go
+++ b/core/auth/auth.go
@@ -8,7 +8,7 @@ import (
"time"
"github.com/go-chi/jwtauth/v5"
- "github.com/lestrrat-go/jwx/v2/jwt"
+ "github.com/lestrrat-go/jwx/v3/jwt"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
@@ -45,42 +45,30 @@ func Init(ds model.DataStore) {
})
}
-func createBaseClaims() map[string]any {
- tokenClaims := map[string]any{}
- tokenClaims[jwt.IssuerKey] = consts.JWTIssuer
- return tokenClaims
-}
-
-func CreatePublicToken(claims map[string]any) (string, error) {
- tokenClaims := createBaseClaims()
- for k, v := range claims {
- tokenClaims[k] = v
- }
- _, token, err := TokenAuth.Encode(tokenClaims)
-
+func CreatePublicToken(claims Claims) (string, error) {
+ claims.Issuer = consts.JWTIssuer
+ _, token, err := TokenAuth.Encode(claims.ToMap())
return token, err
}
-func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, error) {
- tokenClaims := createBaseClaims()
+func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) {
+ claims.Issuer = consts.JWTIssuer
if !exp.IsZero() {
- tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix()
+ claims.ExpiresAt = exp
}
- for k, v := range claims {
- tokenClaims[k] = v
- }
- _, token, err := TokenAuth.Encode(tokenClaims)
-
+ _, token, err := TokenAuth.Encode(claims.ToMap())
return token, err
}
func CreateToken(u *model.User) (string, error) {
- claims := createBaseClaims()
- claims[jwt.SubjectKey] = u.UserName
- claims[jwt.IssuedAtKey] = time.Now().UTC().Unix()
- claims["uid"] = u.ID
- claims["adm"] = u.IsAdmin
- token, _, err := TokenAuth.Encode(claims)
+ claims := Claims{
+ Issuer: consts.JWTIssuer,
+ Subject: u.UserName,
+ IssuedAt: time.Now(),
+ UserID: u.ID,
+ IsAdmin: u.IsAdmin,
+ }
+ token, _, err := TokenAuth.Encode(claims.ToMap())
if err != nil {
return "", err
}
@@ -89,23 +77,18 @@ func CreateToken(u *model.User) (string, error) {
}
func TouchToken(token jwt.Token) (string, error) {
- claims, err := token.AsMap(context.Background())
- if err != nil {
- return "", err
- }
-
- claims[jwt.ExpirationKey] = time.Now().UTC().Add(conf.Server.SessionTimeout).Unix()
- _, newToken, err := TokenAuth.Encode(claims)
-
+ claims := ClaimsFromToken(token).
+ WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout))
+ _, newToken, err := TokenAuth.Encode(claims.ToMap())
return newToken, err
}
-func Validate(tokenStr string) (map[string]interface{}, error) {
+func Validate(tokenStr string) (Claims, error) {
token, err := jwtauth.VerifyToken(TokenAuth, tokenStr)
if err != nil {
- return nil, err
+ return Claims{}, err
}
- return token.AsMap(context.Background())
+ return ClaimsFromToken(token), nil
}
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
@@ -113,11 +96,11 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
if err != nil {
c, err := ds.User(ctx).CountAll()
if c == 0 && err == nil {
- log.Debug(ctx, "Scanner: No admin user yet!", err)
+ log.Debug(ctx, "No admin user yet!", err)
} else {
- log.Error(ctx, "Scanner: No admin user found!", err)
+ log.Error(ctx, "No admin user found!", err)
}
- u = &model.User{}
+ u = &model.User{IsAdmin: true, UserName: "admin"}
}
ctx = request.WithUsername(ctx, u.UserName)
@@ -137,6 +120,19 @@ func createNewSecret(ctx context.Context, ds model.DataStore) string {
return secret
}
+// EncodeToken creates a signed JWT from an arbitrary claims map.
+// It sets the issuer claim automatically.
+func EncodeToken(claims map[string]any) (string, error) {
+ claims[jwt.IssuerKey] = consts.JWTIssuer
+ _, token, err := TokenAuth.Encode(claims)
+ return token, err
+}
+
+// DecodeAndVerifyToken verifies a JWT string and returns the parsed token.
+func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) {
+ return jwtauth.VerifyToken(TokenAuth, tokenStr)
+}
+
func getEncKey() []byte {
key := cmp.Or(
conf.Server.PasswordEncryptionKey,
diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go
index 504e56a52..3a3585e53 100644
--- a/core/auth/auth_test.go
+++ b/core/auth/auth_test.go
@@ -21,8 +21,7 @@ func TestAuth(t *testing.T) {
}
const (
- testJWTSecret = "not so secret"
- oneDay = 24 * time.Hour
+ oneDay = 24 * time.Hour
)
var _ = BeforeSuite(func() {
@@ -45,7 +44,7 @@ var _ = Describe("Auth", func() {
})
It("returns the claims from a valid JWT token", func() {
- claims := map[string]interface{}{}
+ claims := map[string]any{}
claims["iss"] = "issuer"
claims["iat"] = time.Now().Unix()
claims["exp"] = time.Now().Add(1 * time.Minute).Unix()
@@ -54,11 +53,11 @@ var _ = Describe("Auth", func() {
decodedClaims, err := auth.Validate(tokenStr)
Expect(err).NotTo(HaveOccurred())
- Expect(decodedClaims["iss"]).To(Equal("issuer"))
+ Expect(decodedClaims.Issuer).To(Equal("issuer"))
})
It("returns ErrExpired if the `exp` field is in the past", func() {
- claims := map[string]interface{}{}
+ claims := map[string]any{}
claims["iss"] = "issuer"
claims["exp"] = time.Now().Add(-1 * time.Minute).Unix()
_, tokenStr, err := auth.TokenAuth.Encode(claims)
@@ -82,18 +81,18 @@ var _ = Describe("Auth", func() {
claims, err := auth.Validate(tokenStr)
Expect(err).NotTo(HaveOccurred())
- Expect(claims["iss"]).To(Equal(consts.JWTIssuer))
- Expect(claims["sub"]).To(Equal("johndoe"))
- Expect(claims["uid"]).To(Equal("123"))
- Expect(claims["adm"]).To(Equal(true))
- Expect(claims["exp"]).To(BeTemporally(">", time.Now()))
+ Expect(claims.Issuer).To(Equal(consts.JWTIssuer))
+ Expect(claims.Subject).To(Equal("johndoe"))
+ Expect(claims.UserID).To(Equal("123"))
+ Expect(claims.IsAdmin).To(Equal(true))
+ Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now()))
})
})
Describe("TouchToken", func() {
It("updates the expiration time", func() {
yesterday := time.Now().Add(-oneDay)
- claims := map[string]interface{}{}
+ claims := map[string]any{}
claims["iss"] = "issuer"
claims["exp"] = yesterday.Unix()
token, _, err := auth.TokenAuth.Encode(claims)
@@ -104,8 +103,7 @@ var _ = Describe("Auth", func() {
decodedClaims, err := auth.Validate(touched)
Expect(err).NotTo(HaveOccurred())
- exp := decodedClaims["exp"].(time.Time)
- Expect(exp.Sub(yesterday)).To(BeNumerically(">=", oneDay))
+ Expect(decodedClaims.ExpiresAt.Sub(yesterday)).To(BeNumerically(">=", oneDay))
})
})
})
diff --git a/core/auth/claims.go b/core/auth/claims.go
new file mode 100644
index 000000000..c7e6f02fe
--- /dev/null
+++ b/core/auth/claims.go
@@ -0,0 +1,104 @@
+package auth
+
+import (
+ "time"
+
+ "github.com/lestrrat-go/jwx/v3/jwt"
+)
+
+// Claims represents the typed JWT claims used throughout Navidrome,
+// replacing the untyped map[string]any approach.
+type Claims struct {
+ // Standard JWT claims
+ Issuer string
+ Subject string // username for session tokens
+ IssuedAt time.Time
+ ExpiresAt time.Time
+
+ // Custom claims
+ UserID string // "uid"
+ IsAdmin bool // "adm"
+ ID string // "id" - artwork/mediafile ID
+ Format string // "f" - audio format
+ BitRate int // "b" - audio bitrate
+ ShareID string // "sid" - share ID for share stream tokens
+}
+
+// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode().
+// Only non-zero fields are included.
+func (c Claims) ToMap() map[string]any {
+ m := make(map[string]any)
+ if c.Issuer != "" {
+ m[jwt.IssuerKey] = c.Issuer
+ }
+ if c.Subject != "" {
+ m[jwt.SubjectKey] = c.Subject
+ }
+ if !c.IssuedAt.IsZero() {
+ m[jwt.IssuedAtKey] = c.IssuedAt.UTC().Unix()
+ }
+ if !c.ExpiresAt.IsZero() {
+ m[jwt.ExpirationKey] = c.ExpiresAt.UTC().Unix()
+ }
+ if c.UserID != "" {
+ m["uid"] = c.UserID
+ }
+ if c.IsAdmin {
+ m["adm"] = c.IsAdmin
+ }
+ if c.ID != "" {
+ m["id"] = c.ID
+ }
+ if c.Format != "" {
+ m["f"] = c.Format
+ }
+ if c.BitRate != 0 {
+ m["b"] = c.BitRate
+ }
+ if c.ShareID != "" {
+ m["sid"] = c.ShareID
+ }
+ return m
+}
+
+func (c Claims) WithExpiresAt(t time.Time) Claims {
+ c.ExpiresAt = t
+ return c
+}
+
+// ClaimsFromToken extracts Claims directly from a jwt.Token using token.Get().
+func ClaimsFromToken(token jwt.Token) Claims {
+ var c Claims
+ c.Issuer, _ = token.Issuer()
+ c.Subject, _ = token.Subject()
+ c.IssuedAt, _ = token.IssuedAt()
+ c.ExpiresAt, _ = token.Expiration()
+
+ var uid string
+ if err := token.Get("uid", &uid); err == nil {
+ c.UserID = uid
+ }
+ var adm bool
+ if err := token.Get("adm", &adm); err == nil {
+ c.IsAdmin = adm
+ }
+ var id string
+ if err := token.Get("id", &id); err == nil {
+ c.ID = id
+ }
+ var f string
+ if err := token.Get("f", &f); err == nil {
+ c.Format = f
+ }
+ if err := token.Get("b", &c.BitRate); err != nil {
+ var bf float64
+ if err := token.Get("b", &bf); err == nil {
+ c.BitRate = int(bf)
+ }
+ }
+ var sid string
+ if err := token.Get("sid", &sid); err == nil {
+ c.ShareID = sid
+ }
+ return c
+}
diff --git a/core/auth/claims_test.go b/core/auth/claims_test.go
new file mode 100644
index 000000000..8820fd295
--- /dev/null
+++ b/core/auth/claims_test.go
@@ -0,0 +1,108 @@
+package auth_test
+
+import (
+ "time"
+
+ "github.com/go-chi/jwtauth/v5"
+ "github.com/navidrome/navidrome/core/auth"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Claims", func() {
+ Describe("ToMap", func() {
+ It("includes only non-zero fields", func() {
+ c := auth.Claims{
+ Issuer: "ND",
+ Subject: "johndoe",
+ UserID: "123",
+ IsAdmin: true,
+ }
+ m := c.ToMap()
+ Expect(m).To(HaveKeyWithValue("iss", "ND"))
+ Expect(m).To(HaveKeyWithValue("sub", "johndoe"))
+ Expect(m).To(HaveKeyWithValue("uid", "123"))
+ Expect(m).To(HaveKeyWithValue("adm", true))
+ Expect(m).NotTo(HaveKey("exp"))
+ Expect(m).NotTo(HaveKey("iat"))
+ Expect(m).NotTo(HaveKey("id"))
+ Expect(m).NotTo(HaveKey("f"))
+ Expect(m).NotTo(HaveKey("b"))
+ Expect(m).NotTo(HaveKey("sid"))
+ })
+
+ It("includes expiration and issued-at when set", func() {
+ now := time.Now()
+ c := auth.Claims{
+ IssuedAt: now,
+ ExpiresAt: now.Add(time.Hour),
+ }
+ m := c.ToMap()
+ Expect(m).To(HaveKey("iat"))
+ Expect(m).To(HaveKey("exp"))
+ })
+
+ It("includes custom claims for public tokens", func() {
+ c := auth.Claims{
+ ID: "al-123",
+ Format: "mp3",
+ BitRate: 192,
+ }
+ m := c.ToMap()
+ Expect(m).To(HaveKeyWithValue("id", "al-123"))
+ Expect(m).To(HaveKeyWithValue("f", "mp3"))
+ Expect(m).To(HaveKeyWithValue("b", 192))
+ })
+
+ It("includes share ID claim when set", func() {
+ c := auth.Claims{ShareID: "abc1234567"}
+ m := c.ToMap()
+ Expect(m).To(HaveKeyWithValue("sid", "abc1234567"))
+ })
+ })
+
+ Describe("ClaimsFromToken", func() {
+ It("round-trips session claims through encode/decode", func() {
+ tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
+ now := time.Now().Truncate(time.Second)
+ original := auth.Claims{
+ Issuer: "ND",
+ Subject: "johndoe",
+ UserID: "123",
+ IsAdmin: true,
+ }
+ m := original.ToMap()
+ m["iat"] = now.UTC().Unix()
+ token, _, err := tokenAuth.Encode(m)
+ Expect(err).NotTo(HaveOccurred())
+
+ c := auth.ClaimsFromToken(token)
+ Expect(c.Issuer).To(Equal("ND"))
+ Expect(c.Subject).To(Equal("johndoe"))
+ Expect(c.UserID).To(Equal("123"))
+ Expect(c.IsAdmin).To(BeTrue())
+ Expect(c.IssuedAt.UTC()).To(Equal(now.UTC()))
+ })
+
+ It("round-trips public token claims through encode/decode", func() {
+ tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
+ original := auth.Claims{
+ Issuer: "ND",
+ ID: "al-456",
+ Format: "opus",
+ BitRate: 128,
+ ShareID: "abc1234567",
+ }
+ token, _, err := tokenAuth.Encode(original.ToMap())
+ Expect(err).NotTo(HaveOccurred())
+
+ c := auth.ClaimsFromToken(token)
+ Expect(c.Issuer).To(Equal("ND"))
+ Expect(c.ID).To(Equal("al-456"))
+ Expect(c.ShareID).To(Equal("abc1234567"))
+ Expect(c.Format).To(Equal("opus"))
+ Expect(c.BitRate).To(Equal(128))
+ })
+ })
+
+})
diff --git a/core/common_test.go b/core/common_test.go
index c8dde12d9..0d6e3a299 100644
--- a/core/common_test.go
+++ b/core/common_test.go
@@ -41,6 +41,7 @@ var _ = Describe("common.go", func() {
})
It("returns the absolute path when library exists", func() {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-core)")
ctx := context.Background()
abs := AbsolutePath(ctx, ds, libId, path)
Expect(abs).To(Equal("/library/root/music/file.mp3"))
diff --git a/core/external/extdata_helper_test.go b/core/external/extdata_helper_test.go
index 367437815..8fabf4490 100644
--- a/core/external/extdata_helper_test.go
+++ b/core/external/extdata_helper_test.go
@@ -40,7 +40,7 @@ func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
// GetAll implements model.ArtistRepository.
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
- argsSlice := make([]interface{}, len(options))
+ argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v
}
@@ -92,9 +92,14 @@ func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
return args.Get(0).(*model.MediaFile), args.Error(1)
}
+// GetAllByTags implements model.MediaFileRepository.
+func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
+ return m.GetAll(options...)
+}
+
// GetAll implements model.MediaFileRepository.
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
- argsSlice := make([]interface{}, len(options))
+ argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v
}
@@ -147,7 +152,7 @@ func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
// GetAll implements model.AlbumRepository.
func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) {
- argsSlice := make([]interface{}, len(options))
+ argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v
}
@@ -190,10 +195,13 @@ type mockAgents struct {
topSongsAgent agents.ArtistTopSongsRetriever
similarAgent agents.ArtistSimilarRetriever
imageAgent agents.ArtistImageRetriever
- albumInfoAgent agents.AlbumInfoRetriever
- bioAgent agents.ArtistBiographyRetriever
- mbidAgent agents.ArtistMBIDRetriever
- urlAgent agents.ArtistURLRetriever
+ albumInfoAgent interface {
+ agents.AlbumInfoRetriever
+ agents.AlbumImageRetriever
+ }
+ bioAgent agents.ArtistBiographyRetriever
+ mbidAgent agents.ArtistMBIDRetriever
+ urlAgent agents.ArtistURLRetriever
agents.Interface
}
@@ -268,3 +276,38 @@ func (m *mockAgents) GetArtistImages(ctx context.Context, id, name, mbid string)
}
return nil, args.Error(1)
}
+
+func (m *mockAgents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
+ if m.albumInfoAgent != nil {
+ return m.albumInfoAgent.GetAlbumImages(ctx, name, artist, mbid)
+ }
+ args := m.Called(ctx, name, artist, mbid)
+ if args.Get(0) != nil {
+ return args.Get(0).([]agents.ExternalImage), args.Error(1)
+ }
+ return nil, args.Error(1)
+}
+
+func (m *mockAgents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
+ args := m.Called(ctx, id, name, artist, mbid, count)
+ if args.Get(0) != nil {
+ return args.Get(0).([]agents.Song), args.Error(1)
+ }
+ return nil, args.Error(1)
+}
+
+func (m *mockAgents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
+ args := m.Called(ctx, id, name, artist, mbid, count)
+ if args.Get(0) != nil {
+ return args.Get(0).([]agents.Song), args.Error(1)
+ }
+ return nil, args.Error(1)
+}
+
+func (m *mockAgents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) {
+ args := m.Called(ctx, id, name, mbid, count)
+ if args.Get(0) != nil {
+ return args.Get(0).([]agents.Song), args.Error(1)
+ }
+ return nil, args.Error(1)
+}
diff --git a/core/external/provider.go b/core/external/provider.go
index f27ded11b..74dab4972 100644
--- a/core/external/provider.go
+++ b/core/external/provider.go
@@ -3,6 +3,7 @@ package external
import (
"context"
"errors"
+ "fmt"
"net/url"
"sort"
"strings"
@@ -11,9 +12,7 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
- _ "github.com/navidrome/navidrome/core/agents/lastfm"
- _ "github.com/navidrome/navidrome/core/agents/listenbrainz"
- _ "github.com/navidrome/navidrome/core/agents/spotify"
+ "github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
@@ -43,39 +42,60 @@ type Provider interface {
type provider struct {
ds model.DataStore
ag Agents
+ matcher *matcher.Matcher
artistQueue refreshQueue[auxArtist]
albumQueue refreshQueue[auxAlbum]
}
type auxAlbum struct {
model.Album
- Name string
+}
+
+// Name returns the appropriate album name for external API calls
+// based on the DevPreserveUnicodeInExternalCalls configuration option
+func (a *auxAlbum) Name() string {
+ if conf.Server.DevPreserveUnicodeInExternalCalls {
+ return a.Album.Name
+ }
+ return str.Clear(a.Album.Name)
}
type auxArtist struct {
model.Artist
- Name string
+}
+
+// Name returns the appropriate artist name for external API calls
+// based on the DevPreserveUnicodeInExternalCalls configuration option
+func (a *auxArtist) Name() string {
+ if conf.Server.DevPreserveUnicodeInExternalCalls {
+ return a.Artist.Name
+ }
+ return str.Clear(a.Artist.Name)
}
type Agents interface {
agents.AlbumInfoRetriever
+ agents.AlbumImageRetriever
agents.ArtistBiographyRetriever
agents.ArtistMBIDRetriever
agents.ArtistImageRetriever
agents.ArtistSimilarRetriever
agents.ArtistTopSongsRetriever
agents.ArtistURLRetriever
+ agents.SimilarSongsByTrackRetriever
+ agents.SimilarSongsByAlbumRetriever
+ agents.SimilarSongsByArtistRetriever
}
-func NewProvider(ds model.DataStore, agents Agents) Provider {
- e := &provider{ds: ds, ag: agents}
+func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher) Provider {
+ e := &provider{ds: ds, ag: agents, matcher: m}
e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
return e
}
func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
- var entity interface{}
+ var entity any
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return auxAlbum{}, err
@@ -85,7 +105,6 @@ func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
switch v := entity.(type) {
case *model.Album:
album.Album = *v
- album.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getAlbum(ctx, v.AlbumID)
default:
@@ -103,8 +122,9 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
}
updatedAt := V(album.ExternalInfoUpdatedAt)
+ albumName := album.Name()
if updatedAt.IsZero() {
- log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", album.Name)
+ log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", albumName)
album, err = e.populateAlbumInfo(ctx, album)
if err != nil {
return nil, err
@@ -113,7 +133,7 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
// If info is expired, trigger a populateAlbumInfo in the background
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
- log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
+ log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", albumName)
e.albumQueue.enqueue(&album)
}
@@ -122,42 +142,44 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
start := time.Now()
- info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
+ albumName := album.Name()
+ info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if errors.Is(err, agents.ErrNotFound) {
return album, nil
}
if err != nil {
- log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", album.Name, "artist", album.AlbumArtist,
+ log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", albumName, "artist", album.AlbumArtist,
"elapsed", time.Since(start), err)
return album, err
}
- album.ExternalInfoUpdatedAt = P(time.Now())
+ album.ExternalInfoUpdatedAt = new(time.Now())
album.ExternalUrl = info.URL
if info.Description != "" {
album.Description = info.Description
}
- if len(info.Images) > 0 {
- sort.Slice(info.Images, func(i, j int) bool {
- return info.Images[i].Size > info.Images[j].Size
+ images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
+ if err == nil && len(images) > 0 {
+ sort.Slice(images, func(i, j int) bool {
+ return images[i].Size > images[j].Size
})
- album.LargeImageUrl = info.Images[0].URL
+ album.LargeImageUrl = images[0].URL
- if len(info.Images) >= 2 {
- album.MediumImageUrl = info.Images[1].URL
+ if len(images) >= 2 {
+ album.MediumImageUrl = images[1].URL
}
- if len(info.Images) >= 3 {
- album.SmallImageUrl = info.Images[2].URL
+ if len(images) >= 3 {
+ album.SmallImageUrl = images[2].URL
}
}
err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
if err != nil {
- log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", album.Name,
+ log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
@@ -167,7 +189,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
}
func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) {
- var entity interface{}
+ var entity any
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return auxArtist{}, err
@@ -177,7 +199,6 @@ func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error)
switch v := entity.(type) {
case *model.Artist:
artist.Artist = *v
- artist.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getArtist(ctx, v.ArtistID)
case *model.Album:
@@ -206,8 +227,9 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
// If we don't have any info, retrieves it now
updatedAt := V(artist.ExternalInfoUpdatedAt)
+ artistName := artist.Name()
if updatedAt.IsZero() {
- log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artist.Name)
+ log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artistName)
artist, err = e.populateArtistInfo(ctx, artist)
if err != nil {
return auxArtist{}, err
@@ -216,7 +238,7 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
// If info is expired, trigger a populateArtistInfo in the background
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
- log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
+ log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artistName)
e.artistQueue.enqueue(&artist)
}
return artist, nil
@@ -225,8 +247,9 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
start := time.Now()
// Get MBID first, if it is not yet available
+ artistName := artist.Name()
if artist.MbzArtistID == "" {
- mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artist.Name)
+ mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName)
if mbid != "" && err == nil {
artist.MbzArtistID = mbid
}
@@ -238,18 +261,18 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil })
- g.Go(func() error { e.callGetSimilar(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
+ g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
_ = g.Wait()
if utils.IsCtxDone(ctx) {
- log.Warn(ctx, "ArtistInfo update canceled", "elapsed", "id", artist.ID, "name", artist.Name, time.Since(start), ctx.Err())
+ log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err())
return artist, ctx.Err()
}
- artist.ExternalInfoUpdatedAt = P(time.Now())
+ artist.ExternalInfoUpdatedAt = new(time.Now())
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
if err != nil {
- log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name,
+ log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
@@ -258,12 +281,44 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
}
func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
+ entity, err := model.GetEntityByID(ctx, e.ds, id)
+ if err != nil {
+ return nil, err
+ }
+
+ var songs []agents.Song
+
+ // Try entity-specific similarity first
+ switch v := entity.(type) {
+ case *model.MediaFile:
+ songs, err = e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count)
+ case *model.Album:
+ songs, err = e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count)
+ case *model.Artist:
+ songs, err = e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count)
+ default:
+ log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity))
+ return nil, model.ErrNotFound
+ }
+
+ if err == nil && len(songs) > 0 {
+ return e.matcher.MatchSongs(ctx, songs, count)
+ }
+
+ // Fallback to existing similar artists + top songs algorithm
+ return e.similarSongsFallback(ctx, id, count)
+}
+
+// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to
+// get the artist of the given entity, retrieve similar artists, get their top songs, and pick
+// a weighted random selection of songs to return as similar songs.
+func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) {
artist, err := e.getArtist(ctx, id)
if err != nil {
return nil, err
}
- e.callGetSimilar(ctx, e.ag, &artist, 15, false)
+ e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false)
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
return nil, ctx.Err()
@@ -277,7 +332,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode
}
topCount := max(count, 20)
- topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount)
+ topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount)
if err != nil {
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
return nil
@@ -321,13 +376,25 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error)
return nil, err
}
- e.callGetImage(ctx, e.ag, &artist)
- if utils.IsCtxDone(ctx) {
- log.Warn(ctx, "ArtistImage call canceled", ctx.Err())
- return nil, ctx.Err()
+ imageUrl := artist.ArtistImageUrl()
+ if imageUrl == "" {
+ // No cached URL — must fetch from external source synchronously
+ e.callGetImage(ctx, e.ag, &artist)
+ if utils.IsCtxDone(ctx) {
+ log.Warn(ctx, "ArtistImage call canceled", ctx.Err())
+ return nil, ctx.Err()
+ }
+ imageUrl = artist.ArtistImageUrl()
+ } else {
+ // If cached info is expired, enqueue a background refresh so that config changes
+ // (e.g. disabling an agent) take effect without waiting for a full artist info refresh.
+ updatedAt := V(artist.ExternalInfoUpdatedAt)
+ if !updatedAt.IsZero() && time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
+ log.Debug(ctx, "Artist image info expired, enqueuing background refresh", "artist", artist.Name(), "updatedAt", updatedAt)
+ e.artistQueue.enqueue(&artist)
+ }
}
- imageUrl := artist.ArtistImageUrl()
if imageUrl == "" {
return nil, model.ErrNotFound
}
@@ -340,29 +407,29 @@ func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error)
return nil, err
}
- info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
+ albumName := album.Name()
+ images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if err != nil {
switch {
case errors.Is(err, agents.ErrNotFound):
- log.Trace(ctx, "Album not found in agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
+ log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
return nil, model.ErrNotFound
case errors.Is(err, context.Canceled):
- log.Debug(ctx, "GetAlbumInfo call canceled", err)
+ log.Debug(ctx, "GetAlbumImages call canceled", err)
default:
- log.Warn(ctx, "Error getting album info from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err)
+ log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err)
}
-
return nil, err
}
- if info == nil {
- log.Warn(ctx, "Agent returned nil info without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
+ if len(images) == 0 {
+ log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
return nil, model.ErrNotFound
}
// Return the biggest image
var img agents.ExternalImage
- for _, i := range info.Images {
+ for _, i := range images {
if img.Size <= i.Size {
img = i
}
@@ -398,64 +465,38 @@ func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (
}
func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
- songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count)
+ artistName := artist.Name()
+ songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artistName, artist.MbzArtistID, count)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
+ }
+
+ // Enrich songs with artist info if not already present (for top songs, we know the artist)
+ for i := range songs {
+ if songs[i].Artist == "" {
+ songs[i].Artist = artistName
+ }
+ if songs[i].ArtistMBID == "" {
+ songs[i].ArtistMBID = artist.MbzArtistID
+ }
+ }
+
+ mfs, err := e.matcher.MatchSongs(ctx, songs, count)
if err != nil {
return nil, err
}
- var mfs model.MediaFiles
- for _, t := range songs {
- mf, err := e.findMatchingTrack(ctx, t.MBID, artist.ID, t.Name)
- if err != nil {
- continue
- }
- mfs = append(mfs, *mf)
- if len(mfs) == count {
- break
- }
- }
if len(mfs) == 0 {
- log.Debug(ctx, "No matching top songs found", "name", artist.Name)
+ log.Debug(ctx, "No matching top songs found", "name", artistName)
} else {
- log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs))
+ log.Debug(ctx, "Found matching top songs", "name", artistName, "numSongs", len(mfs))
}
return mfs, nil
}
-func (e *provider) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) {
- if mbid != "" {
- mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
- Filters: squirrel.And{
- squirrel.Eq{"mbz_recording_id": mbid},
- squirrel.Eq{"missing": false},
- },
- })
- if err == nil && len(mfs) > 0 {
- return &mfs[0], nil
- }
- return e.findMatchingTrack(ctx, "", artistID, title)
- }
- mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
- Filters: squirrel.And{
- squirrel.Or{
- squirrel.Eq{"artist_id": artistID},
- squirrel.Eq{"album_artist_id": artistID},
- },
- squirrel.Like{"order_title": str.SanitizeFieldForSorting(title)},
- squirrel.Eq{"missing": false},
- },
- Sort: "starred desc, rating desc, year asc, compilation asc ",
- Max: 1,
- })
- if err != nil || len(mfs) == 0 {
- return nil, model.ErrNotFound
- }
- return &mfs[0], nil
-}
-
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
- artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID)
+ artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
}
@@ -463,7 +504,7 @@ func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriev
}
func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
- bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID)
+ bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
}
@@ -473,7 +514,7 @@ func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiog
}
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
- images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name, artist.MbzArtistID)
+ images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
}
@@ -490,65 +531,182 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet
}
}
-func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
+func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
limit int, includeNotPresent bool) {
- similar, err := agent.GetSimilarArtists(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit)
+ artistName := artist.Name()
+ similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit)
if len(similar) == 0 || err != nil {
return
}
start := time.Now()
- sa, err := e.mapSimilarArtists(ctx, similar, includeNotPresent)
- log.Debug(ctx, "Mapped Similar Artists", "artist", artist.Name, "numSimilar", len(sa), "elapsed", time.Since(start))
+ sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent)
+ log.Debug(ctx, "Mapped Similar Artists", "artist", artistName, "numSimilar", len(sa), "elapsed", time.Since(start))
if err != nil {
return
}
artist.SimilarArtists = sa
}
-func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, includeNotPresent bool) (model.Artists, error) {
+func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, limit int, includeNotPresent bool) (model.Artists, error) {
var result model.Artists
var notPresent []string
- artistNames := slice.Map(similar, func(artist agents.Artist) string { return artist.Name })
-
- // Query all artists at once
- clauses := slice.Map(artistNames, func(name string) squirrel.Sqlizer {
- return squirrel.Like{"artist.name": name}
- })
- artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
- Filters: squirrel.Or(clauses),
- })
+ // Load artists by ID (highest priority)
+ idMatches, err := e.loadArtistsByID(ctx, similar)
if err != nil {
return nil, err
}
- // Create a map for quick lookup
- artistMap := make(map[string]model.Artist)
- for _, artist := range artists {
- artistMap[artist.Name] = artist
+ // Load artists by MBID (second priority)
+ mbidMatches, err := e.loadArtistsByMBID(ctx, similar, idMatches)
+ if err != nil {
+ return nil, err
}
- // Process the similar artists
+ // Load artists by name (lowest priority, fallback)
+ nameMatches, err := e.loadArtistsByName(ctx, similar, idMatches, mbidMatches)
+ if err != nil {
+ return nil, err
+ }
+
+ count := 0
+
+ // Process the similar artists using priority: ID → MBID → Name
for _, s := range similar {
- if artist, found := artistMap[s.Name]; found {
+ if count >= limit {
+ break
+ }
+ // Try ID match first
+ if s.ID != "" {
+ if artist, found := idMatches[s.ID]; found {
+ result = append(result, artist)
+ count++
+ continue
+ }
+ }
+ // Try MBID match second
+ if s.MBID != "" {
+ if artist, found := mbidMatches[s.MBID]; found {
+ result = append(result, artist)
+ count++
+ continue
+ }
+ }
+ // Fall back to name match
+ if artist, found := nameMatches[s.Name]; found {
result = append(result, artist)
+ count++
} else {
notPresent = append(notPresent, s.Name)
}
}
// Then fill up with non-present artists
- if includeNotPresent {
+ if includeNotPresent && count < limit {
for _, s := range notPresent {
// Let the ID empty to indicate that the artist is not present in the DB
sa := model.Artist{Name: s}
result = append(result, sa)
+
+ count++
+ if count >= limit {
+ break
+ }
}
}
return result, nil
}
+func (e *provider) loadArtistsByID(ctx context.Context, similar []agents.Artist) (map[string]model.Artist, error) {
+ var ids []string
+ for _, s := range similar {
+ if s.ID != "" {
+ ids = append(ids, s.ID)
+ }
+ }
+ matches := map[string]model.Artist{}
+ if len(ids) == 0 {
+ return matches, nil
+ }
+ res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"artist.id": ids},
+ })
+ if err != nil {
+ return matches, err
+ }
+ for _, a := range res {
+ if _, ok := matches[a.ID]; !ok {
+ matches[a.ID] = a
+ }
+ }
+ return matches, nil
+}
+
+func (e *provider) loadArtistsByMBID(ctx context.Context, similar []agents.Artist, idMatches map[string]model.Artist) (map[string]model.Artist, error) {
+ var mbids []string
+ for _, s := range similar {
+ // Skip if already matched by ID
+ if s.ID != "" && idMatches[s.ID].ID != "" {
+ continue
+ }
+ if s.MBID != "" {
+ mbids = append(mbids, s.MBID)
+ }
+ }
+ matches := map[string]model.Artist{}
+ if len(mbids) == 0 {
+ return matches, nil
+ }
+ res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"mbz_artist_id": mbids},
+ })
+ if err != nil {
+ return matches, err
+ }
+ for _, a := range res {
+ if id := a.MbzArtistID; id != "" {
+ if _, ok := matches[id]; !ok {
+ matches[id] = a
+ }
+ }
+ }
+ return matches, nil
+}
+
+func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artist, idMatches map[string]model.Artist, mbidMatches map[string]model.Artist) (map[string]model.Artist, error) {
+ var names []string
+ for _, s := range similar {
+ // Skip if already matched by ID or MBID
+ if s.ID != "" && idMatches[s.ID].ID != "" {
+ continue
+ }
+ if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
+ continue
+ }
+ names = append(names, s.Name)
+ }
+ matches := map[string]model.Artist{}
+ if len(names) == 0 {
+ return matches, nil
+ }
+ clauses := slice.Map(names, func(name string) squirrel.Sqlizer {
+ return squirrel.Like{"artist.name": name}
+ })
+ res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Or(clauses),
+ })
+ if err != nil {
+ return matches, err
+ }
+ for _, a := range res {
+ if _, ok := matches[a.Name]; !ok {
+ matches[a.Name] = a
+ }
+ }
+ return matches, nil
+}
+
func (e *provider) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Like{"artist.name": artistName},
@@ -560,11 +718,7 @@ func (e *provider) findArtistByName(ctx context.Context, artistName string) (*au
if len(artists) == 0 {
return nil, model.ErrNotFound
}
- artist := &auxArtist{
- Artist: artists[0],
- Name: str.Clear(artists[0].Name),
- }
- return artist, nil
+ return &auxArtist{Artist: artists[0]}, nil
}
func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
@@ -580,7 +734,7 @@ func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int
Filters: squirrel.Eq{"artist.id": ids},
})
if err != nil {
- log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name, err)
+ log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name(), err)
return err
}
diff --git a/core/external/provider_albumimage_test.go b/core/external/provider_albumimage_test.go
index e248813c1..e801b7cce 100644
--- a/core/external/provider_albumimage_test.go
+++ b/core/external/provider_albumimage_test.go
@@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
+ "github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@@ -23,7 +24,6 @@ var _ = Describe("Provider - AlbumImage", func() {
var mockAlbumRepo *mockAlbumRepo
var mockMediaFileRepo *mockMediaFileRepo
var mockAlbumAgent *mockAlbumInfoAgent
- var agentsCombined *mockAgents
var ctx context.Context
BeforeEach(func() {
@@ -43,11 +43,8 @@ var _ = Describe("Provider - AlbumImage", func() {
mockAlbumAgent = newMockAlbumInfoAgent()
- agentsCombined = &mockAgents{
- albumInfoAgent: mockAlbumAgent,
- }
-
- provider = NewProvider(ds, agentsCombined)
+ agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent}
+ provider = NewProvider(ds, agentsCombined, matcher.New(ds))
// Default mocks
// Mocks for GetEntityByID sequence (initial failed lookups)
@@ -66,13 +63,11 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test
- mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
- Return(&agents.AlbumInfo{
- Images: []agents.ExternalImage{
- {URL: "http://example.com/large.jpg", Size: 1000},
- {URL: "http://example.com/medium.jpg", Size: 500},
- {URL: "http://example.com/small.jpg", Size: 200},
- },
+ mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
+ Return([]agents.ExternalImage{
+ {URL: "http://example.com/large.jpg", Size: 1000},
+ {URL: "http://example.com/medium.jpg", Size: 500},
+ {URL: "http://example.com/small.jpg", Size: 200},
}, nil).Once()
expectedURL, _ := url.Parse("http://example.com/large.jpg")
@@ -82,8 +77,8 @@ var _ = Describe("Provider - AlbumImage", func() {
Expect(imgURL).To(Equal(expectedURL))
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
- mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum
- mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist name
+ mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum
+ mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name
})
It("returns ErrNotFound if the album is not found in the DB", func() {
@@ -99,7 +94,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
- mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
+ mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
})
It("returns the agent error if the agent fails", func() {
@@ -109,7 +104,7 @@ var _ = Describe("Provider - AlbumImage", func() {
agentErr := errors.New("agent failure")
// Explicitly mock agent call for this test
- mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist
+ mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist
imgURL, err := provider.AlbumImage(ctx, "album-1")
@@ -118,7 +113,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
- mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist
+ mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
})
It("returns ErrNotFound if the agent returns ErrNotFound", func() {
@@ -127,7 +122,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test
- mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist
+ mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist
imgURL, err := provider.AlbumImage(ctx, "album-1")
@@ -135,7 +130,7 @@ var _ = Describe("Provider - AlbumImage", func() {
Expect(imgURL).To(BeNil())
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
- mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist
+ mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
})
It("returns ErrNotFound if the agent returns no images", func() {
@@ -144,8 +139,8 @@ var _ = Describe("Provider - AlbumImage", func() {
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test
- mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
- Return(&agents.AlbumInfo{Images: []agents.ExternalImage{}}, nil).Once() // Expect empty artist
+ mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
+ Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist
imgURL, err := provider.AlbumImage(ctx, "album-1")
@@ -153,7 +148,7 @@ var _ = Describe("Provider - AlbumImage", func() {
Expect(imgURL).To(BeNil())
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
- mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist
+ mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
})
It("returns context error if context is canceled", func() {
@@ -163,7 +158,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Expect the agent call even if context is cancelled, returning the context error
- mockAlbumAgent.On("GetAlbumInfo", cctx, "Album One", "", "").Return(nil, context.Canceled).Once()
+ mockAlbumAgent.On("GetAlbumImages", cctx, "Album One", "", "").Return(nil, context.Canceled).Once()
// Cancel the context *before* calling the function under test
cancelCtx()
@@ -174,7 +169,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
// Agent should now be called, verify this expectation
- mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", cctx, "Album One", "", "")
+ mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", cctx, "Album One", "", "")
})
It("derives album ID from MediaFile ID", func() {
@@ -186,13 +181,11 @@ var _ = Describe("Provider - AlbumImage", func() {
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test
- mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
- Return(&agents.AlbumInfo{
- Images: []agents.ExternalImage{
- {URL: "http://example.com/large.jpg", Size: 1000},
- {URL: "http://example.com/medium.jpg", Size: 500},
- {URL: "http://example.com/small.jpg", Size: 200},
- },
+ mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
+ Return([]agents.ExternalImage{
+ {URL: "http://example.com/large.jpg", Size: 1000},
+ {URL: "http://example.com/medium.jpg", Size: 500},
+ {URL: "http://example.com/small.jpg", Size: 200},
}, nil).Once()
expectedURL, _ := url.Parse("http://example.com/large.jpg")
@@ -206,7 +199,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
- mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "")
+ mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
})
It("handles different image orders from agent", func() {
@@ -214,13 +207,11 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test
- mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
- Return(&agents.AlbumInfo{
- Images: []agents.ExternalImage{
- {URL: "http://example.com/small.jpg", Size: 200},
- {URL: "http://example.com/large.jpg", Size: 1000},
- {URL: "http://example.com/medium.jpg", Size: 500},
- },
+ mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
+ Return([]agents.ExternalImage{
+ {URL: "http://example.com/small.jpg", Size: 200},
+ {URL: "http://example.com/large.jpg", Size: 1000},
+ {URL: "http://example.com/medium.jpg", Size: 500},
}, nil).Once()
expectedURL, _ := url.Parse("http://example.com/large.jpg")
@@ -228,7 +219,7 @@ var _ = Describe("Provider - AlbumImage", func() {
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest
- mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "")
+ mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
})
It("handles agent returning only one image", func() {
@@ -236,11 +227,9 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test
- mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
- Return(&agents.AlbumInfo{
- Images: []agents.ExternalImage{
- {URL: "http://example.com/single.jpg", Size: 700},
- },
+ mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
+ Return([]agents.ExternalImage{
+ {URL: "http://example.com/single.jpg", Size: 700},
}, nil).Once()
expectedURL, _ := url.Parse("http://example.com/single.jpg")
@@ -248,7 +237,7 @@ var _ = Describe("Provider - AlbumImage", func() {
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
- mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "")
+ mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
})
It("returns ErrNotFound if deriving album ID fails", func() {
@@ -270,14 +259,78 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
- mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
+ mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
+ })
+
+ Context("Unicode handling in album names", func() {
+ var albumWithEnDash *model.Album
+ var expectedURL *url.URL
+
+ const (
+ originalAlbumName = "Raising Hell–Deluxe" // Album name with en dash
+ normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen
+ )
+
+ BeforeEach(func() {
+ // Test with en dash (–) in album name
+ albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"}
+ mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
+ mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations
+ mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once()
+ mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once()
+
+ expectedURL, _ = url.Parse("http://example.com/album.jpg")
+
+ // Mock the album agent to return an image for the album
+ mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", "").
+ Return([]agents.ExternalImage{
+ {URL: "http://example.com/album.jpg", Size: 1000},
+ }, nil).Once()
+ })
+
+ When("DevPreserveUnicodeInExternalCalls is true", func() {
+ BeforeEach(func() {
+ conf.Server.DevPreserveUnicodeInExternalCalls = true
+ })
+
+ It("preserves Unicode characters in album names", func() {
+ // Act
+ imgURL, err := provider.AlbumImage(ctx, "album-endash")
+
+ // Assert
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgURL).To(Equal(expectedURL))
+ mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
+ // This is the key assertion: ensure the original Unicode name is used
+ mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "")
+ })
+ })
+
+ When("DevPreserveUnicodeInExternalCalls is false", func() {
+ BeforeEach(func() {
+ conf.Server.DevPreserveUnicodeInExternalCalls = false
+ })
+
+ It("normalizes Unicode characters", func() {
+ // Act
+ imgURL, err := provider.AlbumImage(ctx, "album-endash")
+
+ // Assert
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgURL).To(Equal(expectedURL))
+ mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
+ // This assertion ensures the normalized name is used (en dash → hyphen)
+ mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "")
+ })
+ })
})
})
// mockAlbumInfoAgent implementation
type mockAlbumInfoAgent struct {
mock.Mock
- agents.AlbumInfoRetriever // Embed interface
+ agents.AlbumInfoRetriever
+ agents.AlbumImageRetriever
}
func newMockAlbumInfoAgent() *mockAlbumInfoAgent {
@@ -299,5 +352,14 @@ func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbi
return args.Get(0).(*agents.AlbumInfo), args.Error(1)
}
-// Ensure mockAgent implements the interface
+func (m *mockAlbumInfoAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
+ args := m.Called(ctx, name, artist, mbid)
+ if args.Get(0) == nil {
+ return nil, args.Error(1)
+ }
+ return args.Get(0).([]agents.ExternalImage), args.Error(1)
+}
+
+// Ensure mockAgent implements the interfaces
var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil)
+var _ agents.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil)
diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go
index 96341836a..79612d651 100644
--- a/core/external/provider_artistimage_test.go
+++ b/core/external/provider_artistimage_test.go
@@ -1,14 +1,18 @@
package external_test
import (
+ "bytes"
"context"
"errors"
"net/url"
+ "time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
+ "github.com/navidrome/navidrome/core/matcher"
+ "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@@ -48,7 +52,7 @@ var _ = Describe("Provider - ArtistImage", func() {
imageAgent: mockImageAgent,
}
- provider = NewProvider(ds, agentsCombined)
+ provider = NewProvider(ds, agentsCombined, matcher.New(ds))
// Default mocks for successful Get calls
mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe()
@@ -265,6 +269,127 @@ var _ = Describe("Provider - ArtistImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
})
+
+ It("returns cached URL and does not call agent when info is not expired", func() {
+ // Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt
+ cachedArtist := &model.Artist{
+ ID: "artist-cached",
+ Name: "Cached Artist",
+ LargeImageUrl: "http://example.com/cached-large.jpg",
+ ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)),
+ }
+ mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe()
+ expectedURL, _ := url.Parse("http://example.com/cached-large.jpg")
+
+ // Capture log output
+ var logBuf bytes.Buffer
+ log.SetOutput(&logBuf)
+ defer log.SetOutput(GinkgoWriter)
+ log.SetLevel(log.LevelDebug)
+
+ // Act
+ imgURL, err := provider.ArtistImage(ctx, "artist-cached")
+
+ // Assert
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgURL).To(Equal(expectedURL))
+ mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-cached", mock.Anything, mock.Anything)
+
+ // Assert: background refresh was NOT enqueued
+ Expect(logBuf.String()).ToNot(ContainSubstring("Artist image info expired, enqueuing background refresh"))
+
+ })
+
+ It("returns stale URL and enqueues refresh when info is expired", func() {
+ // Arrange
+ conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond
+ staleArtist := &model.Artist{
+ ID: "artist-expired",
+ Name: "Expired Artist",
+ LargeImageUrl: "http://example.com/expired-large.jpg",
+ ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)),
+ }
+ mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe()
+ expectedURL, _ := url.Parse("http://example.com/expired-large.jpg")
+
+ // Capture log output
+ var logBuf bytes.Buffer
+ log.SetOutput(&logBuf)
+ defer log.SetOutput(GinkgoWriter)
+ log.SetLevel(log.LevelDebug)
+
+ // Act
+ imgURL, err := provider.ArtistImage(ctx, "artist-expired")
+
+ // Assert: returns stale URL immediately, no agent call
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgURL).To(Equal(expectedURL))
+ mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-expired", mock.Anything, mock.Anything)
+
+ // Assert: background refresh was enqueued
+ Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh"))
+ })
+
+ Context("Unicode handling in artist names", func() {
+ var artistWithEnDash *model.Artist
+ var expectedURL *url.URL
+
+ const (
+ originalArtistName = "Run–D.M.C." // Artist name with en dash
+ normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen
+ )
+
+ BeforeEach(func() {
+ // Test with en dash (–) in artist name like "Run–D.M.C."
+ artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName}
+ mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
+ mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once()
+
+ expectedURL, _ = url.Parse("http://example.com/rundmc.jpg")
+
+ // Mock the image agent to return an image for the artist
+ mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), "").
+ Return([]agents.ExternalImage{
+ {URL: "http://example.com/rundmc.jpg", Size: 1000},
+ }, nil).Once()
+
+ })
+
+ When("DevPreserveUnicodeInExternalCalls is true", func() {
+ BeforeEach(func() {
+ conf.Server.DevPreserveUnicodeInExternalCalls = true
+ })
+ It("preserves Unicode characters in artist names", func() {
+ // Act
+ imgURL, err := provider.ArtistImage(ctx, "artist-endash")
+
+ // Assert
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgURL).To(Equal(expectedURL))
+ mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
+ // This is the key assertion: ensure the original Unicode name is used
+ mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "")
+ })
+ })
+
+ When("DevPreserveUnicodeInExternalCalls is false", func() {
+ BeforeEach(func() {
+ conf.Server.DevPreserveUnicodeInExternalCalls = false
+ })
+
+ It("normalizes Unicode characters", func() {
+ // Act
+ imgURL, err := provider.ArtistImage(ctx, "artist-endash")
+
+ // Assert
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgURL).To(Equal(expectedURL))
+ mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
+ // This assertion ensures the normalized name is used (en dash → hyphen)
+ mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "")
+ })
+ })
+ })
})
// mockArtistImageAgent implementation using testify/mock
diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go
index fd622746a..c9a1a64ef 100644
--- a/core/external/provider_similarsongs_test.go
+++ b/core/external/provider_similarsongs_test.go
@@ -4,8 +4,10 @@ import (
"context"
"errors"
+ "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
+ "github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@@ -19,9 +21,10 @@ var _ = Describe("Provider - SimilarSongs", func() {
var mockAgent *mockSimilarArtistAgent
var mockTopAgent agents.ArtistTopSongsRetriever
var mockSimilarAgent agents.ArtistSimilarRetriever
- var agentsCombined Agents
+ var agentsCombined *mockAgents
var artistRepo *mockArtistRepo
var mediaFileRepo *mockMediaFileRepo
+ var albumRepo *mockAlbumRepo
var ctx context.Context
BeforeEach(func() {
@@ -29,10 +32,12 @@ var _ = Describe("Provider - SimilarSongs", func() {
artistRepo = newMockArtistRepo()
mediaFileRepo = newMockMediaFileRepo()
+ albumRepo = newMockAlbumRepo()
ds = &tests.MockDataStore{
MockedArtist: artistRepo,
MockedMediaFile: mediaFileRepo,
+ MockedAlbum: albumRepo,
}
mockAgent = &mockSimilarArtistAgent{}
@@ -44,15 +49,233 @@ var _ = Describe("Provider - SimilarSongs", func() {
similarAgent: mockSimilarAgent,
}
- provider = NewProvider(ds, agentsCombined)
+ provider = NewProvider(ds, agentsCombined, matcher.New(ds))
+ })
+
+ Describe("dispatch by entity type", func() {
+ Context("when ID is a MediaFile (track)", func() {
+ It("calls GetSimilarSongsByTrack and returns matched songs", func() {
+ track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"}
+ matchedSong := model.MediaFile{ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode"}
+
+ // GetEntityByID tries Artist, Album, Playlist, then MediaFile
+ artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
+ albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
+ mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
+
+ agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5).
+ Return([]agents.Song{
+ {Name: "Dreaming of Me", MBID: "", Artist: "Depeche Mode", ArtistMBID: "artist-mbid"},
+ }, nil).Once()
+
+ // Mock loadTracksByID - no ID matches
+ mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
+ _, ok := opt.Filters.(squirrel.Eq)
+ return ok
+ })).Return(model.MediaFiles{}, nil).Once()
+
+ // Mock loadTracksByMBID - no MBID matches (empty MBID means this won't be called)
+ mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
+ and, ok := opt.Filters.(squirrel.And)
+ if !ok || len(and) < 1 {
+ return false
+ }
+ eq, hasEq := and[0].(squirrel.Eq)
+ if !hasEq {
+ return false
+ }
+ _, hasMBID := eq["mbz_recording_id"]
+ return hasMBID
+ })).Return(model.MediaFiles{}, nil).Maybe()
+
+ // Mock loadTracksByTitleAndArtist - queries by artist name
+ mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
+ and, ok := opt.Filters.(squirrel.And)
+ if !ok || len(and) < 2 {
+ return false
+ }
+ eq, hasEq := and[0].(squirrel.Eq)
+ if !hasEq {
+ return false
+ }
+ _, hasArtist := eq["order_artist_name"]
+ return hasArtist
+ })).Return(model.MediaFiles{matchedSong}, nil).Maybe()
+
+ songs, err := provider.SimilarSongs(ctx, "track-1", 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).To(HaveLen(1))
+ Expect(songs[0].ID).To(Equal("matched-1"))
+ })
+
+ It("falls back to artist-based algorithm when GetSimilarSongsByTrack returns empty", func() {
+ track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"}
+ artist := model.Artist{ID: "artist-1", Name: "Artist"}
+ song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
+
+ // GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile
+ artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
+ albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
+ mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
+
+ agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", mock.Anything).
+ Return([]agents.Song{}, nil).Once()
+
+ // Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the mediafile
+ // and recursively calls getArtist(v.ArtistID)
+ artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
+ albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
+ mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
+
+ // Then it recurses with the artist-1 ID
+ artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
+ artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
+ return opt.Max == 1 && opt.Filters != nil
+ })).Return(model.Artists{artist}, nil).Maybe()
+
+ mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
+ Return([]agents.Artist{}, nil).Once()
+
+ artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
+ return opt.Max == 0 && opt.Filters != nil
+ })).Return(model.Artists{}, nil).Once()
+
+ mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
+ Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
+
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once()
+
+ songs, err := provider.SimilarSongs(ctx, "track-1", 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).To(HaveLen(1))
+ Expect(songs[0].ID).To(Equal("song-1"))
+ })
+ })
+
+ Context("when ID is an Album", func() {
+ It("calls GetSimilarSongsByAlbum and returns matched songs", func() {
+ album := model.Album{ID: "album-1", Name: "Speak & Spell", AlbumArtist: "Depeche Mode", MbzAlbumID: "album-mbid"}
+ matchedSong := model.MediaFile{ID: "matched-1", Title: "New Life", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
+
+ // GetEntityByID tries Artist, Album, Playlist, then MediaFile
+ artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
+ albumRepo.On("Get", "album-1").Return(&album, nil).Once()
+
+ agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5).
+ Return([]agents.Song{
+ {Name: "New Life", MBID: "song-mbid", Artist: "Depeche Mode"},
+ }, nil).Once()
+
+ // Mock loadTracksByID - no ID matches
+ mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
+ _, ok := opt.Filters.(squirrel.Eq)
+ return ok
+ })).Return(model.MediaFiles{}, nil).Once()
+
+ // Mock loadTracksByMBID - MBID match
+ mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
+ and, ok := opt.Filters.(squirrel.And)
+ if !ok || len(and) < 1 {
+ return false
+ }
+ _, hasEq := and[0].(squirrel.Eq)
+ return hasEq
+ })).Return(model.MediaFiles{matchedSong}, nil).Once()
+
+ songs, err := provider.SimilarSongs(ctx, "album-1", 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).To(HaveLen(1))
+ Expect(songs[0].ID).To(Equal("matched-1"))
+ })
+
+ It("falls back when GetSimilarSongsByAlbum returns ErrNotFound", func() {
+ album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"}
+ artist := model.Artist{ID: "artist-1", Name: "Artist"}
+ song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
+
+ // GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile
+ artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
+ albumRepo.On("Get", "album-1").Return(&album, nil).Once()
+
+ agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Album", "Artist", "", mock.Anything).
+ Return(nil, agents.ErrNotFound).Once()
+
+ // Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the album
+ // and recursively calls getArtist(v.AlbumArtistID)
+ artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
+ albumRepo.On("Get", "album-1").Return(&album, nil).Once()
+
+ // Then it recurses with the artist-1 ID
+ artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
+ artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
+ return opt.Max == 1 && opt.Filters != nil
+ })).Return(model.Artists{artist}, nil).Maybe()
+
+ mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
+ Return([]agents.Artist{}, nil).Once()
+
+ artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
+ return opt.Max == 0 && opt.Filters != nil
+ })).Return(model.Artists{}, nil).Once()
+
+ mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
+ Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
+
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once()
+
+ songs, err := provider.SimilarSongs(ctx, "album-1", 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).To(HaveLen(1))
+ Expect(songs[0].ID).To(Equal("song-1"))
+ })
+ })
+
+ Context("when ID is an Artist", func() {
+ It("calls GetSimilarSongsByArtist and returns matched songs", func() {
+ artist := model.Artist{ID: "artist-1", Name: "Depeche Mode", MbzArtistID: "artist-mbid"}
+ matchedSong := model.MediaFile{ID: "matched-1", Title: "Enjoy the Silence", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
+
+ artistRepo.On("Get", "artist-1").Return(&artist, nil).Once()
+ agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5).
+ Return([]agents.Song{
+ {Name: "Enjoy the Silence", MBID: "song-mbid", Artist: "Depeche Mode"},
+ }, nil).Once()
+
+ // Mock loadTracksByID - no ID matches
+ mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
+ _, ok := opt.Filters.(squirrel.Eq)
+ return ok
+ })).Return(model.MediaFiles{}, nil).Once()
+
+ // Mock loadTracksByMBID - MBID match
+ mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
+ and, ok := opt.Filters.(squirrel.And)
+ if !ok || len(and) < 1 {
+ return false
+ }
+ _, hasEq := and[0].(squirrel.Eq)
+ return hasEq
+ })).Return(model.MediaFiles{matchedSong}, nil).Once()
+
+ songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).To(HaveLen(1))
+ Expect(songs[0].ID).To(Equal("matched-1"))
+ })
+ })
})
It("returns similar songs from main artist and similar artists", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
- song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
- song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"}
- song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3"}
+ song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
+ song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
+ song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
@@ -61,14 +284,26 @@ var _ = Describe("Provider - SimilarSongs", func() {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Once()
+ // New similar songs by artist returns ErrNotFound to trigger fallback
+ agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
+ Return(nil, agents.ErrNotFound).Once()
+
similarAgentsResp := []agents.Artist{
{Name: "Similar Artist", MBID: "similar-mbid"},
}
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return(similarAgentsResp, nil).Once()
+ // Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name
+ // MBID lookup returns empty (no match)
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
- return opt.Max == 0 && opt.Filters != nil
+ _, ok := opt.Filters.(squirrel.Eq)
+ return opt.Max == 0 && ok
+ })).Return(model.Artists{}, nil).Once()
+ // Name lookup returns the similar artist
+ artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
+ _, ok := opt.Filters.(squirrel.Or)
+ return opt.Max == 0 && ok
})).Return(model.Artists{similarArtist}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
@@ -82,9 +317,8 @@ var _ = Describe("Provider - SimilarSongs", func() {
{Name: "Song Three", MBID: "mbid-3"},
}, nil).Once()
- mediaFileRepo.FindByMBID("mbid-1", song1)
- mediaFileRepo.FindByMBID("mbid-2", song2)
- mediaFileRepo.FindByMBID("mbid-3", song3)
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 3)
@@ -98,6 +332,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
It("returns ErrNotFound when artist is not found", func() {
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
+ albumRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
@@ -111,13 +346,17 @@ var _ = Describe("Provider - SimilarSongs", func() {
It("returns songs from main artist when GetSimilarArtists returns error", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
- song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
+ song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
+ // New similar songs by artist returns ErrNotFound to trigger fallback
+ agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
+ Return(nil, agents.ErrNotFound).Once()
+
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return(nil, errors.New("error getting similar artists")).Once()
@@ -130,7 +369,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
{Name: "Song One", MBID: "mbid-1"},
}, nil).Once()
- mediaFileRepo.FindByMBID("mbid-1", song1)
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
@@ -147,6 +386,10 @@ var _ = Describe("Provider - SimilarSongs", func() {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
+ // New similar songs by artist returns ErrNotFound to trigger fallback
+ agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
+ Return(nil, agents.ErrNotFound).Once()
+
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return([]agents.Artist{}, nil).Once()
@@ -165,14 +408,18 @@ var _ = Describe("Provider - SimilarSongs", func() {
It("respects count parameter", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
- song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
- song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"}
+ song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
+ song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
+ // New similar songs by artist returns ErrNotFound to trigger fallback
+ agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
+ Return(nil, agents.ErrNotFound).Once()
+
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return([]agents.Artist{}, nil).Once()
@@ -186,8 +433,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
{Name: "Song Two", MBID: "mbid-2"},
}, nil).Once()
- mediaFileRepo.FindByMBID("mbid-1", song1)
- mediaFileRepo.FindByMBID("mbid-2", song2)
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go
index 4ce7911de..0d9b5800d 100644
--- a/core/external/provider_topsongs_test.go
+++ b/core/external/provider_topsongs_test.go
@@ -4,11 +4,13 @@ import (
"context"
"errors"
+ _ "github.com/navidrome/navidrome/adapters/lastfm"
+ _ "github.com/navidrome/navidrome/adapters/listenbrainz"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
- _ "github.com/navidrome/navidrome/core/agents/lastfm"
- _ "github.com/navidrome/navidrome/core/agents/listenbrainz"
- _ "github.com/navidrome/navidrome/core/agents/spotify"
. "github.com/navidrome/navidrome/core/external"
+ "github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@@ -26,6 +28,10 @@ var _ = Describe("Provider - TopSongs", func() {
)
BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ // Disable fuzzy matching for these tests to avoid unexpected GetAll calls
+ conf.Server.Matcher.FuzzyThreshold = 100
+
ctx = GinkgoT().Context()
artistRepo = newMockArtistRepo() // Use helper mock
@@ -39,11 +45,7 @@ var _ = Describe("Provider - TopSongs", func() {
ag = new(mockAgents)
- p = NewProvider(ds, ag)
- })
-
- BeforeEach(func() {
- // Setup expectations in individual tests
+ p = NewProvider(ds, ag, matcher.New(ds))
})
It("returns top songs for a known artist", func() {
@@ -58,11 +60,10 @@ var _ = Describe("Provider - TopSongs", func() {
}
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
- // Mock finding matching tracks
+ // Mock finding matching tracks (both returned in a single query)
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"}
- mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
- mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once()
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
songs, err := p.TopSongs(ctx, "Artist One", 2)
@@ -155,11 +156,10 @@ var _ = Describe("Provider - TopSongs", func() {
}
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
- // Mock finding matching tracks (only find song 1)
+ // Mock finding matching tracks (only find song 1 on bulk query)
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
- mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
- mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // For mbid-song-2 (fails)
- mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // For title fallback (fails)
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title fallback for song2
songs, err := p.TopSongs(ctx, "Artist One", 2)
@@ -190,4 +190,147 @@ var _ = Describe("Provider - TopSongs", func() {
artistRepo.AssertExpectations(GinkgoT())
ag.AssertExpectations(GinkgoT())
})
+
+ It("falls back to title matching when MbzRecordingID is missing", func() {
+ // Mock finding the artist
+ artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
+ artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
+
+ // Mock agent response with songs that have NO MBID (empty string)
+ agentSongs := []agents.Song{
+ {Name: "Song One", MBID: ""}, // No MBID, should fall back to title matching
+ {Name: "Song Two", MBID: ""}, // No MBID, should fall back to title matching
+ }
+ ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
+
+ // Since there are no MBIDs, loadTracksByMBID should not make any database call
+ // loadTracksByTitle should make a database call for title matching
+ song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"}
+ song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"}
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
+
+ songs, err := p.TopSongs(ctx, "Artist One", 2)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).To(HaveLen(2))
+ Expect(songs[0].ID).To(Equal("song-1"))
+ Expect(songs[1].ID).To(Equal("song-2"))
+ artistRepo.AssertExpectations(GinkgoT())
+ ag.AssertExpectations(GinkgoT())
+ mediaFileRepo.AssertExpectations(GinkgoT())
+ })
+
+ It("combines MBID and title matching when some songs have missing MbzRecordingID", func() {
+ // Mock finding the artist
+ artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
+ artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
+
+ // Mock agent response with mixed MBID availability
+ agentSongs := []agents.Song{
+ {Name: "Song One", MBID: "mbid-song-1"}, // Has MBID, should match by MBID
+ {Name: "Song Two", MBID: ""}, // No MBID, should fall back to title matching
+ }
+ ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
+
+ // Mock the MBID query (finds song1 by MBID)
+ song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1", OrderTitle: "song one"}
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
+
+ // Mock the title fallback query (finds song2 by title)
+ song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"}
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once()
+
+ songs, err := p.TopSongs(ctx, "Artist One", 2)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).To(HaveLen(2))
+ Expect(songs[0].ID).To(Equal("song-1")) // Found by MBID
+ Expect(songs[1].ID).To(Equal("song-2")) // Found by title
+ artistRepo.AssertExpectations(GinkgoT())
+ ag.AssertExpectations(GinkgoT())
+ mediaFileRepo.AssertExpectations(GinkgoT())
+ })
+
+ It("only returns requested count when provider returns additional items", func() {
+ // Mock finding the artist
+ artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
+ artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
+
+ // Mock agent response
+ agentSongs := []agents.Song{
+ {Name: "Song One", MBID: "mbid-song-1"},
+ {Name: "Song Two", MBID: "mbid-song-2"},
+ }
+ ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
+
+ // Mock finding matching tracks (both returned in a single query)
+ song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
+ song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"}
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
+
+ songs, err := p.TopSongs(ctx, "Artist One", 1)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).To(HaveLen(1))
+ Expect(songs[0].ID).To(Equal("song-1"))
+ artistRepo.AssertExpectations(GinkgoT())
+ ag.AssertExpectations(GinkgoT())
+ mediaFileRepo.AssertExpectations(GinkgoT())
+ })
+
+ It("matches songs by ID first when agent provides IDs", func() {
+ // Mock finding the artist
+ artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
+ artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
+
+ // Mock agent response with IDs provided (highest priority matching)
+ // Note: Songs have no MBID to ensure only ID matching is used
+ agentSongs := []agents.Song{
+ {ID: "song-1", Name: "Song One"},
+ {ID: "song-2", Name: "Song Two"},
+ }
+ ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
+
+ // Mock ID lookup (first query - should match both songs directly)
+ song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
+ song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"}
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
+
+ songs, err := p.TopSongs(ctx, "Artist One", 2)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).To(HaveLen(2))
+ Expect(songs[0].ID).To(Equal("song-1"))
+ Expect(songs[1].ID).To(Equal("song-2"))
+ artistRepo.AssertExpectations(GinkgoT())
+ ag.AssertExpectations(GinkgoT())
+ mediaFileRepo.AssertExpectations(GinkgoT())
+ })
+
+ It("falls back to MBID when ID is not found", func() {
+ // Mock finding the artist
+ artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
+ artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
+
+ // Mock agent response with ID that won't be found, but MBID that will
+ agentSongs := []agents.Song{
+ {ID: "non-existent-id", Name: "Song One", MBID: "mbid-song-1"},
+ }
+ ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
+
+ // Mock ID lookup - returns empty (ID not found)
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once()
+ // Mock MBID lookup - finds the song
+ song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
+
+ songs, err := p.TopSongs(ctx, "Artist One", 1)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).To(HaveLen(1))
+ Expect(songs[0].ID).To(Equal("song-1"))
+ artistRepo.AssertExpectations(GinkgoT())
+ ag.AssertExpectations(GinkgoT())
+ mediaFileRepo.AssertExpectations(GinkgoT())
+ })
})
diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go
index 0622849f0..21824c93f 100644
--- a/core/external/provider_updatealbuminfo_test.go
+++ b/core/external/provider_updatealbuminfo_test.go
@@ -8,10 +8,10 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external"
+ "github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
- "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
@@ -34,7 +34,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ctx = GinkgoT().Context()
ds = new(tests.MockDataStore)
ag = new(mockAgents)
- p = external.NewProvider(ds, ag)
+ p = external.NewProvider(ds, ag, matcher.New(ds))
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
})
@@ -59,13 +59,13 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
expectedInfo := &agents.AlbumInfo{
URL: "http://example.com/album",
Description: "Album Description",
- Images: []agents.ExternalImage{
- {URL: "http://example.com/large.jpg", Size: 300},
- {URL: "http://example.com/medium.jpg", Size: 200},
- {URL: "http://example.com/small.jpg", Size: 100},
- },
}
ag.On("GetAlbumInfo", ctx, "Test Album", "Test Artist", "mbid-album").Return(expectedInfo, nil)
+ ag.On("GetAlbumImages", ctx, "Test Album", "Test Artist", "mbid-album").Return([]agents.ExternalImage{
+ {URL: "http://example.com/large.jpg", Size: 300},
+ {URL: "http://example.com/medium.jpg", Size: 200},
+ {URL: "http://example.com/small.jpg", Size: 100},
+ }, nil)
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-existing")
@@ -74,9 +74,6 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
Expect(updatedAlbum.ID).To(Equal("al-existing"))
Expect(updatedAlbum.ExternalUrl).To(Equal("http://example.com/album"))
Expect(updatedAlbum.Description).To(Equal("Album Description"))
- Expect(updatedAlbum.LargeImageUrl).To(Equal("http://example.com/large.jpg"))
- Expect(updatedAlbum.MediumImageUrl).To(Equal("http://example.com/medium.jpg"))
- Expect(updatedAlbum.SmallImageUrl).To(Equal("http://example.com/small.jpg"))
Expect(updatedAlbum.ExternalInfoUpdatedAt).NotTo(BeNil())
Expect(*updatedAlbum.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second))
@@ -92,7 +89,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ExternalUrl: "http://cached.com/album",
Description: "Cached Desc",
LargeImageUrl: "http://cached.com/large.jpg",
- ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
+ ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
}
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
@@ -115,7 +112,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ExternalUrl: "http://expired.com/album",
Description: "Expired Desc",
LargeImageUrl: "http://expired.com/large.jpg",
- ExternalInfoUpdatedAt: gg.P(expiredTime),
+ ExternalInfoUpdatedAt: new(expiredTime),
}
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go
index 9b1e8d866..d783128fb 100644
--- a/core/external/provider_updateartistinfo_test.go
+++ b/core/external/provider_updateartistinfo_test.go
@@ -9,10 +9,10 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external"
+ "github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
- "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
@@ -37,7 +37,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
ctx = GinkgoT().Context()
ds = new(tests.MockDataStore)
ag = new(mockAgents)
- p = external.NewProvider(ds, ag)
+ p = external.NewProvider(ds, ag, matcher.New(ds))
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
})
@@ -104,6 +104,29 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
ag.AssertExpectations(GinkgoT())
})
+ It("preserves decoded plain text in biography storage", func() {
+ originalArtist := &model.Artist{
+ ID: "ar-encoded-bio",
+ Name: "Encoded Bio Artist",
+ }
+ mockArtistRepo.SetData(model.Artists{*originalArtist})
+
+ expectedMBID := "mbid-encoded-bio"
+ expectedBio := "R&B"
+
+ ag.On("GetArtistMBID", ctx, "ar-encoded-bio", "Encoded Bio Artist").Return(expectedMBID, nil).Once()
+ ag.On("GetArtistImages", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(nil, nil).Maybe()
+ ag.On("GetArtistBiography", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(expectedBio, nil).Once()
+ ag.On("GetArtistURL", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return("", nil).Maybe()
+ ag.On("GetSimilarArtists", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID, 100).Return(nil, nil).Maybe()
+
+ updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-encoded-bio", 10, false)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(updatedArtist).NotTo(BeNil())
+ Expect(updatedArtist.Biography).To(Equal("R&B"))
+ })
+
It("returns cached info when artist exists and info is not expired", func() {
now := time.Now()
originalArtist := &model.Artist{
@@ -113,7 +136,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
ExternalUrl: "http://cached.url",
Biography: "Cached Bio",
LargeImageUrl: "http://cached_large.jpg",
- ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
+ ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
SimilarArtists: model.Artists{
{ID: "ar-similar-present", Name: "Similar Present"},
{ID: "ar-similar-absent", Name: "Similar Absent"},
@@ -150,7 +173,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
originalArtist := &model.Artist{
ID: "ar-expired",
Name: "Expired Artist",
- ExternalInfoUpdatedAt: gg.P(expiredTime),
+ ExternalInfoUpdatedAt: new(expiredTime),
SimilarArtists: model.Artists{
{ID: "ar-exp-similar", Name: "Expired Similar"},
},
@@ -181,7 +204,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
originalArtist := &model.Artist{
ID: "ar-similar-test",
Name: "Similar Test Artist",
- ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
+ ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
SimilarArtists: model.Artists{
{ID: "ar-sim-present", Name: "Similar Present"},
{ID: "", Name: "Similar Absent Raw"},
@@ -226,4 +249,88 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
Expect(updatedArtist.ID).To(Equal("ar-agent-fail"))
ag.AssertExpectations(GinkgoT())
})
+
+ It("matches similar artists by ID first when agent provides IDs", func() {
+ originalArtist := &model.Artist{
+ ID: "ar-id-match",
+ Name: "ID Match Artist",
+ }
+ similarByID := model.Artist{ID: "ar-similar-by-id", Name: "Similar By ID", MbzArtistID: "mbid-similar"}
+ mockArtistRepo.SetData(model.Artists{*originalArtist, similarByID})
+
+ // Agent returns similar artist with ID (highest priority matching)
+ rawSimilar := []agents.Artist{
+ {ID: "ar-similar-by-id", Name: "Different Name", MBID: "different-mbid"},
+ }
+
+ ag.On("GetArtistMBID", ctx, "ar-id-match", "ID Match Artist").Return("", nil).Once()
+ ag.On("GetArtistImages", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return(nil, nil).Maybe()
+ ag.On("GetArtistBiography", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return("", nil).Maybe()
+ ag.On("GetArtistURL", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return("", nil).Maybe()
+ ag.On("GetSimilarArtists", ctx, "ar-id-match", "ID Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()
+
+ updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-id-match", 10, false)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
+ // Should match by ID, not by name or MBID
+ Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-id"))
+ Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By ID"))
+ })
+
+ It("matches similar artists by MBID when ID is empty", func() {
+ originalArtist := &model.Artist{
+ ID: "ar-mbid-match",
+ Name: "MBID Match Artist",
+ }
+ similarByMBID := model.Artist{ID: "ar-similar-by-mbid", Name: "Similar By MBID", MbzArtistID: "mbid-similar"}
+ mockArtistRepo.SetData(model.Artists{*originalArtist, similarByMBID})
+
+ // Agent returns similar artist with only MBID (no ID)
+ rawSimilar := []agents.Artist{
+ {Name: "Different Name", MBID: "mbid-similar"},
+ }
+
+ ag.On("GetArtistMBID", ctx, "ar-mbid-match", "MBID Match Artist").Return("", nil).Once()
+ ag.On("GetArtistImages", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return(nil, nil).Maybe()
+ ag.On("GetArtistBiography", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return("", nil).Maybe()
+ ag.On("GetArtistURL", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return("", nil).Maybe()
+ ag.On("GetSimilarArtists", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()
+
+ updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-mbid-match", 10, false)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
+ // Should match by MBID since ID was empty
+ Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-mbid"))
+ Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By MBID"))
+ })
+
+ It("falls back to name matching when ID and MBID don't match", func() {
+ originalArtist := &model.Artist{
+ ID: "ar-name-match",
+ Name: "Name Match Artist",
+ }
+ similarByName := model.Artist{ID: "ar-similar-by-name", Name: "Similar By Name"}
+ mockArtistRepo.SetData(model.Artists{*originalArtist, similarByName})
+
+ // Agent returns similar artist with non-matching ID and MBID
+ rawSimilar := []agents.Artist{
+ {ID: "non-existent-id", Name: "Similar By Name", MBID: "non-existent-mbid"},
+ }
+
+ ag.On("GetArtistMBID", ctx, "ar-name-match", "Name Match Artist").Return("", nil).Once()
+ ag.On("GetArtistImages", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return(nil, nil).Maybe()
+ ag.On("GetArtistBiography", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return("", nil).Maybe()
+ ag.On("GetArtistURL", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return("", nil).Maybe()
+ ag.On("GetSimilarArtists", ctx, "ar-name-match", "Name Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()
+
+ updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-name-match", 10, false)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
+ // Should fall back to name matching since ID and MBID didn't match
+ Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-name"))
+ Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By Name"))
+ })
})
diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go
index 2e0d5a4b7..58e9fd152 100644
--- a/core/ffmpeg/ffmpeg.go
+++ b/core/ffmpeg/ffmpeg.go
@@ -1,26 +1,57 @@
package ffmpeg
import (
+ "bytes"
"context"
+ "encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
+ "path/filepath"
+ "slices"
"strconv"
"strings"
"sync"
+ "time"
"github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
)
+// TranscodeOptions contains all parameters for a transcoding operation.
+type TranscodeOptions struct {
+ Command string // DB command template (used to detect custom vs default)
+ Format string // Target format (mp3, opus, aac, flac)
+ FilePath string
+ BitRate int // kbps, 0 = codec default
+ SampleRate int // 0 = no constraint
+ Channels int // 0 = no constraint
+ BitDepth int // 0 = no constraint; valid values: 16, 24, 32
+ Offset int // seconds
+}
+
+// AudioProbeResult contains authoritative audio stream properties from ffprobe.
+type AudioProbeResult struct {
+ Codec string `json:"codec"`
+ Profile string `json:"profile,omitempty"`
+ BitRate int `json:"bitRate"`
+ SampleRate int `json:"sampleRate"`
+ BitDepth int `json:"bitDepth"`
+ Channels int `json:"channels"`
+}
+
type FFmpeg interface {
- Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
+ Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
+ ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error)
Probe(ctx context.Context, files []string) (string, error)
+ ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
CmdPath() (string, error)
IsAvailable() bool
+ IsProbeAvailable() bool
Version() string
}
@@ -28,30 +59,72 @@ func New() FFmpeg {
return &ffmpeg{}
}
+// ErrAnimatedWebPUnsupported is returned by ConvertAnimatedImage when the
+// ffmpeg binary lacks the libwebp_anim encoder. Callers can use errors.Is to
+// detect this specific case and fall back to static resize.
+var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder — install an ffmpeg build with libwebp")
+
const (
- extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
- probeCmd = "ffmpeg %s -f ffmetadata"
+ extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
+ probeCmd = "ffmpeg %s -f ffmetadata"
+ probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s"
)
type ffmpeg struct{}
-func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
+func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
- // First make sure the file exists
- if err := fileExists(path); err != nil {
+ if err := fileExists(opts.FilePath); err != nil {
return nil, err
}
- args := createFFmpegCommand(command, path, maxBitRate, offset)
+ var args []string
+ if isDefaultCommand(opts.Format, opts.Command) {
+ args = buildDynamicArgs(opts)
+ } else {
+ args = buildTemplateArgs(opts)
+ }
return e.start(ctx, args)
}
+func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) {
+ cmdPath, err := ffmpegCmd()
+ if err != nil {
+ return nil, err
+ }
+ if !animWebP.has(cmdPath, "libwebp_anim") {
+ return nil, ErrAnimatedWebPUnsupported
+ }
+
+ args := []string{cmdPath, "-i", "pipe:0"}
+ if maxSize > 0 {
+ vf := fmt.Sprintf("scale='min(%d,iw)':'min(%d,ih)':force_original_aspect_ratio=decrease", maxSize, maxSize)
+ args = append(args, "-vf", vf)
+ }
+ args = append(args, "-loop", "0", "-c:v", "libwebp_anim",
+ "-quality", strconv.Itoa(quality), "-f", "webp", "-")
+
+ return e.start(ctx, args, reader)
+}
+
+// parseEncodersOutput scans the stdout of `ffmpeg -encoders` for a whole-word
+// match of encoder name. The output has rows like " V....D libwebp_anim ..."
+// where the name is the 2nd whitespace-separated field.
+func parseEncodersOutput(out []byte, name string) bool {
+ for line := range strings.SplitSeq(string(out), "\n") {
+ fields := strings.Fields(line)
+ if len(fields) >= 2 && fields[1] == name {
+ return true
+ }
+ }
+ return false
+}
+
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
- // First make sure the file exists
if err := fileExists(path); err != nil {
return nil, err
}
@@ -81,6 +154,91 @@ func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) {
return string(output), nil
}
+func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) {
+ if _, err := ffmpegCmd(); err != nil {
+ return nil, err
+ }
+ if err := fileExists(filePath); err != nil {
+ return nil, err
+ }
+ args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0)
+ log.Trace(ctx, "Executing ffprobe command", "args", args)
+ cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err)
+ }
+ return parseProbeOutput(output)
+}
+
+type probeOutput struct {
+ Streams []probeStream `json:"streams"`
+ Format probeFormat `json:"format"`
+}
+
+type probeFormat struct {
+ BitRate string `json:"bit_rate"`
+}
+
+type probeStream struct {
+ CodecName string `json:"codec_name"`
+ CodecType string `json:"codec_type"`
+ Profile string `json:"profile"`
+ SampleRate string `json:"sample_rate"`
+ BitRate string `json:"bit_rate"`
+ Channels int `json:"channels"`
+ BitsPerSample int `json:"bits_per_sample"`
+ BitsPerRawSample string `json:"bits_per_raw_sample"`
+}
+
+func parseProbeOutput(data []byte) (*AudioProbeResult, error) {
+ var output probeOutput
+ if err := json.Unmarshal(data, &output); err != nil {
+ return nil, fmt.Errorf("parsing ffprobe output: %w", err)
+ }
+
+ for _, s := range output.Streams {
+ if s.CodecType != "audio" {
+ continue
+ }
+ bitDepth := s.BitsPerSample
+ if bitDepth == 0 && s.BitsPerRawSample != "" {
+ bitDepth, _ = strconv.Atoi(s.BitsPerRawSample)
+ }
+ result := &AudioProbeResult{
+ Codec: s.CodecName,
+ Channels: s.Channels,
+ BitDepth: bitDepth,
+ }
+
+ // Profile: "unknown" → empty
+ if s.Profile != "" && !strings.EqualFold(s.Profile, "unknown") {
+ result.Profile = s.Profile
+ }
+
+ // Sample rate: string → int
+ if s.SampleRate != "" {
+ result.SampleRate, _ = strconv.Atoi(s.SampleRate)
+ }
+
+ // Bit rate: bps string → kbps int
+ if s.BitRate != "" {
+ bps, _ := strconv.Atoi(s.BitRate)
+ result.BitRate = bps / 1000
+ }
+
+ // Fallback to format-level bit_rate (needed for FLAC, Opus, etc.)
+ if result.BitRate == 0 && output.Format.BitRate != "" {
+ bps, _ := strconv.Atoi(output.Format.BitRate)
+ result.BitRate = bps / 1000
+ }
+
+ return result, nil
+ }
+
+ return nil, fmt.Errorf("no audio stream found in ffprobe output")
+}
+
func (e *ffmpeg) CmdPath() (string, error) {
return ffmpegCmd()
}
@@ -90,6 +248,19 @@ func (e *ffmpeg) IsAvailable() bool {
return err == nil
}
+func (e *ffmpeg) IsProbeAvailable() bool {
+ if _, err := ffmpegCmd(); err != nil {
+ return false
+ }
+ probeOnce.Do(func() {
+ probePath := ffprobePath(ffmpegPath)
+ if _, err := exec.LookPath(probePath); err == nil {
+ probeAvail = true
+ }
+ })
+ return probeAvail
+}
+
// Version executes ffmpeg -version and extracts the version from the output.
// Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers
func (e *ffmpeg) Version() string {
@@ -108,11 +279,14 @@ func (e *ffmpeg) Version() string {
return parts[2]
}
-func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
+func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) (io.ReadCloser, error) {
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args}
+ if len(input) > 0 {
+ j.input = input[0]
+ }
j.PipeReader, j.out = io.Pipe()
- err := j.start()
+ err := j.start(ctx)
if err != nil {
return nil, err
}
@@ -122,18 +296,25 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
type ffCmd struct {
*io.PipeReader
- out *io.PipeWriter
- args []string
- cmd *exec.Cmd
+ out *io.PipeWriter
+ args []string
+ cmd *exec.Cmd
+ input io.Reader // optional stdin source
+ stderr *bytes.Buffer
}
-func (j *ffCmd) start() error {
- cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
+func (j *ffCmd) start(ctx context.Context) error {
+ cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
+ if j.input != nil {
+ cmd.Stdin = j.input
+ }
+ j.stderr = &bytes.Buffer{}
+ stderrWriter := &limitedWriter{buf: j.stderr, limit: 4096}
if log.IsGreaterOrEqualTo(log.LevelTrace) {
- cmd.Stderr = os.Stderr
+ cmd.Stderr = io.MultiWriter(os.Stderr, stderrWriter)
} else {
- cmd.Stderr = io.Discard
+ cmd.Stderr = stderrWriter
}
j.cmd = cmd
@@ -145,9 +326,12 @@ func (j *ffCmd) start() error {
func (j *ffCmd) wait() {
if err := j.cmd.Wait(); err != nil {
- var exitErr *exec.ExitError
- if errors.As(err, &exitErr) {
- _ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
+ if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
+ errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())
+ if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" {
+ errMsg += ": " + stderrOutput
+ }
+ _ = j.out.CloseWithError(errors.New(errMsg))
} else {
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
}
@@ -156,16 +340,172 @@ func (j *ffCmd) wait() {
_ = j.out.Close()
}
+// limitedWriter wraps a bytes.Buffer and stops writing once the limit is reached.
+// Writes that would exceed the limit are silently discarded to prevent unbounded memory usage.
+type limitedWriter struct {
+ buf *bytes.Buffer
+ limit int
+}
+
+func (w *limitedWriter) Write(p []byte) (int, error) {
+ n := len(p)
+ remaining := w.limit - w.buf.Len()
+ if remaining <= 0 {
+ return n, nil // Discard but report success to avoid breaking the writer
+ }
+ if len(p) > remaining {
+ p = p[:remaining]
+ }
+ w.buf.Write(p)
+ return n, nil // Always report full write to avoid ErrShortWrite from io.MultiWriter
+}
+
+// formatCodecMap maps target format to ffmpeg codec flag.
+var formatCodecMap = map[string]string{
+ "mp3": "libmp3lame",
+ "opus": "libopus",
+ "aac": "aac",
+ "flac": "flac",
+}
+
+// formatOutputMap maps target format to ffmpeg output format flag (-f).
+var formatOutputMap = map[string]string{
+ "mp3": "mp3",
+ "opus": "opus",
+ "aac": "adts",
+ "flac": "flac",
+}
+
+// defaultCommands is used to detect whether a user has customized their transcoding command.
+var defaultCommands = func() map[string]string {
+ m := make(map[string]string, len(consts.DefaultTranscodings))
+ for _, t := range consts.DefaultTranscodings {
+ m[t.TargetFormat] = t.Command
+ }
+ return m
+}()
+
+// isDefaultCommand returns true if the command matches the known default for this format.
+func isDefaultCommand(format, command string) bool {
+ return defaultCommands[format] == command
+}
+
+// buildDynamicArgs programmatically constructs ffmpeg arguments for known formats,
+// including all transcoding parameters (bitrate, sample rate, channels).
+func buildDynamicArgs(opts TranscodeOptions) []string {
+ cmdPath, _ := ffmpegCmd()
+ args := []string{cmdPath}
+
+ if opts.Offset > 0 {
+ args = append(args, "-ss", strconv.Itoa(opts.Offset))
+ }
+
+ args = append(args, "-i", opts.FilePath)
+ args = append(args, "-map", "0:a:0")
+
+ if codec, ok := formatCodecMap[opts.Format]; ok {
+ args = append(args, "-c:a", codec)
+ }
+
+ if opts.BitRate > 0 {
+ args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k")
+ }
+ args = injectDynamicAudioFlags(args, opts)
+
+ args = append(args, "-v", "0")
+
+ if outputFmt, ok := formatOutputMap[opts.Format]; ok {
+ args = append(args, "-f", outputFmt)
+ }
+
+ args = append(args, "-")
+ return args
+}
+
+// buildTemplateArgs handles user-customized command templates, with dynamic injection
+// of sample rate, channels, and bit depth when requested by the transcode decision.
+// Values in opts have already been clamped to codec limits upstream (see
+// core/stream/codec.go codecMax* helpers), so injecting them unconditionally is safe —
+// ffmpeg honors the last occurrence of a duplicate flag.
+func buildTemplateArgs(opts TranscodeOptions) []string {
+ args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset)
+ return injectDynamicAudioFlags(args, opts)
+}
+
+// injectDynamicAudioFlags appends -ar, -ac, and -sample_fmt flags based on opts.
+// Only passes -sample_fmt for lossless output formats where bit depth matters:
+// lossy codecs (mp3, aac, opus) handle sample format conversion internally, and
+// passing interleaved formats like "s16" causes silent failures.
+func injectDynamicAudioFlags(args []string, opts TranscodeOptions) []string {
+ if opts.SampleRate > 0 {
+ args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
+ }
+ if opts.Channels > 0 {
+ args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels))
+ }
+ if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
+ args = injectBeforeOutput(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
+ }
+ return args
+}
+
+// injectBeforeOutput inserts a flag and value before the trailing "-" (stdout output).
+func injectBeforeOutput(args []string, flag, value string) []string {
+ if len(args) > 0 && args[len(args)-1] == "-" {
+ result := make([]string, 0, len(args)+2)
+ result = append(result, args[:len(args)-1]...)
+ result = append(result, flag, value, "-")
+ return result
+ }
+ return append(args, flag, value)
+}
+
+// isLosslessOutputFormat returns true if the format is a lossless audio format
+// where preserving bit depth via -sample_fmt is meaningful.
+// Note: this covers only formats ffmpeg can produce as output. For the full set of
+// lossless formats used in transcoding decisions, see core/stream/codec.go:isLosslessFormat.
+func isLosslessOutputFormat(format string) bool {
+ switch strings.ToLower(format) {
+ case "flac", "alac", "wav", "aiff":
+ return true
+ }
+ return false
+}
+
+// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string.
+// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format
+// (ffmpeg packs 24-bit samples into 32-bit containers).
+func bitDepthToSampleFmt(bitDepth int) string {
+ switch bitDepth {
+ case 16:
+ return "s16"
+ case 32:
+ return "s32"
+ default:
+ // 24-bit and other depths: use s32 (the next valid container size)
+ return "s32"
+ }
+}
+
// Path will always be an absolute path
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
var args []string
for _, s := range fixCmd(cmd) {
if strings.Contains(s, "%s") {
+ if offset > 0 && !strings.Contains(cmd, "%t") {
+ // Pre-input seeking: ffmpeg seeks at the demuxer level (fast)
+ // instead of decoding all frames up to the offset (slow).
+ insertAt := len(args)
+ for i, arg := range slices.Backward(args) {
+ if arg == "-i" {
+ insertAt = i
+ break
+ }
+ }
+ args = slices.Insert(args, insertAt, "-ss", strconv.Itoa(offset))
+ }
s = strings.ReplaceAll(s, "%s", path)
args = append(args, s)
- if offset > 0 && !strings.Contains(cmd, "%t") {
- args = append(args, "-ss", strconv.Itoa(offset))
- }
} else {
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
@@ -196,10 +536,20 @@ func fixCmd(cmd string) []string {
if s == "ffmpeg" || s == "ffmpeg.exe" {
split[i] = cmdPath
}
+ if s == "ffprobe" || s == "ffprobe.exe" {
+ split[i] = ffprobePath(cmdPath)
+ }
}
return split
}
+// ffprobePath derives the ffprobe binary path from the resolved ffmpeg path.
+func ffprobePath(ffmpegCmd string) string {
+ dir := filepath.Dir(ffmpegCmd)
+ base := filepath.Base(ffmpegCmd)
+ return filepath.Join(dir, strings.Replace(base, "ffmpeg", "ffprobe", 1))
+}
+
func ffmpegCmd() (string, error) {
ffOnce.Do(func() {
if conf.Server.FFmpegPath != "" {
@@ -220,9 +570,55 @@ func ffmpegCmd() (string, error) {
return ffmpegPath, ffmpegErr
}
+type encoderProbeState uint8
+
+const (
+ encoderProbeUnknown encoderProbeState = iota
+ encoderProbeAvailable
+ encoderProbeUnavailable
+)
+
+type encoderProbe struct {
+ mu sync.Mutex
+ state encoderProbeState
+}
+
+func (p *encoderProbe) has(cmdPath, encoder string) bool {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ switch p.state {
+ case encoderProbeAvailable:
+ return true
+ case encoderProbeUnavailable:
+ return false
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, cmdPath, "-hide_banner", "-encoders").Output() // #nosec
+ if err != nil {
+ log.Warn(ctx, "Could not probe ffmpeg encoders; will retry on next animated cover", err)
+ return false
+ }
+
+ if parseEncodersOutput(out, encoder) {
+ p.state = encoderProbeAvailable
+ return true
+ }
+
+ p.state = encoderProbeUnavailable
+ log.Warn(ctx, "ffmpeg has no libwebp_anim encoder; animated covers will be served as static images",
+ "path", cmdPath, "hint", "install ffmpeg built with libwebp (e.g. `brew install ffmpeg@7`)")
+ return false
+}
+
// These variables are accessible here for tests. Do not use them directly in production code. Use ffmpegCmd() instead.
var (
ffOnce sync.Once
ffmpegPath string
ffmpegErr error
+ probeOnce sync.Once
+ probeAvail bool
+ animWebP encoderProbe
)
diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go
index 7e67a2a6a..2e2895738 100644
--- a/core/ffmpeg/ffmpeg_test.go
+++ b/core/ffmpeg/ffmpeg_test.go
@@ -1,16 +1,30 @@
package ffmpeg
import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
"testing"
+ "time"
+ "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestFFmpeg(t *testing.T) {
- tests.Init(t, false)
+ // Inline test init to avoid import cycle with tests package
+ //nolint:dogsled
+ _, file, _, _ := runtime.Caller(0)
+ appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
+ confPath := filepath.Join(appPath, "tests", "navidrome-test.toml")
+ _ = os.Chdir(appPath)
+ conf.LoadFromFile(confPath)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "FFmpeg Suite")
@@ -33,15 +47,15 @@ var _ = Describe("ffmpeg", func() {
})
Context("when command has time offset param", func() {
It("creates a valid command line with offset", func() {
- args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456)
- Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"}))
+ args := createFFmpegCommand("ffmpeg -ss %t -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
+ Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
})
Context("when command does not have time offset param", func() {
- It("adds time offset after the input file name", func() {
+ It("adds time offset before the input file name", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
- Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
+ Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
})
})
@@ -65,4 +79,674 @@ var _ = Describe("ffmpeg", func() {
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
})
})
+
+ Describe("isDefaultCommand", func() {
+ It("returns true for known default mp3 command", func() {
+ Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
+ })
+ It("returns true for known default opus command", func() {
+ Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
+ })
+ It("returns true for known default aac command", func() {
+ Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
+ })
+ It("returns true for known default flac command", func() {
+ Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
+ })
+ It("returns false for a custom command", func() {
+ Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
+ })
+ It("returns false for unknown format", func() {
+ Expect(isDefaultCommand("wav", "ffmpeg -i %s -f wav -")).To(BeFalse())
+ })
+ })
+
+ Describe("buildDynamicArgs", func() {
+ It("builds mp3 args with bitrate, samplerate, and channels", func() {
+ args := buildDynamicArgs(TranscodeOptions{
+ Format: "mp3",
+ FilePath: "/music/file.flac",
+ BitRate: 256,
+ SampleRate: 48000,
+ Channels: 2,
+ })
+ Expect(args).To(Equal([]string{
+ "ffmpeg", "-i", "/music/file.flac",
+ "-map", "0:a:0",
+ "-c:a", "libmp3lame",
+ "-b:a", "256k",
+ "-ar", "48000",
+ "-ac", "2",
+ "-v", "0",
+ "-f", "mp3",
+ "-",
+ }))
+ })
+
+ It("builds flac args without bitrate", func() {
+ args := buildDynamicArgs(TranscodeOptions{
+ Format: "flac",
+ FilePath: "/music/file.dsf",
+ SampleRate: 48000,
+ })
+ Expect(args).To(Equal([]string{
+ "ffmpeg", "-i", "/music/file.dsf",
+ "-map", "0:a:0",
+ "-c:a", "flac",
+ "-ar", "48000",
+ "-v", "0",
+ "-f", "flac",
+ "-",
+ }))
+ })
+
+ It("builds opus args with bitrate only", func() {
+ args := buildDynamicArgs(TranscodeOptions{
+ Format: "opus",
+ FilePath: "/music/file.flac",
+ BitRate: 128,
+ })
+ Expect(args).To(Equal([]string{
+ "ffmpeg", "-i", "/music/file.flac",
+ "-map", "0:a:0",
+ "-c:a", "libopus",
+ "-b:a", "128k",
+ "-v", "0",
+ "-f", "opus",
+ "-",
+ }))
+ })
+
+ It("includes offset when specified", func() {
+ args := buildDynamicArgs(TranscodeOptions{
+ Format: "mp3",
+ FilePath: "/music/file.mp3",
+ BitRate: 192,
+ Offset: 30,
+ })
+ Expect(args).To(Equal([]string{
+ "ffmpeg",
+ "-ss", "30",
+ "-i", "/music/file.mp3",
+ "-map", "0:a:0",
+ "-c:a", "libmp3lame",
+ "-b:a", "192k",
+ "-v", "0",
+ "-f", "mp3",
+ "-",
+ }))
+ })
+
+ It("builds aac args with ADTS output", func() {
+ args := buildDynamicArgs(TranscodeOptions{
+ Format: "aac",
+ FilePath: "/music/file.flac",
+ BitRate: 256,
+ })
+ Expect(args).To(Equal([]string{
+ "ffmpeg", "-i", "/music/file.flac",
+ "-map", "0:a:0",
+ "-c:a", "aac",
+ "-b:a", "256k",
+ "-v", "0",
+ "-f", "adts",
+ "-",
+ }))
+ })
+
+ It("builds flac args with bit depth", func() {
+ args := buildDynamicArgs(TranscodeOptions{
+ Format: "flac",
+ FilePath: "/music/file.dsf",
+ BitDepth: 24,
+ })
+ Expect(args).To(Equal([]string{
+ "ffmpeg", "-i", "/music/file.dsf",
+ "-map", "0:a:0",
+ "-c:a", "flac",
+ "-sample_fmt", "s32",
+ "-v", "0",
+ "-f", "flac",
+ "-",
+ }))
+ })
+
+ It("omits -sample_fmt when bit depth is 0", func() {
+ args := buildDynamicArgs(TranscodeOptions{
+ Format: "flac",
+ FilePath: "/music/file.flac",
+ BitDepth: 0,
+ })
+ Expect(args).ToNot(ContainElement("-sample_fmt"))
+ })
+
+ It("omits -sample_fmt when bit depth is too low (DSD)", func() {
+ args := buildDynamicArgs(TranscodeOptions{
+ Format: "flac",
+ FilePath: "/music/file.dsf",
+ BitDepth: 1,
+ })
+ Expect(args).ToNot(ContainElement("-sample_fmt"))
+ })
+
+ DescribeTable("omits -sample_fmt for lossy formats even when bit depth >= 16",
+ func(format string, bitRate int) {
+ args := buildDynamicArgs(TranscodeOptions{
+ Format: format,
+ FilePath: "/music/file.flac",
+ BitRate: bitRate,
+ BitDepth: 16,
+ })
+ Expect(args).ToNot(ContainElement("-sample_fmt"))
+ },
+ Entry("mp3", "mp3", 256),
+ Entry("aac", "aac", 256),
+ Entry("opus", "opus", 128),
+ )
+ })
+
+ Describe("bitDepthToSampleFmt", func() {
+ It("converts 16-bit", func() {
+ Expect(bitDepthToSampleFmt(16)).To(Equal("s16"))
+ })
+ It("converts 24-bit to s32 (FLAC only supports s16/s32)", func() {
+ Expect(bitDepthToSampleFmt(24)).To(Equal("s32"))
+ })
+ It("converts 32-bit", func() {
+ Expect(bitDepthToSampleFmt(32)).To(Equal("s32"))
+ })
+ })
+
+ Describe("buildTemplateArgs", func() {
+ It("injects -ar and -ac into custom template", func() {
+ args := buildTemplateArgs(TranscodeOptions{
+ Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
+ FilePath: "/music/file.flac",
+ BitRate: 192,
+ SampleRate: 44100,
+ Channels: 2,
+ })
+ Expect(args).To(Equal([]string{
+ "ffmpeg", "-i", "/music/file.flac",
+ "-b:a", "192k", "-v", "0", "-f", "mp3",
+ "-ar", "44100", "-ac", "2",
+ "-",
+ }))
+ })
+
+ It("injects only -ar when channels is 0", func() {
+ args := buildTemplateArgs(TranscodeOptions{
+ Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
+ FilePath: "/music/file.flac",
+ BitRate: 192,
+ SampleRate: 48000,
+ })
+ Expect(args).To(Equal([]string{
+ "ffmpeg", "-i", "/music/file.flac",
+ "-b:a", "192k", "-v", "0", "-f", "mp3",
+ "-ar", "48000",
+ "-",
+ }))
+ })
+
+ It("does not inject anything when sample rate and channels are 0", func() {
+ args := buildTemplateArgs(TranscodeOptions{
+ Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
+ FilePath: "/music/file.flac",
+ BitRate: 192,
+ })
+ Expect(args).To(Equal([]string{
+ "ffmpeg", "-i", "/music/file.flac",
+ "-b:a", "192k", "-v", "0", "-f", "mp3",
+ "-",
+ }))
+ })
+
+ It("injects -sample_fmt for lossless output format with bit depth", func() {
+ args := buildTemplateArgs(TranscodeOptions{
+ Command: "ffmpeg -i %s -v 0 -c:a flac -f flac -",
+ Format: "flac",
+ FilePath: "/music/file.dsf",
+ BitDepth: 24,
+ })
+ Expect(args).To(Equal([]string{
+ "ffmpeg", "-i", "/music/file.dsf",
+ "-v", "0", "-c:a", "flac", "-f", "flac",
+ "-sample_fmt", "s32",
+ "-",
+ }))
+ })
+
+ It("does not inject -sample_fmt for lossy output format even with bit depth", func() {
+ args := buildTemplateArgs(TranscodeOptions{
+ Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
+ Format: "mp3",
+ FilePath: "/music/file.flac",
+ BitRate: 192,
+ BitDepth: 16,
+ })
+ Expect(args).To(Equal([]string{
+ "ffmpeg", "-i", "/music/file.flac",
+ "-b:a", "192k", "-v", "0", "-f", "mp3",
+ "-",
+ }))
+ })
+ })
+
+ Describe("injectBeforeOutput", func() {
+ It("inserts flag before trailing dash", func() {
+ args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-"}, "-ar", "48000")
+ Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-ar", "48000", "-"}))
+ })
+
+ It("appends when no trailing dash", func() {
+ args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3"}, "-ar", "48000")
+ Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-ar", "48000"}))
+ })
+ })
+
+ Describe("parseProbeOutput", func() {
+ It("parses MP3 with embedded artwork (real ffprobe output)", func() {
+ // Real: MP3 file with mjpeg artwork stream after audio
+ data := []byte(`{"streams":[` +
+ `{"index":0,"codec_name":"mp3","codec_long_name":"MP3 (MPEG audio layer 3)","codec_type":"audio",` +
+ `"sample_fmt":"fltp","sample_rate":"44100","channels":2,"channel_layout":"stereo",` +
+ `"bits_per_sample":0,"bit_rate":"198314","tags":{"encoder":"LAME3.99r"}},` +
+ `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline","width":400,"height":400}]}`)
+ result, err := parseProbeOutput(data)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result.Codec).To(Equal("mp3"))
+ Expect(result.Profile).To(BeEmpty()) // MP3 has no profile field
+ Expect(result.SampleRate).To(Equal(44100))
+ Expect(result.Channels).To(Equal(2))
+ Expect(result.BitRate).To(Equal(198)) // 198314 bps -> 198 kbps
+ Expect(result.BitDepth).To(Equal(0)) // lossy codec
+ })
+
+ It("parses AAC-LC in m4a container (real ffprobe output)", func() {
+ // Real: AAC LC file with profile and artwork
+ data := []byte(`{"streams":[` +
+ `{"index":0,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` +
+ `"profile":"LC","codec_type":"audio","sample_fmt":"fltp","sample_rate":"44100",` +
+ `"channels":2,"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"279958"},` +
+ `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
+ result, err := parseProbeOutput(data)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result.Codec).To(Equal("aac"))
+ Expect(result.Profile).To(Equal("LC"))
+ Expect(result.SampleRate).To(Equal(44100))
+ Expect(result.Channels).To(Equal(2))
+ Expect(result.BitRate).To(Equal(279)) // 279958 bps -> 279 kbps
+ })
+
+ It("parses HE-AACv2 in mp4 container with video stream (real ffprobe output)", func() {
+ // Real: Fraunhofer HE-AACv2 sample (LFE-SBRstereo.mp4), video stream before audio
+ data := []byte(`{"streams":[` +
+ `{"index":0,"codec_name":"h264","codec_type":"video","profile":"Main"},` +
+ `{"index":1,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` +
+ `"profile":"HE-AACv2","codec_type":"audio","sample_fmt":"fltp",` +
+ `"sample_rate":"48000","channels":2,"channel_layout":"stereo",` +
+ `"bits_per_sample":0,"bit_rate":"55999"}]}`)
+ result, err := parseProbeOutput(data)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result.Codec).To(Equal("aac"))
+ Expect(result.Profile).To(Equal("HE-AACv2"))
+ Expect(result.SampleRate).To(Equal(48000))
+ Expect(result.Channels).To(Equal(2))
+ Expect(result.BitRate).To(Equal(55)) // 55999 bps -> 55 kbps
+ })
+
+ It("parses FLAC using bits_per_raw_sample and format-level bit_rate (real ffprobe output)", func() {
+ // Real: FLAC reports bit depth in bits_per_raw_sample, not bits_per_sample.
+ // Stream-level bit_rate is absent; format-level bit_rate is used as fallback.
+ data := []byte(`{"streams":[` +
+ `{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` +
+ `"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` +
+ `"channel_layout":"stereo","bits_per_sample":0,"bits_per_raw_sample":"16"},` +
+ `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}],` +
+ `"format":{"bit_rate":"906900"}}`)
+ result, err := parseProbeOutput(data)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result.Codec).To(Equal("flac"))
+ Expect(result.SampleRate).To(Equal(44100))
+ Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample
+ Expect(result.BitRate).To(Equal(906)) // format-level: 906900 bps -> 906 kbps
+ Expect(result.Profile).To(BeEmpty()) // no profile field in real output
+ })
+
+ It("parses Opus with format-level bit_rate fallback (real ffprobe output)", func() {
+ // Real: Opus stream-level bit_rate is absent; format-level is used as fallback.
+ data := []byte(`{"streams":[` +
+ `{"index":0,"codec_name":"opus","codec_long_name":"Opus (Opus Interactive Audio Codec)",` +
+ `"codec_type":"audio","sample_fmt":"fltp","sample_rate":"48000","channels":2,` +
+ `"channel_layout":"stereo","bits_per_sample":0}],` +
+ `"format":{"bit_rate":"128000"}}`)
+ result, err := parseProbeOutput(data)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result.Codec).To(Equal("opus"))
+ Expect(result.SampleRate).To(Equal(48000))
+ Expect(result.Channels).To(Equal(2))
+ Expect(result.BitRate).To(Equal(128)) // format-level: 128000 bps -> 128 kbps
+ Expect(result.BitDepth).To(Equal(0))
+ })
+
+ It("parses WAV/PCM with bits_per_sample (real ffprobe output)", func() {
+ // Real: WAV uses bits_per_sample directly
+ data := []byte(`{"streams":[` +
+ `{"index":0,"codec_name":"pcm_s16le","codec_long_name":"PCM signed 16-bit little-endian",` +
+ `"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` +
+ `"bits_per_sample":16,"bit_rate":"1411200"}]}`)
+ result, err := parseProbeOutput(data)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result.Codec).To(Equal("pcm_s16le"))
+ Expect(result.SampleRate).To(Equal(44100))
+ Expect(result.Channels).To(Equal(2))
+ Expect(result.BitDepth).To(Equal(16))
+ Expect(result.BitRate).To(Equal(1411))
+ })
+
+ It("parses ALAC in m4a container (real ffprobe output)", func() {
+ // Real: Beatles - You Can't Do That (2023 Mix), ALAC 16-bit
+ data := []byte(`{"streams":[` +
+ `{"index":0,"codec_name":"alac","codec_long_name":"ALAC (Apple Lossless Audio Codec)",` +
+ `"codec_type":"audio","sample_fmt":"s16p","sample_rate":"44100","channels":2,` +
+ `"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"1011003",` +
+ `"bits_per_raw_sample":"16"},` +
+ `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
+ result, err := parseProbeOutput(data)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result.Codec).To(Equal("alac"))
+ Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample
+ Expect(result.SampleRate).To(Equal(44100))
+ Expect(result.Channels).To(Equal(2))
+ Expect(result.BitRate).To(Equal(1011)) // 1011003 bps -> 1011 kbps
+ })
+
+ It("skips video-only streams", func() {
+ data := []byte(`{"streams":[{"index":0,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
+ _, err := parseProbeOutput(data)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("no audio stream"))
+ })
+
+ It("returns error for empty streams array", func() {
+ data := []byte(`{"streams":[]}`)
+ _, err := parseProbeOutput(data)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("returns error for invalid JSON", func() {
+ data := []byte(`not json`)
+ _, err := parseProbeOutput(data)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("parses HiRes multichannel FLAC with format-level bit_rate (real ffprobe output)", func() {
+ // Real: Pink Floyd - 192kHz/24-bit/7.1 surround FLAC
+ data := []byte(`{"streams":[` +
+ `{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` +
+ `"codec_type":"audio","sample_fmt":"s32","sample_rate":"192000","channels":8,` +
+ `"channel_layout":"7.1","bits_per_sample":0,"bits_per_raw_sample":"24"},` +
+ `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Progressive"}],` +
+ `"format":{"bit_rate":"18432000"}}`)
+ result, err := parseProbeOutput(data)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result.Codec).To(Equal("flac"))
+ Expect(result.SampleRate).To(Equal(192000))
+ Expect(result.BitDepth).To(Equal(24))
+ Expect(result.Channels).To(Equal(8))
+ Expect(result.BitRate).To(Equal(18432)) // format-level: 18432000 bps -> 18432 kbps
+ })
+
+ It("parses DSD/DSF file (real ffprobe output)", func() {
+ // Real: Yes - Owner of a Lonely Heart, DSD64 DSF
+ data := []byte(`{"streams":[` +
+ `{"index":0,"codec_name":"dsd_lsbf_planar",` +
+ `"codec_long_name":"DSD (Direct Stream Digital), least significant bit first, planar",` +
+ `"codec_type":"audio","sample_fmt":"fltp","sample_rate":"352800","channels":2,` +
+ `"channel_layout":"stereo","bits_per_sample":8,"bit_rate":"5644800"},` +
+ `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
+ result, err := parseProbeOutput(data)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result.Codec).To(Equal("dsd_lsbf_planar"))
+ Expect(result.BitDepth).To(Equal(8)) // DSD reports 8 bits_per_sample
+ Expect(result.SampleRate).To(Equal(352800)) // DSD64 sample rate
+ Expect(result.Channels).To(Equal(2))
+ Expect(result.BitRate).To(Equal(5644)) // 5644800 bps -> 5644 kbps
+ })
+
+ It("prefers stream-level bit_rate over format-level when both are present", func() {
+ // ALAC/DSD: stream has bit_rate, format also has bit_rate — stream wins
+ data := []byte(`{"streams":[` +
+ `{"index":0,"codec_name":"alac","codec_type":"audio","sample_fmt":"s16p",` +
+ `"sample_rate":"44100","channels":2,"bits_per_sample":0,` +
+ `"bit_rate":"1011003","bits_per_raw_sample":"16"}],` +
+ `"format":{"bit_rate":"1050000"}}`)
+ result, err := parseProbeOutput(data)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result.BitRate).To(Equal(1011)) // stream-level: 1011003 bps -> 1011 kbps (not format's 1050)
+ })
+
+ It("returns BitRate 0 when neither stream nor format has bit_rate", func() {
+ data := []byte(`{"streams":[` +
+ `{"index":0,"codec_name":"flac","codec_type":"audio","sample_fmt":"s16",` +
+ `"sample_rate":"44100","channels":2,"bits_per_sample":0,"bits_per_raw_sample":"16"}],` +
+ `"format":{}}`)
+ result, err := parseProbeOutput(data)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result.BitRate).To(Equal(0))
+ })
+
+ It("clears 'unknown' profile to empty string", func() {
+ data := []byte(`{"streams":[{"index":0,"codec_name":"flac",` +
+ `"codec_type":"audio","profile":"unknown","sample_rate":"44100",` +
+ `"channels":2,"bits_per_sample":0}]}`)
+ result, err := parseProbeOutput(data)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result.Profile).To(BeEmpty())
+ })
+ })
+
+ Describe("FFmpeg", func() {
+ Context("when FFmpeg is available", func() {
+ var ff FFmpeg
+
+ BeforeEach(func() {
+ ffOnce = sync.Once{}
+ ff = New()
+ // Skip if FFmpeg is not available
+ if !ff.IsAvailable() {
+ Skip("FFmpeg not available on this system")
+ }
+ })
+
+ It("should interrupt transcoding when context is cancelled", func() {
+ ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
+ defer cancel()
+
+ // Use a command that generates audio indefinitely
+ // -f lavfi uses FFmpeg's built-in audio source
+ // -t 0 means no time limit (runs forever)
+ command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
+
+ // The input file is not used here, but we need to provide a valid path to the Transcode function
+ stream, err := ff.Transcode(ctx, TranscodeOptions{
+ Command: command,
+ Format: "mp3",
+ FilePath: "tests/fixtures/test.mp3",
+ BitRate: 128,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ defer stream.Close()
+
+ // Read some data first to ensure FFmpeg is running
+ buf := make([]byte, 1024)
+ _, err = stream.Read(buf)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Cancel the context
+ cancel()
+
+ // Subsequent reads should eventually fail due to cancelled context.
+ // There may be buffered data in the pipe, so we drain until an error occurs.
+ Eventually(func() error {
+ _, err = stream.Read(buf)
+ return err
+ }).WithTimeout(5 * time.Second).WithPolling(10 * time.Millisecond).Should(HaveOccurred())
+ })
+
+ It("should handle immediate context cancellation", func() {
+ ctx, cancel := context.WithCancel(GinkgoT().Context())
+ cancel() // Cancel immediately
+
+ // This should fail immediately
+ _, err := ff.Transcode(ctx, TranscodeOptions{
+ Command: "ffmpeg -i %s -f mp3 -",
+ Format: "mp3",
+ FilePath: "tests/fixtures/test.mp3",
+ BitRate: 128,
+ })
+ Expect(err).To(MatchError(context.Canceled))
+ })
+ })
+
+ Context("stderr capture", func() {
+ BeforeEach(func() {
+ if runtime.GOOS == "windows" {
+ Skip("stderr capture tests use /bin/sh, skipping on Windows")
+ }
+ })
+
+ It("should include stderr in error when process fails", func() {
+ ff := &ffmpeg{}
+ ctx := GinkgoT().Context()
+
+ // Directly call start() with a bash command that writes to stderr and fails
+ args := []string{"/bin/sh", "-c", "echo 'codec not found: libopus' >&2; exit 1"}
+ stream, err := ff.start(ctx, args)
+ Expect(err).ToNot(HaveOccurred())
+ defer stream.Close()
+
+ buf := make([]byte, 1024)
+ _, err = stream.Read(buf)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("codec not found: libopus"))
+ })
+
+ It("should not include stderr in error when process succeeds", func() {
+ ff := &ffmpeg{}
+ ctx := GinkgoT().Context()
+
+ // Command that writes to stderr but exits successfully
+ args := []string{"/bin/sh", "-c", "echo 'warning: something' >&2; printf 'output'"}
+ stream, err := ff.start(ctx, args)
+ Expect(err).ToNot(HaveOccurred())
+ defer stream.Close()
+
+ buf := make([]byte, 1024)
+ n, err := stream.Read(buf)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(buf[:n])).To(Equal("output"))
+ })
+ })
+
+ Context("with mock process behavior", func() {
+ var longRunningCmd string
+ BeforeEach(func() {
+ // Use a long-running command for testing cancellation
+ switch runtime.GOOS {
+ case "windows":
+ // Use PowerShell's Start-Sleep
+ ffmpegPath = "powershell"
+ longRunningCmd = "powershell -Command Start-Sleep -Seconds 10"
+ default:
+ // Use sleep on Unix-like systems
+ ffmpegPath = "sleep"
+ longRunningCmd = "sleep 10"
+ }
+ })
+
+ It("should terminate the underlying process when context is cancelled", func() {
+ ff := New()
+ ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
+ defer cancel()
+
+ // Start a process that will run for a while
+ stream, err := ff.Transcode(ctx, TranscodeOptions{
+ Command: longRunningCmd,
+ FilePath: "tests/fixtures/test.mp3",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ defer stream.Close()
+
+ // Give the process time to start
+ time.Sleep(50 * time.Millisecond)
+
+ // Cancel the context
+ cancel()
+
+ // Try to read from the stream, which should fail
+ buf := make([]byte, 100)
+ _, err = stream.Read(buf)
+ Expect(err).To(HaveOccurred(), "Expected stream to be closed due to process termination")
+
+ // Verify the stream is closed by attempting another read
+ _, err = stream.Read(buf)
+ Expect(err).To(HaveOccurred())
+ })
+ })
+ })
+
+ Describe("parseEncodersOutput", func() {
+ const sample = `Encoders:
+ V..... = Video
+ ------
+ V....D apng APNG (Animated Portable Network Graphics) image
+ V....D libwebp_anim libwebp WebP image (codec webp)
+ V....D libwebp libwebp WebP image (codec webp)
+ A....D aac AAC (Advanced Audio Coding)
+`
+ It("returns true when the encoder is present", func() {
+ Expect(parseEncodersOutput([]byte(sample), "libwebp_anim")).To(BeTrue())
+ Expect(parseEncodersOutput([]byte(sample), "libwebp")).To(BeTrue())
+ Expect(parseEncodersOutput([]byte(sample), "aac")).To(BeTrue())
+ })
+ It("returns false when the encoder is absent", func() {
+ Expect(parseEncodersOutput([]byte(sample), "libwebp_missing")).To(BeFalse())
+ Expect(parseEncodersOutput([]byte(sample), "")).To(BeFalse())
+ })
+ It("does not match partial names", func() {
+ // libwebp is a prefix of libwebp_anim; the parser must treat names as whole-word.
+ stripped := `Encoders:
+ V....D libwebp libwebp WebP image (codec webp)
+`
+ Expect(parseEncodersOutput([]byte(stripped), "libwebp_anim")).To(BeFalse())
+ })
+ It("handles empty output", func() {
+ Expect(parseEncodersOutput(nil, "libwebp_anim")).To(BeFalse())
+ Expect(parseEncodersOutput([]byte(""), "libwebp_anim")).To(BeFalse())
+ })
+ })
+
+ Describe("ConvertAnimatedImage", func() {
+ // Point ffmpegCmd at a stand-in binary that produces empty `-encoders`
+ // output so hasAnimatedWebPEncoder returns false. /usr/bin/true is
+ // portable across POSIX systems.
+ It("returns ErrAnimatedWebPUnsupported when the binary lacks libwebp_anim", func() {
+ truePath, err := exec.LookPath("true")
+ if err != nil {
+ Skip("true(1) not available")
+ }
+ origPath, origErr := ffmpegPath, ffmpegErr
+ ffmpegPath = truePath
+ ffmpegErr = nil
+ defer func() {
+ ffmpegPath, ffmpegErr = origPath, origErr
+ }()
+
+ ff := &ffmpeg{}
+ _, err = ff.ConvertAnimatedImage(GinkgoT().Context(), strings.NewReader("x"), 100, 75)
+ Expect(err).To(MatchError(ErrAnimatedWebPUnsupported))
+ })
+ })
})
diff --git a/core/image_upload.go b/core/image_upload.go
new file mode 100644
index 000000000..c2432b647
--- /dev/null
+++ b/core/image_upload.go
@@ -0,0 +1,71 @@
+package core
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils"
+)
+
+type ImageUploadService interface {
+ SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
+ RemoveImage(ctx context.Context, path string) error
+}
+
+type imageUploadService struct{}
+
+func NewImageUploadService() ImageUploadService {
+ return &imageUploadService{}
+}
+
+func (s *imageUploadService) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) {
+ filename := imageFilename(entityID, name, ext)
+ absPath := model.UploadedImagePath(entityType, filename)
+
+ if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
+ return "", fmt.Errorf("creating image directory: %w", err)
+ }
+
+ // Remove old image if it exists
+ if oldPath != "" {
+ if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
+ log.Warn(ctx, "Failed to remove old image", "path", oldPath, err)
+ }
+ }
+
+ // Save new image
+ f, err := os.Create(absPath)
+ if err != nil {
+ return "", fmt.Errorf("creating image file: %w", err)
+ }
+ defer f.Close()
+
+ if _, err := io.Copy(f, reader); err != nil {
+ return "", fmt.Errorf("writing image file: %w", err)
+ }
+
+ return filename, nil
+}
+
+func (s *imageUploadService) RemoveImage(ctx context.Context, path string) error {
+ if path == "" {
+ return nil
+ }
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("removing image %q: %w", path, err)
+ }
+ return nil
+}
+
+func imageFilename(id, name, ext string) string {
+ clean := utils.CleanFileName(name)
+ if clean == "" {
+ return id + ext
+ }
+ return id + "_" + clean + ext
+}
diff --git a/core/image_upload_test.go b/core/image_upload_test.go
new file mode 100644
index 000000000..265f60a95
--- /dev/null
+++ b/core/image_upload_test.go
@@ -0,0 +1,99 @@
+package core_test
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/core"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("ImageUploadService", func() {
+ var svc core.ImageUploadService
+ var tmpDir string
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ tmpDir = GinkgoT().TempDir()
+ conf.Server.DataFolder = conf.NewDir(tmpDir)
+ svc = core.NewImageUploadService()
+ })
+
+ Describe("SetImage", func() {
+ It("creates directory and saves image file", func() {
+ ctx := context.Background()
+ reader := strings.NewReader("fake image data")
+ filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(filename).To(Equal("ar-1_pink_floyd.jpg"))
+
+ absPath := filepath.Join(tmpDir, "artwork", "artist", "ar-1_pink_floyd.jpg")
+ data, err := os.ReadFile(absPath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("fake image data"))
+ })
+
+ It("falls back to ID-only filename when name cleans to empty", func() {
+ ctx := context.Background()
+ reader := strings.NewReader("data")
+ filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(filename).To(Equal("pl-1.png"))
+ })
+
+ It("removes old image when replacing", func() {
+ ctx := context.Background()
+ oldDir := filepath.Join(tmpDir, "artwork", "artist")
+ Expect(os.MkdirAll(oldDir, 0755)).To(Succeed())
+ oldFile := filepath.Join(oldDir, "ar-1_old.png")
+ Expect(os.WriteFile(oldFile, []byte("old"), 0600)).To(Succeed())
+
+ reader := strings.NewReader("new image")
+ _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "New Name", oldFile, reader, ".jpg")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(oldFile).ToNot(BeAnExistingFile())
+
+ newPath := filepath.Join(oldDir, "ar-1_new_name.jpg")
+ Expect(newPath).To(BeAnExistingFile())
+ })
+
+ It("ignores missing old file without error", func() {
+ ctx := context.Background()
+ reader := strings.NewReader("data")
+ _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg")
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ Describe("RemoveImage", func() {
+ It("removes the file at the given path", func() {
+ ctx := context.Background()
+ dir := filepath.Join(tmpDir, "artwork", "artist")
+ Expect(os.MkdirAll(dir, 0755)).To(Succeed())
+ path := filepath.Join(dir, "ar-1_test.jpg")
+ Expect(os.WriteFile(path, []byte("img"), 0600)).To(Succeed())
+
+ err := svc.RemoveImage(ctx, path)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(path).ToNot(BeAnExistingFile())
+ })
+
+ It("succeeds when file does not exist", func() {
+ ctx := context.Background()
+ err := svc.RemoveImage(ctx, "/nonexistent/file.jpg")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("succeeds with empty path", func() {
+ ctx := context.Background()
+ err := svc.RemoveImage(ctx, "")
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+})
diff --git a/core/inspect.go b/core/inspect.go
index 751cf063f..01ec33760 100644
--- a/core/inspect.go
+++ b/core/inspect.go
@@ -7,7 +7,6 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
- . "github.com/navidrome/navidrome/utils/gg"
)
type InspectOutput struct {
@@ -44,7 +43,7 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e
result := &InspectOutput{
File: filePath,
RawTags: tags[file].Tags,
- MappedTags: P(md.ToMediaFile(libraryId, folderId)),
+ MappedTags: new(md.ToMediaFile(libraryId, folderId)),
}
return result, nil
diff --git a/core/library.go b/core/library.go
new file mode 100644
index 000000000..0bf3be9fa
--- /dev/null
+++ b/core/library.go
@@ -0,0 +1,415 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/core/storage"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/server/events"
+ "github.com/navidrome/navidrome/utils/slice"
+)
+
+// Watcher interface for managing file system watchers
+type Watcher interface {
+ Watch(ctx context.Context, lib *model.Library) error
+ StopWatching(ctx context.Context, libraryID int) error
+}
+
+// Library provides business logic for library management and user-library associations
+type Library interface {
+ GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error)
+ SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error
+ ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error
+
+ NewRepository(ctx context.Context) rest.Repository
+}
+
+type libraryService struct {
+ ds model.DataStore
+ scanner model.Scanner
+ watcher Watcher
+ broker events.Broker
+ pluginManager PluginUnloader
+}
+
+// NewLibrary creates a new Library service
+func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker, pluginManager PluginUnloader) Library {
+ return &libraryService{
+ ds: ds,
+ scanner: scanner,
+ watcher: watcher,
+ broker: broker,
+ pluginManager: pluginManager,
+ }
+}
+
+// User-library association operations
+
+func (s *libraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
+ // Verify user exists
+ if _, err := s.ds.User(ctx).Get(userID); err != nil {
+ return nil, err
+ }
+
+ return s.ds.User(ctx).GetUserLibraries(userID)
+}
+
+func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error {
+ // Verify user exists
+ user, err := s.ds.User(ctx).Get(userID)
+ if err != nil {
+ return err
+ }
+
+ // Admin users get all libraries automatically - don't allow manual assignment
+ if user.IsAdmin {
+ return fmt.Errorf("%w: cannot manually assign libraries to admin users", model.ErrValidation)
+ }
+
+ // Regular users must have at least one library
+ if len(libraryIDs) == 0 {
+ return fmt.Errorf("%w: at least one library must be assigned to non-admin users", model.ErrValidation)
+ }
+
+ // Validate all library IDs exist
+ if len(libraryIDs) > 0 {
+ if err := s.validateLibraryIDs(ctx, libraryIDs); err != nil {
+ return err
+ }
+ }
+
+ // Set user libraries
+ err = s.ds.User(ctx).SetUserLibraries(userID, libraryIDs)
+ if err != nil {
+ return fmt.Errorf("error setting user libraries: %w", err)
+ }
+
+ // Send refresh event to all clients
+ event := &events.RefreshResource{}
+ libIDs := slice.Map(libraryIDs, func(id int) string { return strconv.Itoa(id) })
+ event = event.With("user", userID).With("library", libIDs...)
+ s.broker.SendBroadcastMessage(ctx, event)
+ return nil
+}
+
+func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error {
+ user, ok := request.UserFrom(ctx)
+ if !ok {
+ return fmt.Errorf("user not found in context")
+ }
+
+ // Admin users have access to all libraries
+ if user.IsAdmin {
+ return nil
+ }
+
+ // Check if user has explicit access to this library
+ libraries, err := s.ds.User(ctx).GetUserLibraries(userID)
+ if err != nil {
+ log.Error(ctx, "Error checking library access", "userID", userID, "libraryID", libraryID, err)
+ return fmt.Errorf("error checking library access: %w", err)
+ }
+
+ for _, lib := range libraries {
+ if lib.ID == libraryID {
+ return nil
+ }
+ }
+
+ return fmt.Errorf("%w: user does not have access to library %d", model.ErrNotAuthorized, libraryID)
+}
+
+// REST repository wrapper
+
+func (s *libraryService) NewRepository(ctx context.Context) rest.Repository {
+ repo := s.ds.Library(ctx)
+ wrapper := &libraryRepositoryWrapper{
+ ctx: ctx,
+ LibraryRepository: repo,
+ Repository: repo.(rest.Repository),
+ ds: s.ds,
+ scanner: s.scanner,
+ watcher: s.watcher,
+ broker: s.broker,
+ pluginManager: s.pluginManager,
+ }
+ return wrapper
+}
+
+type libraryRepositoryWrapper struct {
+ rest.Repository
+ model.LibraryRepository
+ ctx context.Context
+ ds model.DataStore
+ scanner model.Scanner
+ watcher Watcher
+ broker events.Broker
+ pluginManager PluginUnloader
+}
+
+func (r *libraryRepositoryWrapper) Save(entity any) (string, error) {
+ lib := entity.(*model.Library)
+ if err := r.validateLibrary(lib); err != nil {
+ return "", err
+ }
+
+ err := r.LibraryRepository.Put(lib)
+ if err != nil {
+ return "", r.mapError(err)
+ }
+
+ // Start watcher and trigger scan after successful library creation
+ if r.watcher != nil {
+ if err := r.watcher.Watch(r.ctx, lib); err != nil {
+ log.Warn(r.ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
+ }
+ }
+
+ if r.scanner != nil {
+ go r.triggerScan(lib, "new")
+ }
+
+ // Send library refresh event to all clients
+ if r.broker != nil {
+ event := &events.RefreshResource{}
+ r.broker.SendBroadcastMessage(r.ctx, event.With("library", strconv.Itoa(lib.ID)))
+ log.Debug(r.ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name)
+ }
+
+ return strconv.Itoa(lib.ID), nil
+}
+
+func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) error {
+ lib := entity.(*model.Library)
+ libID, err := strconv.Atoi(id)
+ if err != nil {
+ return fmt.Errorf("invalid library ID: %s", id)
+ }
+
+ lib.ID = libID
+ if err := r.validateLibrary(lib); err != nil {
+ return err
+ }
+
+ // Get the original library to check if path changed
+ originalLib, err := r.Get(libID)
+ if err != nil {
+ return r.mapError(err)
+ }
+
+ pathChanged := originalLib.Path != lib.Path
+
+ err = r.LibraryRepository.Put(lib)
+ if err != nil {
+ return r.mapError(err)
+ }
+
+ // Restart watcher and trigger scan if path was updated
+ if pathChanged {
+ if r.watcher != nil {
+ if err := r.watcher.Watch(r.ctx, lib); err != nil {
+ log.Warn(r.ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
+ }
+ }
+
+ if r.scanner != nil {
+ go r.triggerScan(lib, "updated")
+ }
+ }
+
+ // Send library refresh event to all clients
+ if r.broker != nil {
+ event := &events.RefreshResource{}
+ r.broker.SendBroadcastMessage(r.ctx, event.With("library", id))
+ log.Debug(r.ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name)
+ }
+
+ return nil
+}
+
+func (r *libraryRepositoryWrapper) Delete(id string) error {
+ libID, err := strconv.Atoi(id)
+ if err != nil {
+ return &rest.ValidationError{Errors: map[string]string{
+ "id": "invalid library ID format",
+ }}
+ }
+
+ // Get library info before deletion for logging
+ lib, err := r.Get(libID)
+ if err != nil {
+ return r.mapError(err)
+ }
+
+ err = r.LibraryRepository.Delete(libID)
+ if err != nil {
+ return r.mapError(err)
+ }
+
+ // Stop watcher and trigger scan after successful library deletion to clean up orphaned data
+ if r.watcher != nil {
+ if err := r.watcher.StopWatching(r.ctx, libID); err != nil {
+ log.Warn(r.ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err)
+ }
+ }
+
+ if r.scanner != nil {
+ go r.triggerScan(lib, "deleted")
+ }
+
+ // Send library refresh event to all clients
+ if r.broker != nil {
+ event := &events.RefreshResource{}
+ r.broker.SendBroadcastMessage(r.ctx, event.With("library", id))
+ log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name)
+ }
+
+ // After successful deletion, check if any plugins were auto-disabled
+ // and need to be unloaded from memory
+ r.pluginManager.UnloadDisabledPlugins(r.ctx)
+
+ return nil
+}
+
+// Helper methods
+
+func (r *libraryRepositoryWrapper) mapError(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ errStr := err.Error()
+
+ // Handle database constraint violations.
+ // TODO: Being tied to react-admin translations is not ideal, but this will probably go away with the new UI/API
+ if strings.Contains(errStr, "UNIQUE constraint failed") {
+ if strings.Contains(errStr, "library.name") {
+ return &rest.ValidationError{Errors: map[string]string{"name": "ra.validation.unique"}}
+ }
+ if strings.Contains(errStr, "library.path") {
+ return &rest.ValidationError{Errors: map[string]string{"path": "ra.validation.unique"}}
+ }
+ }
+
+ switch {
+ case errors.Is(err, model.ErrNotFound):
+ return rest.ErrNotFound
+ case errors.Is(err, model.ErrNotAuthorized):
+ return rest.ErrPermissionDenied
+ default:
+ return err
+ }
+}
+
+func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error {
+ validationErrors := make(map[string]string)
+
+ if library.Name == "" {
+ validationErrors["name"] = "ra.validation.required"
+ }
+
+ if library.Path == "" {
+ validationErrors["path"] = "ra.validation.required"
+ } else {
+ // Validate path format and accessibility
+ if err := r.validateLibraryPath(library); err != nil {
+ validationErrors["path"] = err.Error()
+ }
+ }
+
+ if len(validationErrors) > 0 {
+ return &rest.ValidationError{Errors: validationErrors}
+ }
+
+ return nil
+}
+
+func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) error {
+ // Validate path format
+ if !filepath.IsAbs(library.Path) {
+ return fmt.Errorf("library path must be absolute")
+ }
+
+ // Clean the path to normalize it
+ cleanPath := filepath.Clean(library.Path)
+ library.Path = cleanPath
+
+ // Check if path exists and is accessible using storage abstraction
+ fileStore, err := storage.For(library.Path)
+ if err != nil {
+ return fmt.Errorf("invalid storage scheme: %w", err)
+ }
+
+ fsys, err := fileStore.FS()
+ if err != nil {
+ log.Warn(r.ctx, "Error validating library.path", "path", library.Path, err)
+ return fmt.Errorf("resources.library.validation.pathInvalid")
+ }
+
+ // Check if root directory exists
+ info, err := fs.Stat(fsys, ".")
+ if err != nil {
+ // Parse the error message to check for "not a directory"
+ log.Warn(r.ctx, "Error stating library.path", "path", library.Path, err)
+ errStr := err.Error()
+ if strings.Contains(errStr, "not a directory") ||
+ strings.Contains(errStr, "The directory name is invalid.") {
+ return fmt.Errorf("resources.library.validation.pathNotDirectory")
+ } else if os.IsNotExist(err) {
+ return fmt.Errorf("resources.library.validation.pathNotFound")
+ } else if os.IsPermission(err) {
+ return fmt.Errorf("resources.library.validation.pathNotAccessible")
+ } else {
+ return fmt.Errorf("resources.library.validation.pathInvalid")
+ }
+ }
+
+ if !info.IsDir() {
+ return fmt.Errorf("resources.library.validation.pathNotDirectory")
+ }
+
+ return nil
+}
+
+func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []int) error {
+ if len(libraryIDs) == 0 {
+ return nil
+ }
+
+ // Use CountAll to efficiently validate library IDs exist
+ count, err := s.ds.Library(ctx).CountAll(model.QueryOptions{
+ Filters: squirrel.Eq{"id": libraryIDs},
+ })
+ if err != nil {
+ return fmt.Errorf("error validating library IDs: %w", err)
+ }
+
+ if int(count) != len(libraryIDs) {
+ return fmt.Errorf("%w: one or more library IDs are invalid", model.ErrValidation)
+ }
+
+ return nil
+}
+
+func (r *libraryRepositoryWrapper) triggerScan(lib *model.Library, action string) {
+ log.Info(r.ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
+ start := time.Now()
+ warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for new library
+ if err != nil {
+ log.Error(r.ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
+ } else {
+ log.Info(r.ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))
+ }
+}
diff --git a/core/library_test.go b/core/library_test.go
new file mode 100644
index 000000000..175d9c37d
--- /dev/null
+++ b/core/library_test.go
@@ -0,0 +1,998 @@
+package core_test
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "github.com/deluan/rest"
+ _ "github.com/navidrome/navidrome/adapters/gotaglib" // Register taglib extractor
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core"
+ _ "github.com/navidrome/navidrome/core/storage/local" // Register local storage
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/server/events"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// These tests require the local storage adapter and the taglib extractor to be registered.
+var _ = Describe("Library Service", func() {
+ var service core.Library
+ var ds *tests.MockDataStore
+ var libraryRepo *tests.MockLibraryRepo
+ var userRepo *tests.MockedUserRepo
+ var ctx context.Context
+ var tempDir string
+ var scanner *tests.MockScanner
+ var watcherManager *mockWatcherManager
+ var broker *mockEventBroker
+ var pluginManager *mockPluginUnloader
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+
+ ds = &tests.MockDataStore{}
+ libraryRepo = &tests.MockLibraryRepo{}
+ userRepo = tests.CreateMockUserRepo()
+ ds.MockedLibrary = libraryRepo
+ ds.MockedUser = userRepo
+
+ // Create a mock scanner that tracks calls
+ scanner = tests.NewMockScanner()
+ // Create a mock watcher manager
+ watcherManager = &mockWatcherManager{
+ libraryStates: make(map[int]model.Library),
+ }
+ // Create a mock event broker
+ broker = &mockEventBroker{}
+ // Create a mock plugin unloader
+ pluginManager = &mockPluginUnloader{}
+ service = core.NewLibrary(ds, scanner, watcherManager, broker, pluginManager)
+ ctx = context.Background()
+
+ // Create a temporary directory for testing valid paths
+ var err error
+ tempDir, err = os.MkdirTemp("", "navidrome-library-test-")
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() {
+ os.RemoveAll(tempDir)
+ })
+ })
+
+ Describe("Library CRUD Operations", func() {
+ var repo rest.Persistable
+
+ BeforeEach(func() {
+ r := service.NewRepository(ctx)
+ repo = r.(rest.Persistable)
+ })
+
+ Describe("Create", func() {
+ It("creates a new library successfully", func() {
+ library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
+
+ _, err := repo.Save(library)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(libraryRepo.Data[1].Name).To(Equal("New Library"))
+ Expect(libraryRepo.Data[1].Path).To(Equal(tempDir))
+ })
+
+ It("fails when library name is empty", func() {
+ library := &model.Library{Path: tempDir}
+
+ _, err := repo.Save(library)
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
+ })
+
+ It("fails when library path is empty", func() {
+ library := &model.Library{Name: "Test"}
+
+ _, err := repo.Save(library)
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
+ })
+
+ It("fails when library path is not absolute", func() {
+ library := &model.Library{Name: "Test", Path: "relative/path"}
+
+ _, err := repo.Save(library)
+
+ Expect(err).To(HaveOccurred())
+ var validationErr *rest.ValidationError
+ Expect(errors.As(err, &validationErr)).To(BeTrue())
+ Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
+ })
+
+ Context("Database constraint violations", func() {
+ BeforeEach(func() {
+ // Set up an existing library that will cause constraint violations
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Existing Library", Path: tempDir},
+ })
+ })
+
+ AfterEach(func() {
+ // Reset custom PutFn after each test
+ libraryRepo.PutFn = nil
+ })
+
+ It("handles name uniqueness constraint violation from database", func() {
+ // Create the directory that will be used for the test
+ otherTempDir, err := os.MkdirTemp("", "navidrome-other-")
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { os.RemoveAll(otherTempDir) })
+
+ // Try to create another library with the same name
+ library := &model.Library{ID: 2, Name: "Existing Library", Path: otherTempDir}
+
+ // Mock the repository to return a UNIQUE constraint error
+ libraryRepo.PutFn = func(library *model.Library) error {
+ return errors.New("UNIQUE constraint failed: library.name")
+ }
+
+ _, err = repo.Save(library)
+
+ Expect(err).To(HaveOccurred())
+ var validationErr *rest.ValidationError
+ Expect(errors.As(err, &validationErr)).To(BeTrue())
+ Expect(validationErr.Errors["name"]).To(Equal("ra.validation.unique"))
+ })
+
+ It("handles path uniqueness constraint violation from database", func() {
+ // Try to create another library with the same path
+ library := &model.Library{ID: 2, Name: "Different Library", Path: tempDir}
+
+ // Mock the repository to return a UNIQUE constraint error
+ libraryRepo.PutFn = func(library *model.Library) error {
+ return errors.New("UNIQUE constraint failed: library.path")
+ }
+
+ _, err := repo.Save(library)
+
+ Expect(err).To(HaveOccurred())
+ var validationErr *rest.ValidationError
+ Expect(errors.As(err, &validationErr)).To(BeTrue())
+ Expect(validationErr.Errors["path"]).To(Equal("ra.validation.unique"))
+ })
+ })
+ })
+
+ Describe("Update", func() {
+ BeforeEach(func() {
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Original Library", Path: tempDir},
+ })
+ })
+
+ It("updates an existing library successfully", func() {
+ newTempDir, err := os.MkdirTemp("", "navidrome-library-update-")
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { os.RemoveAll(newTempDir) })
+
+ library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
+
+ err = repo.Update("1", library)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(libraryRepo.Data[1].Name).To(Equal("Updated Library"))
+ Expect(libraryRepo.Data[1].Path).To(Equal(newTempDir))
+ })
+
+ It("fails when library doesn't exist", func() {
+ // Create a unique temporary directory to avoid path conflicts
+ uniqueTempDir, err := os.MkdirTemp("", "navidrome-nonexistent-")
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { os.RemoveAll(uniqueTempDir) })
+
+ library := &model.Library{ID: 999, Name: "Non-existent", Path: uniqueTempDir}
+
+ err = repo.Update("999", library)
+
+ Expect(err).To(HaveOccurred())
+ Expect(err).To(Equal(model.ErrNotFound))
+ })
+
+ It("fails when library name is empty", func() {
+ library := &model.Library{ID: 1, Path: tempDir}
+
+ err := repo.Update("1", library)
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
+ })
+
+ It("cleans and normalizes the path on update", func() {
+ unnormalizedPath := tempDir + "//../" + filepath.Base(tempDir)
+ library := &model.Library{ID: 1, Name: "Updated Library", Path: unnormalizedPath}
+
+ err := repo.Update("1", library)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(libraryRepo.Data[1].Path).To(Equal(filepath.Clean(unnormalizedPath)))
+ })
+
+ It("allows updating library with same name (no change)", func() {
+ // Set up a library
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Test Library", Path: tempDir},
+ })
+
+ // Update the library keeping the same name (should be allowed)
+ library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
+
+ err := repo.Update("1", library)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("allows updating library with same path (no change)", func() {
+ // Set up a library
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Test Library", Path: tempDir},
+ })
+
+ // Update the library keeping the same path (should be allowed)
+ library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
+
+ err := repo.Update("1", library)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ Context("Database constraint violations during update", func() {
+ BeforeEach(func() {
+ // Reset any custom PutFn from previous tests
+ libraryRepo.PutFn = nil
+ })
+
+ It("handles name uniqueness constraint violation during update", func() {
+ // Create additional temp directory for the test
+ otherTempDir, err := os.MkdirTemp("", "navidrome-other-")
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { os.RemoveAll(otherTempDir) })
+
+ // Set up two libraries
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Library One", Path: tempDir},
+ {ID: 2, Name: "Library Two", Path: otherTempDir},
+ })
+
+ // Mock database constraint violation
+ libraryRepo.PutFn = func(library *model.Library) error {
+ return errors.New("UNIQUE constraint failed: library.name")
+ }
+
+ // Try to update library 2 to have the same name as library 1
+ library := &model.Library{ID: 2, Name: "Library One", Path: otherTempDir}
+
+ err = repo.Update("2", library)
+
+ Expect(err).To(HaveOccurred())
+ var validationErr *rest.ValidationError
+ Expect(errors.As(err, &validationErr)).To(BeTrue())
+ Expect(validationErr.Errors["name"]).To(Equal("ra.validation.unique"))
+ })
+
+ It("handles path uniqueness constraint violation during update", func() {
+ // Create additional temp directory for the test
+ otherTempDir, err := os.MkdirTemp("", "navidrome-other-")
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { os.RemoveAll(otherTempDir) })
+
+ // Set up two libraries
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Library One", Path: tempDir},
+ {ID: 2, Name: "Library Two", Path: otherTempDir},
+ })
+
+ // Mock database constraint violation
+ libraryRepo.PutFn = func(library *model.Library) error {
+ return errors.New("UNIQUE constraint failed: library.path")
+ }
+
+ // Try to update library 2 to have the same path as library 1
+ library := &model.Library{ID: 2, Name: "Library Two", Path: tempDir}
+
+ err = repo.Update("2", library)
+
+ Expect(err).To(HaveOccurred())
+ var validationErr *rest.ValidationError
+ Expect(errors.As(err, &validationErr)).To(BeTrue())
+ Expect(validationErr.Errors["path"]).To(Equal("ra.validation.unique"))
+ })
+ })
+ })
+
+ Describe("Path Validation", func() {
+ Context("Create operation", func() {
+ It("fails when path is not absolute", func() {
+ library := &model.Library{Name: "Test", Path: "relative/path"}
+
+ _, err := repo.Save(library)
+
+ Expect(err).To(HaveOccurred())
+ var validationErr *rest.ValidationError
+ Expect(errors.As(err, &validationErr)).To(BeTrue())
+ Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
+ })
+
+ It("fails when path does not exist", func() {
+ nonExistentPath := filepath.Join(tempDir, "nonexistent")
+ library := &model.Library{Name: "Test", Path: nonExistentPath}
+
+ _, err := repo.Save(library)
+
+ Expect(err).To(HaveOccurred())
+ var validationErr *rest.ValidationError
+ Expect(errors.As(err, &validationErr)).To(BeTrue())
+ Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid"))
+ })
+
+ It("fails when path is a file instead of directory", func() {
+ testFile := filepath.Join(tempDir, "testfile.txt")
+ err := os.WriteFile(testFile, []byte("test"), 0600)
+ Expect(err).NotTo(HaveOccurred())
+
+ library := &model.Library{Name: "Test", Path: testFile}
+
+ _, err = repo.Save(library)
+
+ Expect(err).To(HaveOccurred())
+ var validationErr *rest.ValidationError
+ Expect(errors.As(err, &validationErr)).To(BeTrue())
+ Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotDirectory"))
+ })
+
+ It("fails when path is not accessible due to permissions", func() {
+ Skip("Permission tests are environment-dependent and may fail in CI")
+ // This test is skipped because creating a directory with no read permissions
+ // is complex and may not work consistently across different environments
+ })
+
+ It("handles multiple validation errors", func() {
+ library := &model.Library{Name: "", Path: "relative/path"}
+
+ _, err := repo.Save(library)
+
+ Expect(err).To(HaveOccurred())
+ var validationErr *rest.ValidationError
+ Expect(errors.As(err, &validationErr)).To(BeTrue())
+ Expect(validationErr.Errors).To(HaveKey("name"))
+ Expect(validationErr.Errors).To(HaveKey("path"))
+ Expect(validationErr.Errors["name"]).To(Equal("ra.validation.required"))
+ Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
+ })
+ })
+
+ Context("Update operation", func() {
+ BeforeEach(func() {
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Test Library", Path: tempDir},
+ })
+ })
+
+ It("fails when updated path is not absolute", func() {
+ library := &model.Library{ID: 1, Name: "Test", Path: "relative/path"}
+
+ err := repo.Update("1", library)
+
+ Expect(err).To(HaveOccurred())
+ var validationErr *rest.ValidationError
+ Expect(errors.As(err, &validationErr)).To(BeTrue())
+ Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
+ })
+
+ It("allows updating library with same name (no change)", func() {
+ // Set up a library
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Test Library", Path: tempDir},
+ })
+
+ // Update the library keeping the same name (should be allowed)
+ library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
+
+ err := repo.Update("1", library)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("fails when updated path does not exist", func() {
+ nonExistentPath := filepath.Join(tempDir, "nonexistent")
+ library := &model.Library{ID: 1, Name: "Test", Path: nonExistentPath}
+
+ err := repo.Update("1", library)
+
+ Expect(err).To(HaveOccurred())
+ var validationErr *rest.ValidationError
+ Expect(errors.As(err, &validationErr)).To(BeTrue())
+ Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid"))
+ })
+
+ It("fails when updated path is a file instead of directory", func() {
+ testFile := filepath.Join(tempDir, "updatefile.txt")
+ err := os.WriteFile(testFile, []byte("test"), 0600)
+ Expect(err).NotTo(HaveOccurred())
+
+ library := &model.Library{ID: 1, Name: "Test", Path: testFile}
+
+ err = repo.Update("1", library)
+
+ Expect(err).To(HaveOccurred())
+ var validationErr *rest.ValidationError
+ Expect(errors.As(err, &validationErr)).To(BeTrue())
+ Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotDirectory"))
+ })
+
+ It("handles multiple validation errors on update", func() {
+ // Try to update with empty name and invalid path
+ library := &model.Library{ID: 1, Name: "", Path: "relative/path"}
+
+ err := repo.Update("1", library)
+
+ Expect(err).To(HaveOccurred())
+ var validationErr *rest.ValidationError
+ Expect(errors.As(err, &validationErr)).To(BeTrue())
+ Expect(validationErr.Errors).To(HaveKey("name"))
+ Expect(validationErr.Errors).To(HaveKey("path"))
+ Expect(validationErr.Errors["name"]).To(Equal("ra.validation.required"))
+ Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
+ })
+ })
+ })
+
+ Describe("Delete", func() {
+ BeforeEach(func() {
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Library to Delete", Path: tempDir},
+ })
+ })
+
+ It("deletes an existing library successfully", func() {
+ err := repo.Delete("1")
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(libraryRepo.Data).To(HaveLen(0))
+ })
+
+ It("fails when library doesn't exist", func() {
+ err := repo.Delete("999")
+
+ Expect(err).To(HaveOccurred())
+ Expect(err).To(Equal(model.ErrNotFound))
+ })
+ })
+ })
+
+ Describe("User-Library Association Operations", func() {
+ var regularUser, adminUser *model.User
+
+ BeforeEach(func() {
+ regularUser = &model.User{ID: "user1", UserName: "regular", IsAdmin: false}
+ adminUser = &model.User{ID: "admin1", UserName: "admin", IsAdmin: true}
+
+ userRepo.Data = map[string]*model.User{
+ "regular": regularUser,
+ "admin": adminUser,
+ }
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Library 1", Path: "/music1"},
+ {ID: 2, Name: "Library 2", Path: "/music2"},
+ {ID: 3, Name: "Library 3", Path: "/music3"},
+ })
+ })
+
+ Describe("GetUserLibraries", func() {
+ It("returns user's libraries", func() {
+ userRepo.UserLibraries = map[string][]int{
+ "user1": {1},
+ }
+
+ result, err := service.GetUserLibraries(ctx, "user1")
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal(1))
+ })
+
+ It("fails when user doesn't exist", func() {
+ _, err := service.GetUserLibraries(ctx, "nonexistent")
+
+ Expect(err).To(HaveOccurred())
+ Expect(err).To(Equal(model.ErrNotFound))
+ })
+ })
+
+ Describe("SetUserLibraries", func() {
+ It("sets libraries for regular user successfully", func() {
+ err := service.SetUserLibraries(ctx, "user1", []int{1, 2})
+
+ Expect(err).NotTo(HaveOccurred())
+ libraries := userRepo.UserLibraries["user1"]
+ Expect(libraries).To(HaveLen(2))
+ })
+
+ It("fails when user doesn't exist", func() {
+ err := service.SetUserLibraries(ctx, "nonexistent", []int{1})
+
+ Expect(err).To(HaveOccurred())
+ Expect(err).To(Equal(model.ErrNotFound))
+ })
+
+ It("fails when trying to set libraries for admin user", func() {
+ err := service.SetUserLibraries(ctx, "admin1", []int{1})
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("cannot manually assign libraries to admin users"))
+ })
+
+ It("fails when no libraries provided for regular user", func() {
+ err := service.SetUserLibraries(ctx, "user1", []int{})
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("at least one library must be assigned to non-admin users"))
+ })
+
+ It("fails when library doesn't exist", func() {
+ err := service.SetUserLibraries(ctx, "user1", []int{999})
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("one or more library IDs are invalid"))
+ })
+
+ It("fails when some libraries don't exist", func() {
+ err := service.SetUserLibraries(ctx, "user1", []int{1, 999, 2})
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("one or more library IDs are invalid"))
+ })
+ })
+
+ Describe("ValidateLibraryAccess", func() {
+ Context("admin user", func() {
+ BeforeEach(func() {
+ ctx = request.WithUser(ctx, *adminUser)
+ })
+
+ It("allows access to any library", func() {
+ err := service.ValidateLibraryAccess(ctx, "admin1", 1)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+ })
+
+ Context("regular user", func() {
+ BeforeEach(func() {
+ ctx = request.WithUser(ctx, *regularUser)
+ userRepo.UserLibraries = map[string][]int{
+ "user1": {1},
+ }
+ })
+
+ It("allows access to user's libraries", func() {
+ err := service.ValidateLibraryAccess(ctx, "user1", 1)
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("denies access to libraries user doesn't have", func() {
+ err := service.ValidateLibraryAccess(ctx, "user1", 2)
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("user does not have access to library 2"))
+ })
+ })
+
+ Context("no user in context", func() {
+ It("fails with user not found error", func() {
+ err := service.ValidateLibraryAccess(ctx, "user1", 1)
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("user not found in context"))
+ })
+ })
+ })
+ })
+
+ Describe("Scan Triggering", func() {
+ var repo rest.Persistable
+
+ BeforeEach(func() {
+ r := service.NewRepository(ctx)
+ repo = r.(rest.Persistable)
+ })
+
+ It("triggers scan when creating a new library", func() {
+ library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
+
+ _, err := repo.Save(library)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Wait briefly for the goroutine to complete
+ Eventually(func() int {
+ return scanner.GetScanAllCallCount()
+ }, "1s", "10ms").Should(Equal(1))
+
+ // Verify scan was called with correct parameters
+ calls := scanner.GetScanAllCalls()
+ Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
+ })
+
+ It("triggers scan when updating library path", func() {
+ // First create a library
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Original Library", Path: tempDir},
+ })
+
+ // Create a new temporary directory for the update
+ newTempDir, err := os.MkdirTemp("", "navidrome-library-update-")
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { os.RemoveAll(newTempDir) })
+
+ // Update the library with a new path
+ library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
+ err = repo.Update("1", library)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Wait briefly for the goroutine to complete
+ Eventually(func() int {
+ return scanner.GetScanAllCallCount()
+ }, "1s", "10ms").Should(Equal(1))
+
+ // Verify scan was called with correct parameters
+ calls := scanner.GetScanAllCalls()
+ Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
+ })
+
+ It("does not trigger scan when updating library without path change", func() {
+ // First create a library
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Original Library", Path: tempDir},
+ })
+
+ // Update the library name only (same path)
+ library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
+ err := repo.Update("1", library)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Wait a bit to ensure no scan was triggered
+ Consistently(func() int {
+ return scanner.GetScanAllCallCount()
+ }, "100ms", "10ms").Should(Equal(0))
+ })
+
+ It("does not trigger scan when library creation fails", func() {
+ // Try to create library with invalid data (empty name)
+ library := &model.Library{Path: tempDir}
+
+ _, err := repo.Save(library)
+ Expect(err).To(HaveOccurred())
+
+ // Ensure no scan was triggered since creation failed
+ Consistently(func() int {
+ return scanner.GetScanAllCallCount()
+ }, "100ms", "10ms").Should(Equal(0))
+ })
+
+ It("does not trigger scan when library update fails", func() {
+ // First create a library
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Original Library", Path: tempDir},
+ })
+
+ // Try to update with invalid data (empty name)
+ library := &model.Library{ID: 1, Name: "", Path: tempDir}
+ err := repo.Update("1", library)
+ Expect(err).To(HaveOccurred())
+
+ // Ensure no scan was triggered since update failed
+ Consistently(func() int {
+ return scanner.GetScanAllCallCount()
+ }, "100ms", "10ms").Should(Equal(0))
+ })
+
+ It("triggers scan when deleting a library", func() {
+ // First create a library
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Library to Delete", Path: tempDir},
+ })
+
+ // Delete the library
+ err := repo.Delete("1")
+ Expect(err).NotTo(HaveOccurred())
+
+ // Wait briefly for the goroutine to complete
+ Eventually(func() int {
+ return scanner.GetScanAllCallCount()
+ }, "1s", "10ms").Should(Equal(1))
+
+ // Verify scan was called with correct parameters
+ calls := scanner.GetScanAllCalls()
+ Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
+ })
+
+ It("does not trigger scan when library deletion fails", func() {
+ // Try to delete a non-existent library
+ err := repo.Delete("999")
+ Expect(err).To(HaveOccurred())
+
+ // Ensure no scan was triggered since deletion failed
+ Consistently(func() int {
+ return scanner.GetScanAllCallCount()
+ }, "100ms", "10ms").Should(Equal(0))
+ })
+
+ Context("Watcher Integration", func() {
+ It("starts watcher when creating a new library", func() {
+ library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
+
+ _, err := repo.Save(library)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify watcher was started
+ Eventually(func() int {
+ return watcherManager.lenStarted()
+ }, "1s", "10ms").Should(Equal(1))
+
+ Expect(watcherManager.StartedWatchers[0].ID).To(Equal(1))
+ Expect(watcherManager.StartedWatchers[0].Name).To(Equal("New Library"))
+ Expect(watcherManager.StartedWatchers[0].Path).To(Equal(tempDir))
+ })
+
+ It("restarts watcher when library path is updated", func() {
+ // First create a library
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Original Library", Path: tempDir},
+ })
+
+ // Simulate that this library already has a watcher
+ watcherManager.simulateExistingLibrary(model.Library{ID: 1, Name: "Original Library", Path: tempDir})
+
+ // Create a new temp directory for the update
+ newTempDir, err := os.MkdirTemp("", "navidrome-library-update-")
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { os.RemoveAll(newTempDir) })
+
+ // Update library with new path
+ library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
+ err = repo.Update("1", library)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify watcher was restarted
+ Eventually(func() int {
+ return watcherManager.lenRestarted()
+ }, "1s", "10ms").Should(Equal(1))
+
+ Expect(watcherManager.RestartedWatchers[0].ID).To(Equal(1))
+ Expect(watcherManager.RestartedWatchers[0].Path).To(Equal(newTempDir))
+ })
+
+ It("does not restart watcher when only library name is updated", func() {
+ // First create a library
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Original Library", Path: tempDir},
+ })
+
+ // Update library with same path but different name
+ library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
+ err := repo.Update("1", library)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify watcher was NOT restarted (since path didn't change)
+ Consistently(func() int {
+ return watcherManager.lenRestarted()
+ }, "100ms", "10ms").Should(Equal(0))
+ })
+
+ It("stops watcher when library is deleted", func() {
+ // Set up a library
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Test Library", Path: tempDir},
+ })
+
+ err := repo.Delete("1")
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify watcher was stopped
+ Eventually(func() int {
+ return watcherManager.lenStopped()
+ }, "1s", "10ms").Should(Equal(1))
+
+ Expect(watcherManager.StoppedWatchers[0]).To(Equal(1))
+ })
+
+ It("does not stop watcher when library deletion fails", func() {
+ // Set up a library
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Test Library", Path: tempDir},
+ })
+
+ // Mock deletion to fail by trying to delete non-existent library
+ err := repo.Delete("999")
+ Expect(err).To(HaveOccurred())
+
+ // Verify watcher was NOT stopped since deletion failed
+ Consistently(func() int {
+ return watcherManager.lenStopped()
+ }, "100ms", "10ms").Should(Equal(0))
+ })
+ })
+ })
+
+ Describe("Event Broadcasting", func() {
+ var repo rest.Persistable
+
+ BeforeEach(func() {
+ r := service.NewRepository(ctx)
+ repo = r.(rest.Persistable)
+ // Clear any events from broker
+ broker.Events = []events.Event{}
+ })
+
+ It("sends refresh event when creating a library", func() {
+ library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
+
+ _, err := repo.Save(library)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(broker.Events).To(HaveLen(1))
+ })
+
+ It("sends refresh event when updating a library", func() {
+ // First create a library
+ libraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Original Library", Path: tempDir},
+ })
+
+ library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir}
+ err := repo.Update("1", library)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(broker.Events).To(HaveLen(1))
+ })
+
+ It("sends refresh event when deleting a library", func() {
+ // First create a library
+ libraryRepo.SetData(model.Libraries{
+ {ID: 2, Name: "Library to Delete", Path: tempDir},
+ })
+
+ err := repo.Delete("2")
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(broker.Events).To(HaveLen(1))
+ })
+ })
+
+ Describe("Plugin Manager Integration", func() {
+ var repo rest.Persistable
+
+ BeforeEach(func() {
+ // Reset the call count for each test
+ pluginManager.unloadCalls = 0
+ r := service.NewRepository(ctx)
+ repo = r.(rest.Persistable)
+ })
+
+ It("calls UnloadDisabledPlugins after successful library deletion", func() {
+ libraryRepo.SetData(model.Libraries{
+ {ID: 2, Name: "Library to Delete", Path: tempDir},
+ })
+
+ err := repo.Delete("2")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(pluginManager.unloadCalls).To(Equal(1))
+ })
+
+ It("does not call UnloadDisabledPlugins when library deletion fails", func() {
+ // Try to delete non-existent library
+ err := repo.Delete("999")
+ Expect(err).To(HaveOccurred())
+ Expect(pluginManager.unloadCalls).To(Equal(0))
+ })
+ })
+})
+
+// mockPluginUnloader is a simple mock for testing UnloadDisabledPlugins calls
+type mockPluginUnloader struct {
+ unloadCalls int
+}
+
+func (m *mockPluginUnloader) UnloadDisabledPlugins(ctx context.Context) {
+ m.unloadCalls++
+}
+
+// mockWatcherManager provides a simple mock implementation of core.Watcher for testing
+type mockWatcherManager struct {
+ StartedWatchers []model.Library
+ StoppedWatchers []int
+ RestartedWatchers []model.Library
+ libraryStates map[int]model.Library // Track which libraries we know about
+ mu sync.RWMutex
+}
+
+func (m *mockWatcherManager) Watch(ctx context.Context, lib *model.Library) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ // Check if we already know about this library ID
+ if _, exists := m.libraryStates[lib.ID]; exists {
+ // This is a restart - the library already existed
+ // Update our tracking and record the restart
+ for i, startedLib := range m.StartedWatchers {
+ if startedLib.ID == lib.ID {
+ m.StartedWatchers[i] = *lib
+ break
+ }
+ }
+ m.RestartedWatchers = append(m.RestartedWatchers, *lib)
+ m.libraryStates[lib.ID] = *lib
+ return nil
+ }
+
+ // This is a new library - first time we're seeing it
+ m.StartedWatchers = append(m.StartedWatchers, *lib)
+ m.libraryStates[lib.ID] = *lib
+ return nil
+}
+
+func (m *mockWatcherManager) StopWatching(ctx context.Context, libraryID int) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.StoppedWatchers = append(m.StoppedWatchers, libraryID)
+ return nil
+}
+
+func (m *mockWatcherManager) lenStarted() int {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return len(m.StartedWatchers)
+}
+
+func (m *mockWatcherManager) lenStopped() int {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return len(m.StoppedWatchers)
+}
+
+func (m *mockWatcherManager) lenRestarted() int {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return len(m.RestartedWatchers)
+}
+
+// simulateExistingLibrary simulates the scenario where a library already exists
+// and has a watcher running (used by tests to set up the initial state)
+func (m *mockWatcherManager) simulateExistingLibrary(lib model.Library) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.libraryStates[lib.ID] = lib
+}
+
+// mockEventBroker provides a mock implementation of events.Broker for testing
+type mockEventBroker struct {
+ http.Handler
+ Events []events.Event
+ mu sync.RWMutex
+}
+
+func (m *mockEventBroker) SendMessage(ctx context.Context, event events.Event) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.Events = append(m.Events, event)
+}
+
+func (m *mockEventBroker) SendBroadcastMessage(ctx context.Context, event events.Event) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.Events = append(m.Events, event)
+}
diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go
index 858a3ffd8..758053042 100644
--- a/core/lyrics/lyrics.go
+++ b/core/lyrics/lyrics.go
@@ -9,23 +9,45 @@ import (
"github.com/navidrome/navidrome/model"
)
-func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
+// Lyrics can fetch lyrics for a media file.
+type Lyrics interface {
+ GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
+}
+
+// PluginLoader discovers and loads lyrics provider plugins.
+type PluginLoader interface {
+ LoadLyricsProvider(name string) (Lyrics, bool)
+}
+
+type lyricsService struct {
+ pluginLoader PluginLoader
+}
+
+// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin
+// system is available.
+func NewLyrics(pluginLoader PluginLoader) Lyrics {
+ return &lyricsService{pluginLoader: pluginLoader}
+}
+
+// GetLyrics returns lyrics for the given media file, trying sources in the
+// order specified by conf.Server.LyricsPriority.
+func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
var lyricsList model.LyricList
var err error
- for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") {
+ for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") {
pattern = strings.TrimSpace(pattern)
switch {
- case pattern == "embedded":
+ case strings.EqualFold(pattern, "embedded"):
lyricsList, err = fromEmbedded(ctx, mf)
case strings.HasPrefix(pattern, "."):
- lyricsList, err = fromExternalFile(ctx, mf, pattern)
+ lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern))
default:
- log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
+ lyricsList, err = l.fromPlugin(ctx, mf, pattern)
}
if err != nil {
- log.Error(ctx, "error parsing lyrics", "source", pattern, err)
+ log.Error(ctx, "error getting lyrics", "source", pattern, err)
}
if len(lyricsList) > 0 {
diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go
index f4197ccf6..9ab732ad1 100644
--- a/core/lyrics/lyrics_test.go
+++ b/core/lyrics/lyrics_test.go
@@ -3,14 +3,15 @@ package lyrics_test
import (
"context"
"encoding/json"
+ "fmt"
"os"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils"
- "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -30,15 +31,15 @@ var _ = Describe("sources", func() {
Lang: "eng",
Line: []model.Line{
{
- Start: gg.P(int64(18800)),
+ Start: new(int64(18800)),
Value: "We're no strangers to love",
},
{
- Start: gg.P(int64(22801)),
+ Start: new(int64(22801)),
Value: "You know the rules and so do I",
},
},
- Offset: gg.P(int64(-100)),
+ Offset: new(int64(-100)),
Synced: true,
},
}
@@ -72,7 +73,8 @@ var _ = Describe("sources", func() {
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
conf.Server.LyricsPriority = priority
- list, err := lyrics.GetLyrics(ctx, &mf)
+ svc := lyrics.NewLyrics(nil)
+ list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(expected))
},
@@ -91,6 +93,7 @@ var _ = Describe("sources", func() {
var accessForbiddenFile string
BeforeEach(func() {
+ tests.SkipOnWindows("uses Unix file permission bits")
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
@@ -107,7 +110,8 @@ var _ = Describe("sources", func() {
It("should fallback to embedded if an error happens when parsing file", func() {
conf.Server.LyricsPriority = ".mp3,embedded"
- list, err := lyrics.GetLyrics(ctx, &mf)
+ svc := lyrics.NewLyrics(nil)
+ list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics))
})
@@ -115,10 +119,109 @@ var _ = Describe("sources", func() {
It("should return nothing if error happens when trying to parse file", func() {
conf.Server.LyricsPriority = ".mp3"
- list, err := lyrics.GetLyrics(ctx, &mf)
+ svc := lyrics.NewLyrics(nil)
+ list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(BeEmpty())
})
})
})
+
+ Context("plugin sources", func() {
+ var mockLoader *mockPluginLoader
+
+ BeforeEach(func() {
+ mockLoader = &mockPluginLoader{}
+ })
+
+ It("should return lyrics from a plugin", func() {
+ conf.Server.LyricsPriority = "test-lyrics-plugin"
+ mockLoader.lyrics = unsyncedLyrics
+ svc := lyrics.NewLyrics(mockLoader)
+ list, err := svc.GetLyrics(ctx, &mf)
+ Expect(err).To(BeNil())
+ Expect(list).To(Equal(unsyncedLyrics))
+ })
+
+ It("should try plugin after embedded returns nothing", func() {
+ conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
+ mf.Lyrics = "" // No embedded lyrics
+ mockLoader.lyrics = unsyncedLyrics
+ svc := lyrics.NewLyrics(mockLoader)
+ list, err := svc.GetLyrics(ctx, &mf)
+ Expect(err).To(BeNil())
+ Expect(list).To(Equal(unsyncedLyrics))
+ })
+
+ It("should skip plugin if embedded has lyrics", func() {
+ conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
+ mockLoader.lyrics = unsyncedLyrics
+ svc := lyrics.NewLyrics(mockLoader)
+ list, err := svc.GetLyrics(ctx, &mf)
+ Expect(err).To(BeNil())
+ Expect(list).To(Equal(embeddedLyrics)) // embedded wins
+ })
+
+ It("should skip unknown plugin names gracefully", func() {
+ conf.Server.LyricsPriority = "nonexistent-plugin,embedded"
+ mockLoader.notFound = true
+ svc := lyrics.NewLyrics(mockLoader)
+ list, err := svc.GetLyrics(ctx, &mf)
+ Expect(err).To(BeNil())
+ Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
+ })
+
+ It("should preserve plugin name case from config", func() {
+ conf.Server.LyricsPriority = "MyLyricsPlugin"
+ mockLoader.pluginName = "MyLyricsPlugin"
+ mockLoader.lyrics = unsyncedLyrics
+ svc := lyrics.NewLyrics(mockLoader)
+ list, err := svc.GetLyrics(ctx, &mf)
+ Expect(err).To(BeNil())
+ Expect(list).To(Equal(unsyncedLyrics))
+ })
+
+ It("should handle plugin error gracefully", func() {
+ conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
+ mockLoader.err = fmt.Errorf("plugin error")
+ svc := lyrics.NewLyrics(mockLoader)
+ list, err := svc.GetLyrics(ctx, &mf)
+ Expect(err).To(BeNil())
+ Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
+ })
+ })
})
+
+type mockPluginLoader struct {
+ lyrics model.LyricList
+ err error
+ notFound bool
+ pluginName string // expected plugin name (exact match, like real manager)
+}
+
+func (m *mockPluginLoader) PluginNames(_ string) []string {
+ if m.notFound {
+ return nil
+ }
+ return []string{"test-lyrics-plugin"}
+}
+
+func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
+ if m.notFound {
+ return nil, false
+ }
+ // If pluginName is set, require exact match (like the real plugin manager)
+ if m.pluginName != "" && name != m.pluginName {
+ return nil, false
+ }
+ return &mockLyricsProvider{lyrics: m.lyrics, err: m.err}, true
+}
+
+type mockLyricsProvider struct {
+ lyrics model.LyricList
+ err error
+}
+
+func (m *mockLyricsProvider) GetLyrics(_ context.Context, _ *model.MediaFile) (model.LyricList, error) {
+ return m.lyrics, m.err
+}
diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go
index 6d4a4cc6f..82a10ca41 100644
--- a/core/lyrics/sources.go
+++ b/core/lyrics/sources.go
@@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils/ioutils"
)
func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
@@ -27,8 +28,7 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
- contents, err := os.ReadFile(externalLyric)
-
+ contents, err := ioutils.UTF8ReadFile(externalLyric)
if errors.Is(err, os.ErrNotExist) {
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
return nil, nil
@@ -49,3 +49,27 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
return model.LyricList{*lyrics}, nil
}
+
+// fromPlugin attempts to load lyrics from a plugin with the given name.
+func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, pluginName string) (model.LyricList, error) {
+ if l.pluginLoader == nil {
+ log.Debug(ctx, "Invalid lyric source", "source", pluginName)
+ return nil, nil
+ }
+
+ provider, ok := l.pluginLoader.LoadLyricsProvider(pluginName)
+ if !ok {
+ log.Warn(ctx, "Lyrics plugin not found", "plugin", pluginName)
+ return nil, nil
+ }
+
+ lyricsList, err := provider.GetLyrics(ctx, mf)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(lyricsList) > 0 {
+ log.Trace(ctx, "Retrieved lyrics from plugin", "plugin", pluginName, "count", len(lyricsList))
+ }
+ return lyricsList, nil
+}
diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go
index e92564c00..d1aefcb5d 100644
--- a/core/lyrics/sources_test.go
+++ b/core/lyrics/sources_test.go
@@ -5,7 +5,6 @@ import (
"encoding/json"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -74,15 +73,15 @@ var _ = Describe("sources", func() {
Lang: "eng",
Line: []model.Line{
{
- Start: gg.P(int64(18800)),
+ Start: new(int64(18800)),
Value: "We're no strangers to love",
},
{
- Start: gg.P(int64(22801)),
+ Start: new(int64(22801)),
Value: "You know the rules and so do I",
},
},
- Offset: gg.P(int64(-100)),
+ Offset: new(int64(-100)),
Synced: true,
},
}))
@@ -108,5 +107,39 @@ var _ = Describe("sources", func() {
},
}))
})
+
+ It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() {
+ // The function looks for , so we need to pass
+ // a MediaFile with .mp3 path and look for .lrc suffix
+ mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"}
+ lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
+
+ Expect(err).To(BeNil())
+ Expect(lyrics).ToNot(BeNil())
+ Expect(lyrics).To(HaveLen(1))
+
+ // The critical assertion: even with BOM, synced should be true
+ Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced")
+ Expect(lyrics[0].Line).To(HaveLen(1))
+ Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0))))
+ Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲"))
+ })
+
+ It("should handle UTF-16 LE encoded LRC files", func() {
+ mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"}
+ lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
+
+ Expect(err).To(BeNil())
+ Expect(lyrics).ToNot(BeNil())
+ Expect(lyrics).To(HaveLen(1))
+
+ // UTF-16 should be properly converted to UTF-8
+ Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced")
+ Expect(lyrics[0].Line).To(HaveLen(2))
+ Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800))))
+ Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love"))
+ Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801))))
+ Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I"))
+ })
})
})
diff --git a/core/maintenance.go b/core/maintenance.go
new file mode 100644
index 000000000..13d1141d3
--- /dev/null
+++ b/core/maintenance.go
@@ -0,0 +1,224 @@
+package core
+
+import (
+ "context"
+ "fmt"
+ "slices"
+ "sync"
+ "time"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/utils/slice"
+)
+
+type Maintenance interface {
+ // DeleteMissingFiles deletes specific missing files by their IDs
+ DeleteMissingFiles(ctx context.Context, ids []string) error
+ // DeleteAllMissingFiles deletes all files marked as missing
+ DeleteAllMissingFiles(ctx context.Context) error
+}
+
+type maintenanceService struct {
+ ds model.DataStore
+ wg sync.WaitGroup
+}
+
+func NewMaintenance(ds model.DataStore) Maintenance {
+ return &maintenanceService{
+ ds: ds,
+ }
+}
+
+func (s *maintenanceService) DeleteMissingFiles(ctx context.Context, ids []string) error {
+ return s.deleteMissing(ctx, ids)
+}
+
+func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error {
+ return s.deleteMissing(ctx, nil)
+}
+
+// deleteMissing handles the deletion of missing files and triggers necessary cleanup operations
+func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error {
+ // Track affected album IDs before deletion for refresh
+ affectedAlbumIDs, err := s.getAffectedAlbumIDs(ctx, ids)
+ if err != nil {
+ log.Warn(ctx, "Error tracking affected albums for refresh", err)
+ // Don't fail the operation, just log the warning
+ }
+
+ // Delete missing files within a transaction
+ err = s.ds.WithTx(func(tx model.DataStore) error {
+ if len(ids) == 0 {
+ _, err := tx.MediaFile(ctx).DeleteAllMissing()
+ return err
+ }
+ return tx.MediaFile(ctx).DeleteMissing(ids)
+ })
+ if err != nil {
+ log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
+ return err
+ }
+
+ // Run garbage collection to clean up orphaned records
+ if err := s.ds.GC(ctx); err != nil {
+ log.Error(ctx, "Error running GC after deleting missing tracks", err)
+ return err
+ }
+
+ // Refresh statistics in background
+ s.refreshStatsAsync(ctx, affectedAlbumIDs)
+
+ return nil
+}
+
+// refreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files.
+// It uses batch queries to minimize database round-trips for efficiency.
+func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []string) error {
+ if len(albumIDs) == 0 {
+ return nil
+ }
+
+ log.Debug(ctx, "Refreshing albums", "count", len(albumIDs))
+
+ // Process in chunks to avoid query size limits
+ const chunkSize = 100
+ for chunk := range slice.CollectChunks(slices.Values(albumIDs), chunkSize) {
+ if err := s.refreshAlbumChunk(ctx, chunk); err != nil {
+ return fmt.Errorf("refreshing album chunk: %w", err)
+ }
+ }
+
+ log.Debug(ctx, "Successfully refreshed albums", "count", len(albumIDs))
+ return nil
+}
+
+// refreshAlbumChunk processes a single chunk of album IDs
+func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error {
+ albumRepo := s.ds.Album(ctx)
+ mfRepo := s.ds.MediaFile(ctx)
+
+ // Batch load existing albums
+ albums, err := albumRepo.GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"album.id": albumIDs},
+ })
+ if err != nil {
+ return fmt.Errorf("loading albums: %w", err)
+ }
+
+ // Create a map for quick lookup
+ albumMap := make(map[string]*model.Album, len(albums))
+ for i := range albums {
+ albumMap[albums[i].ID] = &albums[i]
+ }
+
+ // Batch load all media files for these albums
+ mediaFiles, err := mfRepo.GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"album_id": albumIDs},
+ Sort: "album_id, path",
+ })
+ if err != nil {
+ return fmt.Errorf("loading media files: %w", err)
+ }
+
+ // Group media files by album ID
+ filesByAlbum := make(map[string]model.MediaFiles)
+ for i := range mediaFiles {
+ albumID := mediaFiles[i].AlbumID
+ filesByAlbum[albumID] = append(filesByAlbum[albumID], mediaFiles[i])
+ }
+
+ // Recalculate each album from its media files
+ for albumID, oldAlbum := range albumMap {
+ mfs, hasTracks := filesByAlbum[albumID]
+ if !hasTracks {
+ // Album has no tracks anymore, skip (will be cleaned up by GC)
+ log.Debug(ctx, "Skipping album with no tracks", "albumID", albumID)
+ continue
+ }
+
+ // Recalculate album from media files
+ newAlbum := mfs.ToAlbum()
+
+ // Only update if something changed (avoid unnecessary writes)
+ if !oldAlbum.Equals(newAlbum) {
+ // Preserve original timestamps
+ newAlbum.UpdatedAt = time.Now()
+ newAlbum.CreatedAt = oldAlbum.CreatedAt
+
+ if err := albumRepo.Put(&newAlbum); err != nil {
+ log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err)
+ // Continue with other albums instead of failing entirely
+ continue
+ }
+ log.Trace(ctx, "Refreshed album", "albumID", albumID, "name", newAlbum.Name)
+ }
+ }
+
+ return nil
+}
+
+// getAffectedAlbumIDs returns distinct album IDs from missing media files
+func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []string) ([]string, error) {
+ var filters squirrel.Sqlizer = squirrel.Eq{"missing": true}
+ if len(ids) > 0 {
+ filters = squirrel.And{
+ squirrel.Eq{"missing": true},
+ squirrel.Eq{"media_file.id": ids},
+ }
+ }
+
+ mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: filters,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ // Extract unique album IDs
+ albumIDMap := make(map[string]struct{}, len(mfs))
+ for _, mf := range mfs {
+ if mf.AlbumID != "" {
+ albumIDMap[mf.AlbumID] = struct{}{}
+ }
+ }
+
+ albumIDs := make([]string, 0, len(albumIDMap))
+ for id := range albumIDMap {
+ albumIDs = append(albumIDs, id)
+ }
+
+ return albumIDs, nil
+}
+
+// refreshStatsAsync refreshes artist and album statistics in background goroutines
+func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) {
+ // Refresh artist stats in background
+ s.wg.Go(func() {
+ bgCtx := request.AddValues(context.Background(), ctx)
+ if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil {
+ log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
+ } else {
+ log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
+ }
+
+ // Refresh album stats in background if we have affected albums
+ if len(affectedAlbumIDs) > 0 {
+ if err := s.refreshAlbums(bgCtx, affectedAlbumIDs); err != nil {
+ log.Error(bgCtx, "Error refreshing album stats after deleting missing files", err)
+ } else {
+ log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs))
+ }
+ }
+ })
+}
+
+// Wait waits for all background goroutines to complete.
+// WARNING: This method is ONLY for testing. Never call this in production code.
+// Calling Wait() in production will block until ALL background operations complete
+// and may cause race conditions with new operations starting.
+func (s *maintenanceService) wait() {
+ s.wg.Wait()
+}
diff --git a/core/maintenance_test.go b/core/maintenance_test.go
new file mode 100644
index 000000000..09b442438
--- /dev/null
+++ b/core/maintenance_test.go
@@ -0,0 +1,364 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "sync"
+
+ "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"
+ "github.com/sirupsen/logrus"
+)
+
+var _ = Describe("Maintenance", func() {
+ var ds *tests.MockDataStore
+ var mfRepo *extendedMediaFileRepo
+ var service Maintenance
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ ctx = request.WithUser(ctx, model.User{ID: "user1", IsAdmin: true})
+
+ ds = createTestDataStore()
+ mfRepo = ds.MockedMediaFile.(*extendedMediaFileRepo)
+ service = NewMaintenance(ds)
+ })
+
+ Describe("DeleteMissingFiles", func() {
+ Context("with specific IDs", func() {
+ It("deletes specific missing files and runs GC", func() {
+ // Setup: mock missing files with album IDs
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album2", Missing: true},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mfRepo.deleteMissingCalled).To(BeTrue())
+ Expect(mfRepo.deletedIDs).To(Equal([]string{"mf1", "mf2"}))
+ Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion")
+ })
+
+ It("triggers artist stats refresh and album refresh after deletion", func() {
+ artistRepo := ds.MockedArtist.(*extendedArtistRepo)
+ // Setup: mock missing files with albums
+ albumRepo := ds.MockedAlbum.(*extendedAlbumRepo)
+ albumRepo.SetData(model.Albums{
+ {ID: "album1", Name: "Test Album", SongCount: 5},
+ })
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
+ {ID: "mf3", AlbumID: "album1", Missing: false, Size: 2000, Duration: 200},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ Expect(err).ToNot(HaveOccurred())
+
+ // Wait for background goroutines to complete
+ service.(*maintenanceService).wait()
+
+ // RefreshStats should be called
+ Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed")
+
+ // Album should be updated with new calculated values
+ Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Album.Put() should be called to refresh album data")
+ })
+
+ It("returns error if deletion fails", func() {
+ mfRepo.deleteMissingError = errors.New("delete failed")
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("delete failed"))
+ })
+
+ It("continues even if album tracking fails", func() {
+ mfRepo.SetError(true)
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ // Should not fail, just log warning
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mfRepo.deleteMissingCalled).To(BeTrue())
+ })
+
+ It("returns error if GC fails", func() {
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ })
+
+ // Set GC to return error
+ ds.GCError = errors.New("gc failed")
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("gc failed"))
+ })
+ })
+
+ Context("album ID extraction", func() {
+ It("extracts unique album IDs from missing files", func() {
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album1", Missing: true},
+ {ID: "mf3", AlbumID: "album2", Missing: true},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2", "mf3"})
+
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("skips files without album IDs", func() {
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "", Missing: true},
+ {ID: "mf2", AlbumID: "album1", Missing: true},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
+
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+ })
+
+ Describe("DeleteAllMissingFiles", func() {
+ It("deletes all missing files and runs GC", func() {
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album2", Missing: true},
+ {ID: "mf3", AlbumID: "album3", Missing: true},
+ })
+
+ err := service.DeleteAllMissingFiles(ctx)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion")
+ })
+
+ It("returns error if deletion fails", func() {
+ mfRepo.SetError(true)
+
+ err := service.DeleteAllMissingFiles(ctx)
+
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("handles empty result gracefully", func() {
+ mfRepo.SetData(model.MediaFiles{})
+
+ err := service.DeleteAllMissingFiles(ctx)
+
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ Describe("Album refresh logic", func() {
+ var albumRepo *extendedAlbumRepo
+
+ BeforeEach(func() {
+ albumRepo = ds.MockedAlbum.(*extendedAlbumRepo)
+ })
+
+ Context("when album has no tracks after deletion", func() {
+ It("skips the album without updating it", func() {
+ // Setup album with no remaining tracks
+ albumRepo.SetData(model.Albums{
+ {ID: "album1", Name: "Empty Album", SongCount: 1},
+ })
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ Expect(err).ToNot(HaveOccurred())
+
+ // Wait for background goroutines to complete
+ service.(*maintenanceService).wait()
+
+ // Album should NOT be updated because it has no tracks left
+ Expect(albumRepo.GetPutCallCount()).To(Equal(0), "Album with no tracks should not be updated")
+ })
+ })
+
+ Context("when Put fails for one album", func() {
+ It("continues processing other albums", func() {
+ albumRepo.SetData(model.Albums{
+ {ID: "album1", Name: "Album 1"},
+ {ID: "album2", Name: "Album 2"},
+ })
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
+ {ID: "mf3", AlbumID: "album2", Missing: true},
+ {ID: "mf4", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200},
+ })
+
+ // Make Put fail on first call but succeed on subsequent calls
+ albumRepo.putError = errors.New("put failed")
+ albumRepo.failOnce = true
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf3"})
+
+ // Should not fail even if one album's Put fails
+ Expect(err).ToNot(HaveOccurred())
+
+ // Wait for background goroutines to complete
+ service.(*maintenanceService).wait()
+
+ // Put should have been called multiple times
+ Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Put should be attempted")
+ })
+ })
+
+ Context("when media file loading fails", func() {
+ It("logs warning but continues when tracking affected albums fails", func() {
+ // Set up log capturing
+ hook, cleanup := tests.LogHook()
+ defer cleanup()
+
+ albumRepo.SetData(model.Albums{
+ {ID: "album1", Name: "Album 1"},
+ })
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ })
+ // Make GetAll fail when loading media files
+ mfRepo.SetError(true)
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ // Deletion should succeed despite the tracking error
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mfRepo.deleteMissingCalled).To(BeTrue())
+
+ // Verify the warning was logged
+ Expect(hook.LastEntry()).ToNot(BeNil())
+ Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
+ Expect(hook.LastEntry().Message).To(Equal("Error tracking affected albums for refresh"))
+ })
+ })
+ })
+})
+
+// Test helper to create a mock DataStore with controllable behavior
+func createTestDataStore() *tests.MockDataStore {
+ ds := &tests.MockDataStore{}
+
+ // Create extended album repo with Put tracking
+ albumRepo := &extendedAlbumRepo{
+ MockAlbumRepo: tests.CreateMockAlbumRepo(),
+ }
+ ds.MockedAlbum = albumRepo
+
+ // Create extended artist repo with RefreshStats tracking
+ artistRepo := &extendedArtistRepo{
+ MockArtistRepo: tests.CreateMockArtistRepo(),
+ }
+ ds.MockedArtist = artistRepo
+
+ // Create extended media file repo with DeleteMissing support
+ mfRepo := &extendedMediaFileRepo{
+ MockMediaFileRepo: tests.CreateMockMediaFileRepo(),
+ }
+ ds.MockedMediaFile = mfRepo
+
+ return ds
+}
+
+// Extension of MockMediaFileRepo to add DeleteMissing method
+type extendedMediaFileRepo struct {
+ *tests.MockMediaFileRepo
+ deleteMissingCalled bool
+ deletedIDs []string
+ deleteMissingError error
+}
+
+func (m *extendedMediaFileRepo) DeleteMissing(ids []string) error {
+ m.deleteMissingCalled = true
+ m.deletedIDs = ids
+ if m.deleteMissingError != nil {
+ return m.deleteMissingError
+ }
+ // Actually delete from the mock data
+ for _, id := range ids {
+ delete(m.Data, id)
+ }
+ return nil
+}
+
+// Extension of MockAlbumRepo to track Put calls
+type extendedAlbumRepo struct {
+ *tests.MockAlbumRepo
+ mu sync.RWMutex
+ putCallCount int
+ lastPutData *model.Album
+ putError error
+ failOnce bool
+}
+
+func (m *extendedAlbumRepo) Put(album *model.Album) error {
+ m.mu.Lock()
+ m.putCallCount++
+ m.lastPutData = album
+
+ // Handle failOnce behavior
+ var err error
+ if m.putError != nil {
+ if m.failOnce {
+ err = m.putError
+ m.putError = nil // Clear error after first failure
+ m.mu.Unlock()
+ return err
+ }
+ err = m.putError
+ m.mu.Unlock()
+ return err
+ }
+ m.mu.Unlock()
+
+ return m.MockAlbumRepo.Put(album)
+}
+
+func (m *extendedAlbumRepo) GetPutCallCount() int {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.putCallCount
+}
+
+// Extension of MockArtistRepo to track RefreshStats calls
+type extendedArtistRepo struct {
+ *tests.MockArtistRepo
+ mu sync.RWMutex
+ refreshStatsCalled bool
+ refreshStatsError error
+}
+
+func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) {
+ m.mu.Lock()
+ m.refreshStatsCalled = true
+ err := m.refreshStatsError
+ m.mu.Unlock()
+
+ if err != nil {
+ return 0, err
+ }
+ return m.MockArtistRepo.RefreshStats(allArtists)
+}
+
+func (m *extendedArtistRepo) IsRefreshStatsCalled() bool {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.refreshStatsCalled
+}
diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go
new file mode 100644
index 000000000..54c2a368e
--- /dev/null
+++ b/core/matcher/matcher.go
@@ -0,0 +1,522 @@
+package matcher
+
+import (
+ "context"
+ "fmt"
+ "math"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/core/agents"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils/str"
+ "github.com/xrash/smetrics"
+)
+
+// Matcher matches agent song results to local library tracks.
+type Matcher struct {
+ ds model.DataStore
+}
+
+// New creates a new Matcher with the given DataStore.
+func New(ds model.DataStore) *Matcher {
+ return &Matcher{ds: ds}
+}
+
+// MatchSongs matches agent song results to local library tracks using a multi-phase
+// matching algorithm that prioritizes accuracy over recall.
+//
+// # Algorithm Overview
+//
+// The algorithm matches songs from external agents (Last.fm, Deezer, etc.) to tracks in the
+// local music library using four matching strategies in priority order:
+//
+// 1. Direct ID match: Songs with an ID field are matched directly to MediaFiles by ID
+// 2. MusicBrainz Recording ID (MBID) match: Songs with MBID are matched to tracks with
+// matching mbz_recording_id
+// 3. ISRC match: Songs with ISRC are matched to tracks with matching ISRC tag
+// 4. Title+Artist fuzzy match: Remaining songs are matched using fuzzy string comparison
+// with metadata specificity scoring
+//
+// # Matching Priority
+//
+// When selecting the final result, matches are prioritized in order: ID > MBID > ISRC > Title+Artist.
+// This ensures that more reliable identifiers take precedence over fuzzy text matching.
+//
+// # Fuzzy Matching Details
+//
+// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable
+// via Matcher.FuzzyThreshold, default 85%). Matches are ranked by:
+//
+// 1. Title similarity (Jaro-Winkler score, 0.0-1.0)
+// 2. Duration proximity (closer duration = higher score, 1.0 if unknown)
+// 3. Preferred track flag (enabled by Matcher.PreferStarred; prioritized when the track is
+// starred or has rating >= 4)
+// 4. Specificity level (0-5, based on metadata precision):
+// - Level 5: Title + Artist MBID + Album MBID (most specific)
+// - Level 4: Title + Artist MBID + Album name (fuzzy)
+// - Level 3: Title + Artist name + Album name (fuzzy)
+// - Level 2: Title + Artist MBID
+// - Level 1: Title + Artist name
+// - Level 0: Title only
+// 5. Album similarity (Jaro-Winkler, as final tiebreaker)
+//
+// # Examples
+//
+// Example 1 - MBID Priority:
+//
+// Agent returns: {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}
+// Library has: [
+// {ID: "t1", Title: "Paranoid Android", MbzRecordingID: "abc-123"},
+// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"},
+// ]
+// Result: t1 (MBID match takes priority over title+artist)
+//
+// Example 2 - ISRC Priority:
+//
+// Agent returns: {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"}
+// Library has: [
+// {ID: "t1", Title: "Paranoid Android", Tags: {isrc: ["GBAYE0000351"]}},
+// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"},
+// ]
+// Result: t1 (ISRC match takes priority over title+artist)
+//
+// Example 3 - Specificity Ranking:
+//
+// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}
+// Library has: [
+// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"}, // Level 1
+// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, // Level 3
+// ]
+// Result: t2 (Level 3 beats Level 1 due to album match)
+//
+// Example 4 - Fuzzy Title Matching:
+//
+// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"}
+// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"}
+// With threshold=85%: Match succeeds (similarity ~0.87)
+// With threshold=100%: No match (not exact)
+//
+// # Parameters
+//
+// - ctx: Context for database operations
+// - songs: Slice of agent.Song results from external providers
+// - count: Maximum number of matches to return
+//
+// # Returns
+//
+// Returns up to 'count' MediaFiles from the library that best match the input songs,
+// preserving the original order from the agent. Songs that cannot be matched are skipped.
+func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
+ if len(songs) == 0 {
+ return nil, nil
+ }
+
+ byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
+ if err != nil {
+ return nil, err
+ }
+ return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil
+}
+
+// MatchSongsIndexed matches agent song results to local library tracks and returns a map
+// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map.
+// This preserves original indices, allowing callers to correlate results back to the input slice.
+func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) {
+ if len(songs) == 0 {
+ return nil, nil
+ }
+
+ byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
+ if err != nil {
+ return nil, err
+ }
+
+ result := make(map[int]model.MediaFile, len(songs))
+ for i, t := range songs {
+ if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found {
+ result[i] = mf
+ }
+ }
+ return result, nil
+}
+
+func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) {
+ byID, err = m.loadTracksByID(ctx, songs)
+ if err != nil {
+ return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err)
+ }
+ byMBID, err = m.loadTracksByMBID(ctx, songs, byID)
+ if err != nil {
+ return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
+ }
+ byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID)
+ if err != nil {
+ return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
+ }
+ byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC)
+ if err != nil {
+ return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err)
+ }
+ return byID, byMBID, byISRC, byTitle, nil
+}
+
+// songMatchedIn checks if a song has already been matched in any of the provided match maps.
+func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool {
+ _, found := lookupByIdentifiers(s, priorMatches...)
+ return found
+}
+
+// lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps.
+func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) {
+ keys := []string{s.ID, s.MBID, s.ISRC}
+ for _, m := range maps {
+ for _, key := range keys {
+ if key != "" {
+ if mf, ok := m[key]; ok && mf.ID != "" {
+ return mf, true
+ }
+ }
+ }
+ }
+ return model.MediaFile{}, false
+}
+
+// loadTracksByID fetches MediaFiles from the library using direct ID matching.
+func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
+ var ids []string
+ for _, s := range songs {
+ if s.ID != "" {
+ ids = append(ids, s.ID)
+ }
+ }
+ matches := map[string]model.MediaFile{}
+ if len(ids) == 0 {
+ return matches, nil
+ }
+ res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.And{
+ squirrel.Eq{"media_file.id": ids},
+ squirrel.Eq{"missing": false},
+ },
+ })
+ if err != nil {
+ return matches, err
+ }
+ for _, mf := range res {
+ if _, ok := matches[mf.ID]; !ok {
+ matches[mf.ID] = mf
+ }
+ }
+ return matches, nil
+}
+
+// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs.
+func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
+ var mbids []string
+ for _, s := range songs {
+ if s.MBID != "" && !songMatchedIn(s, priorMatches...) {
+ mbids = append(mbids, s.MBID)
+ }
+ }
+ matches := map[string]model.MediaFile{}
+ if len(mbids) == 0 {
+ return matches, nil
+ }
+ res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.And{
+ squirrel.Eq{"mbz_recording_id": mbids},
+ squirrel.Eq{"missing": false},
+ },
+ })
+ if err != nil {
+ return matches, err
+ }
+ for _, mf := range res {
+ if id := mf.MbzRecordingID; id != "" {
+ if _, ok := matches[id]; !ok {
+ matches[id] = mf
+ }
+ }
+ }
+ return matches, nil
+}
+
+// loadTracksByISRC fetches MediaFiles from the library using ISRC matching.
+func (m *Matcher) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
+ var isrcs []string
+ for _, s := range songs {
+ if s.ISRC != "" && !songMatchedIn(s, priorMatches...) {
+ isrcs = append(isrcs, s.ISRC)
+ }
+ }
+ matches := map[string]model.MediaFile{}
+ if len(isrcs) == 0 {
+ return matches, nil
+ }
+ res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{
+ Filters: squirrel.Eq{"missing": false},
+ Sort: "starred desc, rating desc, year asc, compilation asc",
+ })
+ if err != nil {
+ return matches, err
+ }
+ for _, mf := range res {
+ for _, isrc := range mf.Tags.Values(model.TagISRC) {
+ if _, ok := matches[isrc]; !ok {
+ matches[isrc] = mf
+ }
+ }
+ }
+ return matches, nil
+}
+
+// songQuery represents a normalized query for matching a song to library tracks.
+type songQuery struct {
+ title string
+ artist string
+ artistMBID string
+ album string
+ albumMBID string
+ durationMs uint32
+}
+
+// matchScore combines title/album similarity with metadata specificity for ranking matches.
+type matchScore struct {
+ titleSimilarity float64
+ durationProximity float64
+ preferredMatch bool
+ albumSimilarity float64
+ specificityLevel int
+}
+
+// betterThan returns true if this score beats another.
+func (s matchScore) betterThan(other matchScore) bool {
+ if s.titleSimilarity != other.titleSimilarity {
+ return s.titleSimilarity > other.titleSimilarity
+ }
+ if s.durationProximity != other.durationProximity {
+ return s.durationProximity > other.durationProximity
+ }
+ if s.preferredMatch != other.preferredMatch {
+ return s.preferredMatch
+ }
+ if s.specificityLevel != other.specificityLevel {
+ return s.specificityLevel > other.specificityLevel
+ }
+ return s.albumSimilarity > other.albumSimilarity
+}
+
+// sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization
+// when the same track is scored against multiple queries in the inner loop. The `mf` field
+// is a pointer to avoid copying the large MediaFile struct into each entry of the per-artist
+// sanitized slice.
+type sanitizedTrack struct {
+ mf *model.MediaFile
+ title string
+ artist string
+ album string
+}
+
+func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack {
+ return sanitizedTrack{
+ mf: mf,
+ title: str.SanitizeFieldForSorting(mf.Title),
+ artist: str.SanitizeFieldForSortingNoArticle(mf.Artist),
+ album: str.SanitizeFieldForSorting(mf.Album),
+ }
+}
+
+// computeSpecificityLevel determines how well query metadata matches a track (0-5).
+// The track's title, artist, and album fields must be pre-sanitized.
+func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int {
+ if q.artistMBID != "" && q.albumMBID != "" &&
+ t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID {
+ return 5
+ }
+ if q.artistMBID != "" && q.album != "" &&
+ t.mf.MbzArtistID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold {
+ return 4
+ }
+ if q.artist != "" && q.album != "" &&
+ t.artist == q.artist && similarityRatio(t.album, q.album) >= albumThreshold {
+ return 3
+ }
+ if q.artistMBID != "" && t.mf.MbzArtistID == q.artistMBID {
+ return 2
+ }
+ if q.artist != "" && t.artist == q.artist {
+ return 1
+ }
+ if t.title == q.title {
+ return 0
+ }
+ return -1
+}
+
+// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering.
+func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
+ queries := m.buildTitleQueries(songs, priorMatches...)
+ if len(queries) == 0 {
+ return map[string]model.MediaFile{}, nil
+ }
+
+ threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0
+
+ byArtist := map[string][]songQuery{}
+ for _, q := range queries {
+ if q.artist != "" {
+ byArtist[q.artist] = append(byArtist[q.artist], q)
+ }
+ }
+
+ matches := map[string]model.MediaFile{}
+ for artist, artistQueries := range byArtist {
+ tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.And{
+ squirrel.Eq{"order_artist_name": artist},
+ squirrel.Eq{"missing": false},
+ },
+ Sort: "starred desc, rating desc, year asc, compilation asc",
+ })
+ if err != nil {
+ continue
+ }
+
+ sanitized := make([]sanitizedTrack, len(tracks))
+ for i := range tracks {
+ sanitized[i] = newSanitizedTrack(&tracks[i])
+ }
+
+ for _, q := range artistQueries {
+ if mf, found := m.findBestMatch(q, sanitized, threshold); found {
+ key := q.title + "|" + q.artist
+ if _, exists := matches[key]; !exists {
+ matches[key] = mf
+ }
+ }
+ }
+ }
+ return matches, nil
+}
+
+// durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration
+// is to the target. Returns 1.0 if durationMs is 0 (unknown).
+func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 {
+ if durationMs == 0 {
+ return 1.0
+ }
+ durationSec := float64(durationMs) / 1000.0
+ diff := math.Abs(durationSec - float64(mediaFileDurationSec))
+ return 1.0 / (1.0 + diff)
+}
+
+// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring.
+func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, threshold float64) (model.MediaFile, bool) {
+ var bestMatch model.MediaFile
+ bestScore := matchScore{titleSimilarity: -1}
+ found := false
+
+ for _, t := range sanitizedTracks {
+ titleSim := similarityRatio(q.title, t.title)
+
+ if titleSim < threshold {
+ continue
+ }
+
+ var albumSim float64
+ if q.album != "" {
+ albumSim = similarityRatio(q.album, t.album)
+ }
+
+ score := matchScore{
+ titleSimilarity: titleSim,
+ durationProximity: durationProximity(q.durationMs, t.mf.Duration),
+ preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf),
+ albumSimilarity: albumSim,
+ specificityLevel: computeSpecificityLevel(q, t, threshold),
+ }
+
+ if score.betterThan(bestScore) {
+ bestScore = score
+ bestMatch = *t.mf
+ found = true
+ }
+ }
+ return bestMatch, found
+}
+
+func isPreferredTrack(mf *model.MediaFile) bool {
+ return mf.Starred || mf.Rating >= 4
+}
+
+// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching.
+func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
+ var queries []songQuery
+ for _, s := range songs {
+ if songMatchedIn(s, priorMatches...) {
+ continue
+ }
+ queries = append(queries, songQuery{
+ title: str.SanitizeFieldForSorting(s.Name),
+ artist: str.SanitizeFieldForSortingNoArticle(s.Artist),
+ artistMBID: s.ArtistMBID,
+ album: str.SanitizeFieldForSorting(s.Album),
+ albumMBID: s.AlbumMBID,
+ durationMs: s.Duration,
+ })
+ }
+ return queries
+}
+
+// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching
+// library tracks using priority order: ID > MBID > ISRC > title+artist.
+func (m *Matcher) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles {
+ mfs := make(model.MediaFiles, 0, len(songs))
+ addedBy := make(map[string]agents.Song, len(songs))
+
+ for _, t := range songs {
+ if len(mfs) == count {
+ break
+ }
+
+ mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitleArtist)
+ if !found {
+ continue
+ }
+
+ if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
+ if t != prevSong {
+ continue
+ }
+ } else {
+ addedBy[mf.ID] = t
+ }
+
+ mfs = append(mfs, mf)
+ }
+ return mfs
+}
+
+// findMatchingTrack looks up a song in the match maps using priority order.
+func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) {
+ if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found {
+ return mf, true
+ }
+ key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist)
+ if mf, ok := byTitleArtist[key]; ok {
+ return mf, true
+ }
+ return model.MediaFile{}, false
+}
+
+// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm.
+func similarityRatio(a, b string) float64 {
+ if a == b {
+ return 1.0
+ }
+ if len(a) == 0 || len(b) == 0 {
+ return 0.0
+ }
+ return smetrics.JaroWinkler(a, b, 0.7, 4)
+}
diff --git a/core/matcher/matcher_internal_test.go b/core/matcher/matcher_internal_test.go
new file mode 100644
index 000000000..f111364c1
--- /dev/null
+++ b/core/matcher/matcher_internal_test.go
@@ -0,0 +1,53 @@
+package matcher
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("similarityRatio", func() {
+ It("returns 1.0 for identical strings", func() {
+ Expect(similarityRatio("hello", "hello")).To(BeNumerically("==", 1.0))
+ })
+
+ It("returns 0.0 for empty strings", func() {
+ Expect(similarityRatio("", "test")).To(BeNumerically("==", 0.0))
+ Expect(similarityRatio("test", "")).To(BeNumerically("==", 0.0))
+ })
+
+ It("returns high similarity for remastered suffix", func() {
+ ratio := similarityRatio("paranoid android", "paranoid android remastered")
+ Expect(ratio).To(BeNumerically(">=", 0.85))
+ })
+
+ It("returns high similarity for suffix additions like (Live)", func() {
+ ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live")
+ Expect(ratio).To(BeNumerically(">=", 0.90))
+ })
+
+ It("returns high similarity for 'yesterday' variants (common prefix)", func() {
+ ratio := similarityRatio("yesterday", "yesterday once more")
+ Expect(ratio).To(BeNumerically(">=", 0.85))
+ })
+
+ It("returns low similarity for same suffix", func() {
+ ratio := similarityRatio("postman (live)", "taxman (live)")
+ Expect(ratio).To(BeNumerically("<", 0.85))
+ })
+
+ It("handles unicode characters", func() {
+ ratio := similarityRatio("dont stop believin", "don't stop believin'")
+ Expect(ratio).To(BeNumerically(">=", 0.85))
+ })
+
+ It("returns low similarity for completely different strings", func() {
+ ratio := similarityRatio("abc", "xyz")
+ Expect(ratio).To(BeNumerically("<", 0.5))
+ })
+
+ It("is symmetric", func() {
+ ratio1 := similarityRatio("hello world", "hello")
+ ratio2 := similarityRatio("hello", "hello world")
+ Expect(ratio1).To(Equal(ratio2))
+ })
+})
diff --git a/core/matcher/matcher_suite_test.go b/core/matcher/matcher_suite_test.go
new file mode 100644
index 000000000..44877a3c8
--- /dev/null
+++ b/core/matcher/matcher_suite_test.go
@@ -0,0 +1,17 @@
+package matcher_test
+
+import (
+ "testing"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestMatcher(t *testing.T) {
+ tests.Init(t, false)
+ log.SetLevel(log.LevelFatal)
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Matcher Suite")
+}
diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go
new file mode 100644
index 000000000..42e3ec88d
--- /dev/null
+++ b/core/matcher/matcher_test.go
@@ -0,0 +1,897 @@
+package matcher_test
+
+import (
+ "context"
+ "errors"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core/agents"
+ "github.com/navidrome/navidrome/core/matcher"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/stretchr/testify/mock"
+)
+
+var _ = Describe("Matcher", func() {
+ var ds model.DataStore
+ var mediaFileRepo *mockMediaFileRepo
+ var ctx context.Context
+ var m *matcher.Matcher
+
+ BeforeEach(func() {
+ ctx = GinkgoT().Context()
+ DeferCleanup(configtest.SetupConfig())
+ mediaFileRepo = newMockMediaFileRepo()
+ DeferCleanup(func() {
+ mediaFileRepo.AssertExpectations(GinkgoT())
+ })
+ ds = &tests.MockDataStore{
+ MockedMediaFile: mediaFileRepo,
+ }
+ m = matcher.New(ds)
+ })
+
+ // Per-phase expectation helpers. Each `expect*Phase` registers a .Once() expectation
+ // that will fail the suite via AssertExpectations if the phase is NOT called. Tests
+ // use these to deterministically verify which matching phases fire. Phases that may
+ // or may not fire should use the `allow*Phase` variants instead, which register
+ // .Maybe() fallbacks.
+ expectIDPhase := func(matches model.MediaFiles) {
+ mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))).
+ Return(matches, nil).Once()
+ }
+ expectMBIDPhase := func(matches model.MediaFiles) {
+ mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))).
+ Return(matches, nil).Once()
+ }
+ expectISRCPhase := func(matches model.MediaFiles) {
+ mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))).
+ Return(matches, nil).Once()
+ }
+
+ // allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return
+ // early without hitting the DB) don't cause test failures for unexpected calls. Call
+ // this after expect*Phase for the phases the test actually wants to verify.
+ allowOtherPhases := func() {
+ mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))).
+ Return(model.MediaFiles{}, nil).Maybe()
+ mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))).
+ Return(model.MediaFiles{}, nil).Maybe()
+ mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))).
+ Return(model.MediaFiles{}, nil).Maybe()
+ mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))).
+ Return(model.MediaFiles{}, nil).Maybe()
+ }
+
+ // setupTitleOnlyExpectations is a convenience for fuzzy-match tests that only exercise
+ // the title+artist phase. The title phase uses .Maybe() because it may short-circuit
+ // when no songs have an artist.
+ setupTitleOnlyExpectations := func(artistTracks model.MediaFiles) {
+ mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))).
+ Return(artistTracks, nil).Maybe()
+ }
+
+ Describe("MatchSongs", func() {
+ Context("matching by direct ID", func() {
+ It("matches songs with an ID field to MediaFiles by ID", func() {
+ conf.Server.Matcher.FuzzyThreshold = 100
+ songs := []agents.Song{
+ {ID: "track-1", Name: "Some Song", Artist: "Some Artist"},
+ }
+ idMatch := model.MediaFile{
+ ID: "track-1", Title: "Some Song", Artist: "Some Artist",
+ }
+ expectIDPhase(model.MediaFiles{idMatch})
+ allowOtherPhases()
+ result, err := m.MatchSongs(ctx, songs, 5)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("track-1"))
+ })
+ })
+
+ Context("matching by MBID", func() {
+ It("matches songs with MBID to tracks with matching mbz_recording_id", func() {
+ conf.Server.Matcher.FuzzyThreshold = 100
+ songs := []agents.Song{
+ {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"},
+ }
+ mbidMatch := model.MediaFile{
+ ID: "track-mbid", Title: "Paranoid Android", Artist: "Radiohead",
+ MbzRecordingID: "abc-123",
+ }
+ expectMBIDPhase(model.MediaFiles{mbidMatch})
+ allowOtherPhases()
+ result, err := m.MatchSongs(ctx, songs, 5)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("track-mbid"))
+ })
+ })
+
+ Context("matching by ISRC", func() {
+ It("matches songs with ISRC to tracks with matching ISRC tag", func() {
+ conf.Server.Matcher.FuzzyThreshold = 100
+ songs := []agents.Song{
+ {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"},
+ }
+ isrcMatch := model.MediaFile{
+ ID: "track-isrc", Title: "Paranoid Android", Artist: "Radiohead",
+ Tags: model.Tags{model.TagISRC: []string{"GBAYE0000351"}},
+ }
+ expectISRCPhase(model.MediaFiles{isrcMatch})
+ allowOtherPhases()
+ result, err := m.MatchSongs(ctx, songs, 5)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("track-isrc"))
+ })
+ })
+
+ Context("fuzzy title+artist matching", func() {
+ It("matches songs by title and artist name", func() {
+ conf.Server.Matcher.FuzzyThreshold = 100
+ songs := []agents.Song{
+ {Name: "Enjoy the Silence", Artist: "Depeche Mode"},
+ }
+ titleMatch := model.MediaFile{
+ ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode",
+ }
+ setupTitleOnlyExpectations(model.MediaFiles{titleMatch})
+ result, err := m.MatchSongs(ctx, songs, 5)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("track-title"))
+ })
+
+ It("matches songs with fuzzy title similarity", func() {
+ conf.Server.Matcher.FuzzyThreshold = 85
+ songs := []agents.Song{
+ {Name: "Bohemian Rhapsody", Artist: "Queen"},
+ }
+ fuzzyMatch := model.MediaFile{
+ ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
+ }
+ setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch})
+ result, err := m.MatchSongs(ctx, songs, 5)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("track-fuzzy"))
+ })
+
+ It("does not match completely different titles", func() {
+ conf.Server.Matcher.FuzzyThreshold = 85
+ songs := []agents.Song{
+ {Name: "Yesterday", Artist: "The Beatles"},
+ }
+ differentTracks := model.MediaFiles{
+ {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"},
+ }
+ setupTitleOnlyExpectations(differentTracks)
+ result, err := m.MatchSongs(ctx, songs, 5)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(BeEmpty())
+ })
+ })
+
+ Context("deduplication", func() {
+ It("removes duplicates when different input songs match the same library track", func() {
+ conf.Server.Matcher.FuzzyThreshold = 85
+ songs := []agents.Song{
+ {Name: "Bohemian Rhapsody (Live)", Artist: "Queen"},
+ {Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"},
+ }
+ libraryTrack := model.MediaFile{
+ ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
+ }
+ setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
+ result, err := m.MatchSongs(ctx, songs, 5)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("br-live"))
+ })
+
+ It("preserves duplicates when identical input songs match the same library track", func() {
+ conf.Server.Matcher.FuzzyThreshold = 85
+ songs := []agents.Song{
+ {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
+ {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
+ }
+ libraryTrack := model.MediaFile{
+ ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera",
+ }
+ setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
+ result, err := m.MatchSongs(ctx, songs, 5)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(2))
+ Expect(result[0].ID).To(Equal("br"))
+ Expect(result[1].ID).To(Equal("br"))
+ })
+ })
+
+ Context("priority ordering", func() {
+ It("prefers ID match over MBID match", func() {
+ conf.Server.Matcher.FuzzyThreshold = 100
+ // Song has both ID and MBID set. The matcher should resolve via ID
+ // and short-circuit the MBID phase entirely, so no MBID fetch should
+ // occur even though an mbz_recording_id exists in the input.
+ songs := []agents.Song{
+ {ID: "track-id", Name: "Song", MBID: "mbid-1", Artist: "Artist"},
+ }
+ idMatch := model.MediaFile{
+ ID: "track-id", Title: "Song", Artist: "Artist",
+ }
+ expectIDPhase(model.MediaFiles{idMatch})
+ allowOtherPhases()
+ result, err := m.MatchSongs(ctx, songs, 5)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("track-id"))
+ })
+ })
+
+ Context("count limit", func() {
+ It("returns at most 'count' results", func() {
+ conf.Server.Matcher.FuzzyThreshold = 100
+ songs := []agents.Song{
+ {Name: "Song A", Artist: "Artist"},
+ {Name: "Song B", Artist: "Artist"},
+ {Name: "Song C", Artist: "Artist"},
+ }
+ tracks := model.MediaFiles{
+ {ID: "a", Title: "Song A", Artist: "Artist"},
+ {ID: "b", Title: "Song B", Artist: "Artist"},
+ {ID: "c", Title: "Song C", Artist: "Artist"},
+ }
+ setupTitleOnlyExpectations(tracks)
+ result, err := m.MatchSongs(ctx, songs, 2)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(2))
+ })
+ })
+
+ Context("empty input", func() {
+ It("returns empty results for no songs", func() {
+ result, err := m.MatchSongs(ctx, []agents.Song{}, 5)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(BeEmpty())
+ })
+ })
+ })
+
+ Describe("MatchSongsIndexed", func() {
+ It("returns index-keyed map of matched songs", func() {
+ songs := []agents.Song{
+ {ID: "track-1", Name: "Song One", Artist: "Artist A"},
+ {ID: "track-2", Name: "Song Two", Artist: "Artist B"},
+ {ID: "track-3", Name: "Song Three", Artist: "Artist C"},
+ }
+ mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"}
+ mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"}
+
+ expectIDPhase(model.MediaFiles{mf1, mf2})
+ allowOtherPhases()
+
+ result, err := m.MatchSongsIndexed(ctx, songs)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(2))
+ Expect(result[0].ID).To(Equal("track-1"))
+ Expect(result[1].ID).To(Equal("track-2"))
+ _, exists := result[2]
+ Expect(exists).To(BeFalse())
+ })
+
+ It("preserves original indices when some songs don't match", func() {
+ songs := []agents.Song{
+ {Name: "Unknown Song", Artist: "Unknown Artist"},
+ {ID: "track-1", Name: "Known Song", Artist: "Known Artist"},
+ }
+ mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"}
+
+ expectIDPhase(model.MediaFiles{mf1})
+ allowOtherPhases()
+
+ result, err := m.MatchSongsIndexed(ctx, songs)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ _, exists := result[0]
+ Expect(exists).To(BeFalse())
+ Expect(result[1].ID).To(Equal("track-1"))
+ })
+
+ It("returns empty map for empty input", func() {
+ result, err := m.MatchSongsIndexed(ctx, nil)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(BeEmpty())
+ })
+ })
+
+ Describe("specificity level matching", func() {
+ BeforeEach(func() {
+ conf.Server.Matcher.FuzzyThreshold = 100
+ })
+
+ It("matches by title + artist MBID + album MBID (highest priority)", func() {
+ correctMatch := model.MediaFile{
+ ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator",
+ MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456",
+ }
+ wrongMatch := model.MediaFile{
+ ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album",
+ MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid",
+ }
+ songs := []agents.Song{
+ {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"},
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("correct-match"))
+ })
+
+ It("matches by title + artist name + album name when MBIDs unavailable", func() {
+ correctMatch := model.MediaFile{
+ ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator",
+ }
+ wrongMatch := model.MediaFile{
+ ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
+ }
+ songs := []agents.Song{
+ {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"},
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("correct-match"))
+ })
+
+ It("matches by title + artist only when album info unavailable", func() {
+ correctMatch := model.MediaFile{
+ ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album",
+ }
+ wrongMatch := model.MediaFile{
+ ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
+ }
+ songs := []agents.Song{
+ {Name: "Similar Song", Artist: "Depeche Mode"},
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("correct-match"))
+ })
+
+ It("does not match songs without artist info", func() {
+ songs := []agents.Song{
+ {Name: "Similar Song"},
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(BeEmpty())
+ })
+
+ It("returns distinct matches for each artist's version (covers scenario)", func() {
+ cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!"}
+ cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}
+ cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}
+
+ songs := []agents.Song{
+ {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
+ {Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"},
+ {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"},
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(3))
+ ids := []string{result[0].ID, result[1].ID, result[2].ID}
+ Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3"))
+ })
+
+ It("prefers more precise matches for each song", func() {
+ preciseMatch := model.MediaFile{
+ ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One",
+ MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1",
+ }
+ lessAccurateMatch := model.MediaFile{
+ ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation",
+ MbzArtistID: "mbid-1",
+ }
+ artistTwoMatch := model.MediaFile{
+ ID: "artist-two", Title: "Song B", Artist: "Artist Two",
+ }
+
+ songs := []agents.Song{
+ {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"},
+ {Name: "Song B", Artist: "Artist Two"},
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(2))
+ Expect(result[0].ID).To(Equal("precise"))
+ Expect(result[1].ID).To(Equal("artist-two"))
+ })
+ })
+
+ Describe("fuzzy matching thresholds", func() {
+ Context("with default threshold (85%)", func() {
+ It("matches songs with remastered suffix", func() {
+ conf.Server.Matcher.FuzzyThreshold = 85
+
+ songs := []agents.Song{
+ {Name: "Paranoid Android", Artist: "Radiohead"},
+ }
+ artistTracks := model.MediaFiles{
+ {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
+ }
+
+ setupTitleOnlyExpectations(artistTracks)
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("remastered"))
+ })
+
+ It("matches songs with live suffix", func() {
+ conf.Server.Matcher.FuzzyThreshold = 85
+
+ songs := []agents.Song{
+ {Name: "Bohemian Rhapsody", Artist: "Queen"},
+ }
+ artistTracks := model.MediaFiles{
+ {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"},
+ }
+
+ setupTitleOnlyExpectations(artistTracks)
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("live"))
+ })
+ })
+
+ Context("with threshold set to 100 (exact match only)", func() {
+ It("only matches exact titles", func() {
+ conf.Server.Matcher.FuzzyThreshold = 100
+
+ songs := []agents.Song{
+ {Name: "Paranoid Android", Artist: "Radiohead"},
+ }
+ artistTracks := model.MediaFiles{
+ {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
+ }
+
+ setupTitleOnlyExpectations(artistTracks)
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(BeEmpty())
+ })
+ })
+
+ Context("with lower threshold (75%)", func() {
+ It("matches more aggressively", func() {
+ conf.Server.Matcher.FuzzyThreshold = 75
+
+ songs := []agents.Song{
+ {Name: "Song", Artist: "Artist"},
+ }
+ artistTracks := model.MediaFiles{
+ {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"},
+ }
+
+ setupTitleOnlyExpectations(artistTracks)
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("extended"))
+ })
+ })
+ })
+
+ Describe("fuzzy album matching", func() {
+ BeforeEach(func() {
+ conf.Server.Matcher.FuzzyThreshold = 85
+ conf.Server.Matcher.PreferStarred = false
+ })
+
+ It("matches album with (Remaster) suffix", func() {
+ songs := []agents.Song{
+ {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
+ }
+ correctMatch := model.MediaFile{
+ ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)",
+ }
+ wrongMatch := model.MediaFile{
+ ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits",
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("correct"))
+ })
+
+ It("matches album with (Deluxe Edition) suffix", func() {
+ songs := []agents.Song{
+ {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
+ }
+ correctMatch := model.MediaFile{
+ ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
+ }
+ wrongMatch := model.MediaFile{
+ ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101",
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("correct"))
+ })
+
+ It("prefers exact album match over fuzzy album match", func() {
+ songs := []agents.Song{
+ {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
+ }
+ exactMatch := model.MediaFile{
+ ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
+ }
+ fuzzyMatch := model.MediaFile{
+ ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("exact"))
+ })
+
+ It("prefers starred songs over better album match when enabled", func() {
+ conf.Server.Matcher.PreferStarred = true
+ songs := []agents.Song{
+ {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
+ }
+ albumMatch := model.MediaFile{
+ ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
+ }
+ starredTrack := model.MediaFile{
+ ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Starred: true},
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("starred"))
+ })
+
+ It("prefers 4-star songs over better album match when enabled", func() {
+ conf.Server.Matcher.PreferStarred = true
+ songs := []agents.Song{
+ {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
+ }
+ albumMatch := model.MediaFile{
+ ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
+ }
+ ratedTrack := model.MediaFile{
+ ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Rating: 4},
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("rated"))
+ })
+ })
+
+ Describe("duration matching", func() {
+ BeforeEach(func() {
+ conf.Server.Matcher.FuzzyThreshold = 100
+ })
+
+ It("prefers tracks with matching duration", func() {
+ songs := []agents.Song{
+ {Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
+ }
+ correctMatch := model.MediaFile{
+ ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0,
+ }
+ wrongDuration := model.MediaFile{
+ ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0,
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("correct"))
+ })
+
+ It("matches tracks with close duration", func() {
+ songs := []agents.Song{
+ {Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
+ }
+ closeDuration := model.MediaFile{
+ ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{closeDuration})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("close-duration"))
+ })
+
+ It("prefers closer duration over farther duration", func() {
+ songs := []agents.Song{
+ {Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
+ }
+ closeDuration := model.MediaFile{
+ ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
+ }
+ farDuration := model.MediaFile{
+ ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("close"))
+ })
+
+ It("still matches when no tracks have matching duration", func() {
+ songs := []agents.Song{
+ {Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
+ }
+ differentDuration := model.MediaFile{
+ ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{differentDuration})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("different"))
+ })
+
+ It("prefers title match over duration match when titles differ", func() {
+ songs := []agents.Song{
+ {Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
+ }
+ differentTitle := model.MediaFile{
+ ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0,
+ }
+ correctTitle := model.MediaFile{
+ ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("correct-title"))
+ })
+
+ It("matches without duration filtering when agent duration is 0", func() {
+ songs := []agents.Song{
+ {Name: "Similar Song", Artist: "Test Artist", Duration: 0},
+ }
+ anyTrack := model.MediaFile{
+ ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0,
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{anyTrack})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("any"))
+ })
+
+ It("handles very short songs with close duration", func() {
+ songs := []agents.Song{
+ {Name: "Short Song", Artist: "Test Artist", Duration: 30000},
+ }
+ shortTrack := model.MediaFile{
+ ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0,
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{shortTrack})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(1))
+ Expect(result[0].ID).To(Equal("short"))
+ })
+ })
+
+ Describe("deduplication edge cases", func() {
+ BeforeEach(func() {
+ conf.Server.Matcher.FuzzyThreshold = 85
+ })
+
+ It("handles mixed scenario with both identical and different input songs", func() {
+ songs := []agents.Song{
+ {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
+ {Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"},
+ {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
+ {Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"},
+ }
+ libraryTrack := model.MediaFile{
+ ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
+ }
+
+ setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(2))
+ Expect(result[0].ID).To(Equal("yesterday"))
+ Expect(result[1].ID).To(Equal("yesterday"))
+ })
+
+ It("does not deduplicate songs that match different library tracks", func() {
+ songs := []agents.Song{
+ {Name: "Song A", Artist: "Artist"},
+ {Name: "Song B", Artist: "Artist"},
+ {Name: "Song C", Artist: "Artist"},
+ }
+ trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
+ trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
+ trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"}
+
+ setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC})
+
+ result, err := m.MatchSongs(ctx, songs, 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(3))
+ Expect(result[0].ID).To(Equal("track-a"))
+ Expect(result[1].ID).To(Equal("track-b"))
+ Expect(result[2].ID).To(Equal("track-c"))
+ })
+
+ It("respects count limit after deduplication", func() {
+ songs := []agents.Song{
+ {Name: "Song A", Artist: "Artist"},
+ {Name: "Song A (Live)", Artist: "Artist"},
+ {Name: "Song B", Artist: "Artist"},
+ {Name: "Song B (Remix)", Artist: "Artist"},
+ }
+ trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
+ trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
+
+ setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB})
+
+ result, err := m.MatchSongs(ctx, songs, 2)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(HaveLen(2))
+ Expect(result[0].ID).To(Equal("track-a"))
+ Expect(result[1].ID).To(Equal("track-b"))
+ })
+ })
+})
+
+type mockMediaFileRepo struct {
+ mock.Mock
+ model.MediaFileRepository
+}
+
+func newMockMediaFileRepo() *mockMediaFileRepo {
+ return &mockMediaFileRepo{}
+}
+
+func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
+ argsSlice := make([]any, len(options))
+ for i, v := range options {
+ argsSlice[i] = v
+ }
+ args := m.Called(argsSlice...)
+ if args.Get(0) == nil {
+ return nil, args.Error(1)
+ }
+ return args.Get(0).(model.MediaFiles), args.Error(1)
+}
+
+func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
+ return m.GetAll(options...)
+}
+
+func (m *mockMediaFileRepo) SetError(hasError bool) {
+ if hasError {
+ m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error"))
+ }
+}
+
+// matchFieldInAnd returns a matcher that checks whether QueryOptions.Filters is a
+// squirrel.And whose first element is a squirrel.Eq containing the given field name.
+func matchFieldInAnd(fieldName string) func(opt model.QueryOptions) bool {
+ return func(opt model.QueryOptions) bool {
+ and, ok := opt.Filters.(squirrel.And)
+ if !ok || len(and) < 2 {
+ return false
+ }
+ eq, hasEq := and[0].(squirrel.Eq)
+ if !hasEq {
+ return false
+ }
+ _, hasField := eq[fieldName]
+ return hasField
+ }
+}
+
+// matchFieldInEq returns a matcher that checks whether QueryOptions.Filters is a
+// squirrel.Eq containing the given field name.
+func matchFieldInEq(fieldName string) func(opt model.QueryOptions) bool {
+ return func(opt model.QueryOptions) bool {
+ eq, ok := opt.Filters.(squirrel.Eq)
+ if !ok {
+ return false
+ }
+ _, hasField := eq[fieldName]
+ return hasField
+ }
+}
diff --git a/core/media_streamer.go b/core/media_streamer.go
deleted file mode 100644
index b3593c4eb..000000000
--- a/core/media_streamer.go
+++ /dev/null
@@ -1,214 +0,0 @@
-package core
-
-import (
- "context"
- "fmt"
- "io"
- "mime"
- "os"
- "sync"
- "time"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/consts"
- "github.com/navidrome/navidrome/core/ffmpeg"
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/request"
- "github.com/navidrome/navidrome/utils/cache"
-)
-
-type MediaStreamer interface {
- NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error)
- DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error)
-}
-
-type TranscodingCache cache.FileCache
-
-func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer {
- return &mediaStreamer{ds: ds, transcoder: t, cache: cache}
-}
-
-type mediaStreamer struct {
- ds model.DataStore
- transcoder ffmpeg.FFmpeg
- cache cache.FileCache
-}
-
-type streamJob struct {
- ms *mediaStreamer
- mf *model.MediaFile
- filePath string
- format string
- bitRate int
- offset int
-}
-
-func (j *streamJob) Key() string {
- return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
-}
-
-func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
- mf, err := ms.ds.MediaFile(ctx).Get(id)
- if err != nil {
- return nil, err
- }
-
- return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
-}
-
-func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
- var format string
- var bitRate int
- var cached bool
- defer func() {
- log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
- "bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw",
- "originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
- }()
-
- format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
- s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
- filePath := mf.AbsolutePath()
-
- if format == "raw" {
- log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
- "requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
- "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
- "selectedBitrate", bitRate, "selectedFormat", format)
- f, err := os.Open(filePath)
- if err != nil {
- return nil, err
- }
- s.ReadCloser = f
- s.Seeker = f
- s.format = mf.Suffix
- return s, nil
- }
-
- job := &streamJob{
- ms: ms,
- mf: mf,
- filePath: filePath,
- format: format,
- bitRate: bitRate,
- offset: reqOffset,
- }
- r, err := ms.cache.Get(ctx, job)
- if err != nil {
- log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
- return nil, err
- }
- cached = r.Cached
-
- s.ReadCloser = r
- s.Seeker = r.Seeker
-
- log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
- "requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
- "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
- "selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
-
- return s, nil
-}
-
-type Stream struct {
- ctx context.Context
- mf *model.MediaFile
- bitRate int
- format string
- io.ReadCloser
- io.Seeker
-}
-
-func (s *Stream) Seekable() bool { return s.Seeker != nil }
-func (s *Stream) Duration() float32 { return s.mf.Duration }
-func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
-func (s *Stream) Name() string { return s.mf.Title + "." + s.format }
-func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
-func (s *Stream) EstimatedContentLength() int {
- return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
-}
-
-// TODO This function deserves some love (refactoring)
-func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
- format = "raw"
- if reqFormat == "raw" {
- return format, 0
- }
- if reqFormat == mf.Suffix && reqBitRate == 0 {
- bitRate = mf.BitRate
- return format, bitRate
- }
- trc, hasDefault := request.TranscodingFrom(ctx)
- var cFormat string
- var cBitRate int
- if reqFormat != "" {
- cFormat = reqFormat
- } else {
- if hasDefault {
- cFormat = trc.TargetFormat
- cBitRate = trc.DefaultBitRate
- if p, ok := request.PlayerFrom(ctx); ok {
- cBitRate = p.MaxBitRate
- }
- } else if reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "" {
- // If no format is specified and no transcoding associated to the player, but a bitrate is specified,
- // and there is no transcoding set for the player, we use the default downsampling format.
- // But only if the requested bitRate is lower than the original bitRate.
- log.Debug("Default Downsampling", "Using default downsampling format", conf.Server.DefaultDownsamplingFormat)
- cFormat = conf.Server.DefaultDownsamplingFormat
- }
- }
- if reqBitRate > 0 {
- cBitRate = reqBitRate
- }
- if cBitRate == 0 && cFormat == "" {
- return format, bitRate
- }
- t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
- if err == nil {
- format = t.TargetFormat
- if cBitRate != 0 {
- bitRate = cBitRate
- } else {
- bitRate = t.DefaultBitRate
- }
- }
- if format == mf.Suffix && bitRate >= mf.BitRate {
- format = "raw"
- bitRate = 0
- }
- return format, bitRate
-}
-
-var (
- onceTranscodingCache sync.Once
- instanceTranscodingCache TranscodingCache
-)
-
-func GetTranscodingCache() TranscodingCache {
- onceTranscodingCache.Do(func() {
- instanceTranscodingCache = NewTranscodingCache()
- })
- return instanceTranscodingCache
-}
-
-func NewTranscodingCache() TranscodingCache {
- return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
- consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
- func(ctx context.Context, arg cache.Item) (io.Reader, error) {
- job := arg.(*streamJob)
- t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
- if err != nil {
- log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
- return nil, os.ErrInvalid
- }
- out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.filePath, job.bitRate, job.offset)
- if err != nil {
- log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
- return nil, os.ErrInvalid
- }
- return out, nil
- })
-}
diff --git a/core/media_streamer_Internal_test.go b/core/media_streamer_Internal_test.go
deleted file mode 100644
index 44fbf701c..000000000
--- a/core/media_streamer_Internal_test.go
+++ /dev/null
@@ -1,162 +0,0 @@
-package core
-
-import (
- "context"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/request"
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("MediaStreamer", func() {
- var ds model.DataStore
- ctx := log.NewContext(context.Background())
-
- BeforeEach(func() {
- ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
- })
-
- Context("selectTranscodingOptions", func() {
- mf := &model.MediaFile{}
- Context("player is not configured", func() {
- It("returns raw if raw is requested", func() {
- mf.Suffix = "flac"
- mf.BitRate = 1000
- format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
- Expect(format).To(Equal("raw"))
- })
- It("returns raw if a transcoder does not exists", func() {
- mf.Suffix = "flac"
- mf.BitRate = 1000
- format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
- Expect(format).To(Equal("raw"))
- })
- It("returns the requested format if a transcoder exists", func() {
- mf.Suffix = "flac"
- mf.BitRate = 1000
- format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
- Expect(format).To(Equal("mp3"))
- Expect(bitRate).To(Equal(160)) // Default Bit Rate
- })
- It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
- mf.Suffix = "mp3"
- mf.BitRate = 112
- format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
- Expect(format).To(Equal("raw"))
- })
- It("returns the requested format if requested BitRate is lower than original", func() {
- mf.Suffix = "mp3"
- mf.BitRate = 320
- format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
- Expect(format).To(Equal("mp3"))
- Expect(bitRate).To(Equal(192))
- })
- It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
- mf.Suffix = "mp3"
- mf.BitRate = 320
- format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
- Expect(format).To(Equal("raw"))
- Expect(bitRate).To(Equal(320))
- })
- Context("Downsampling", func() {
- BeforeEach(func() {
- conf.Server.DefaultDownsamplingFormat = "opus"
- mf.Suffix = "FLAC"
- mf.BitRate = 960
- })
- It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() {
- format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128)
- Expect(format).To(Equal("opus"))
- Expect(bitRate).To(Equal(128))
- })
- It("returns raw if maxBitrate is equal or greater than original", func() {
- // This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066
- format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960)
- Expect(format).To(Equal("raw"))
- Expect(bitRate).To(Equal(0))
- })
- })
- })
-
- Context("player has format configured", func() {
- BeforeEach(func() {
- t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
- ctx = request.WithTranscoding(ctx, t)
- })
- It("returns raw if raw is requested", func() {
- mf.Suffix = "flac"
- mf.BitRate = 1000
- format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
- Expect(format).To(Equal("raw"))
- })
- It("returns configured format/bitrate as default", func() {
- mf.Suffix = "flac"
- mf.BitRate = 1000
- format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
- Expect(format).To(Equal("oga"))
- Expect(bitRate).To(Equal(96))
- })
- It("returns requested format", func() {
- mf.Suffix = "flac"
- mf.BitRate = 1000
- format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
- Expect(format).To(Equal("mp3"))
- Expect(bitRate).To(Equal(160)) // Default Bit Rate
- })
- It("returns requested bitrate", func() {
- mf.Suffix = "flac"
- mf.BitRate = 1000
- format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
- Expect(format).To(Equal("oga"))
- Expect(bitRate).To(Equal(80))
- })
- It("returns raw if selected bitrate and format is the same as original", func() {
- mf.Suffix = "mp3"
- mf.BitRate = 192
- format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
- Expect(format).To(Equal("raw"))
- Expect(bitRate).To(Equal(0))
- })
- })
-
- Context("player has maxBitRate configured", func() {
- BeforeEach(func() {
- t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
- p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 192}
- ctx = request.WithTranscoding(ctx, t)
- ctx = request.WithPlayer(ctx, p)
- })
- It("returns raw if raw is requested", func() {
- mf.Suffix = "flac"
- mf.BitRate = 1000
- format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
- Expect(format).To(Equal("raw"))
- })
- It("returns configured format/bitrate as default", func() {
- mf.Suffix = "flac"
- mf.BitRate = 1000
- format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
- Expect(format).To(Equal("oga"))
- Expect(bitRate).To(Equal(192))
- })
- It("returns requested format", func() {
- mf.Suffix = "flac"
- mf.BitRate = 1000
- format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
- Expect(format).To(Equal("mp3"))
- Expect(bitRate).To(Equal(160)) // Default Bit Rate
- })
- It("returns requested bitrate", func() {
- mf.Suffix = "flac"
- mf.BitRate = 1000
- format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160)
- Expect(format).To(Equal("oga"))
- Expect(bitRate).To(Equal(160))
- })
- })
- })
-})
diff --git a/core/media_streamer_test.go b/core/media_streamer_test.go
deleted file mode 100644
index f5175495b..000000000
--- a/core/media_streamer_test.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package core_test
-
-import (
- "context"
- "io"
- "os"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/conf/configtest"
- "github.com/navidrome/navidrome/core"
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("MediaStreamer", func() {
- var streamer core.MediaStreamer
- var ds model.DataStore
- ffmpeg := tests.NewMockFFmpeg("fake data")
- ctx := log.NewContext(context.TODO())
-
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches")
- conf.Server.TranscodingCacheSize = "100MB"
- ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
- ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
- {ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
- })
- testCache := core.NewTranscodingCache()
- Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
- streamer = core.NewMediaStreamer(ds, ffmpeg, testCache)
- })
- AfterEach(func() {
- _ = os.RemoveAll(conf.Server.CacheFolder)
- })
-
- Context("NewStream", func() {
- It("returns a seekable stream if format is 'raw'", func() {
- s, err := streamer.NewStream(ctx, "123", "raw", 0, 0)
- Expect(err).ToNot(HaveOccurred())
- Expect(s.Seekable()).To(BeTrue())
- })
- It("returns a seekable stream if maxBitRate is 0", func() {
- s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0)
- Expect(err).ToNot(HaveOccurred())
- Expect(s.Seekable()).To(BeTrue())
- })
- It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
- s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0)
- Expect(err).ToNot(HaveOccurred())
- Expect(s.Seekable()).To(BeTrue())
- })
- It("returns a NON seekable stream if transcode is required", func() {
- s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0)
- Expect(err).To(BeNil())
- Expect(s.Seekable()).To(BeFalse())
- Expect(s.Duration()).To(Equal(float32(257.0)))
- })
- It("returns a seekable stream if the file is complete in the cache", func() {
- s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0)
- Expect(err).To(BeNil())
- _, _ = io.ReadAll(s)
- _ = s.Close()
- Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
-
- s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0)
- Expect(err).To(BeNil())
- Expect(s.Seekable()).To(BeTrue())
- })
- })
-})
diff --git a/core/metrics/insights.go b/core/metrics/insights.go
index 6076be0a5..bcd0343c2 100644
--- a/core/metrics/insights.go
+++ b/core/metrics/insights.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"math"
"net/http"
+ "os"
"path/filepath"
"runtime"
"runtime/debug"
@@ -21,6 +22,9 @@ import (
"github.com/navidrome/navidrome/core/metrics/insights"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/plugins"
+ "github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/singleton"
)
@@ -56,9 +60,16 @@ func GetInstance(ds model.DataStore) Insights {
}
func (c *insightsCollector) Run(ctx context.Context) {
- ctx = auth.WithAdminUser(ctx, c.ds)
for {
- c.sendInsights(ctx)
+ // Refresh admin context on each iteration to handle cases where
+ // admin user wasn't available on previous runs
+ insightsCtx := auth.WithAdminUser(ctx, c.ds)
+ u, _ := request.UserFrom(insightsCtx)
+ if !u.IsAdmin {
+ log.Trace(insightsCtx, "No admin user available, skipping insights collection")
+ } else {
+ c.sendInsights(insightsCtx)
+ }
select {
case <-time.After(consts.InsightsUpdateInterval):
continue
@@ -97,7 +108,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) {
return
}
req.Header.Set("Content-Type", "application/json")
- resp, err := hc.Do(req)
+ resp, err := hc.Do(req) //nolint:gosec
if err != nil {
log.Trace(ctx, "Could not send Insights data", err)
return
@@ -153,6 +164,13 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Build.Settings, data.Build.GoVersion = buildInfo()
data.OS.Containerized = consts.InContainer
+ // Install info
+ packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".package")
+ packageFileData, err := os.ReadFile(packageFilename)
+ if err == nil {
+ data.OS.Package = string(packageFileData)
+ }
+
// OS info
data.OS.Type = runtime.GOOS
data.OS.Arch = runtime.GOARCH
@@ -161,12 +179,12 @@ var staticData = sync.OnceValue(func() insights.Data {
// FS info
data.FS.Music = getFSInfo(conf.Server.MusicFolder)
- data.FS.Data = getFSInfo(conf.Server.DataFolder)
- if conf.Server.CacheFolder != "" {
- data.FS.Cache = getFSInfo(conf.Server.CacheFolder)
+ data.FS.Data = getFSInfo(conf.Server.DataFolder.String())
+ if conf.Server.CacheFolder.String() != "" {
+ data.FS.Cache = getFSInfo(conf.Server.CacheFolder.String())
}
- if conf.Server.Backup.Path != "" {
- data.FS.Backup = getFSInfo(conf.Server.Backup.Path)
+ if conf.Server.Backup.Path.String() != "" {
+ data.FS.Backup = getFSInfo(conf.Server.Backup.Path.String())
}
// Config info
@@ -175,29 +193,39 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != ""
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
+ data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload
+ data.Config.CoverArtQuality = conf.Server.CoverArtQuality
+ data.Config.EnableWebPEncoding = conf.Server.EnableWebPEncoding
+ data.Config.UICoverArtSize = conf.Server.UICoverArtSize
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
+ data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
data.Config.EnableDownloads = conf.Server.EnableDownloads
data.Config.EnableSharing = conf.Server.EnableSharing
data.Config.EnableStarRating = conf.Server.EnableStarRating
- data.Config.EnableLastFM = conf.Server.LastFM.Enabled
+ data.Config.EnableLastFM = conf.Server.LastFM.Enabled && conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != ""
data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled
+ data.Config.EnableDeezer = conf.Server.Deezer.Enabled
data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt
- data.Config.EnableSpotify = conf.Server.Spotify.ID != ""
data.Config.EnableJukebox = conf.Server.Jukebox.Enabled
data.Config.EnablePrometheus = conf.Server.Prometheus.Enabled
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
- data.Config.SearchFullString = conf.Server.SearchFullString
+ data.Config.SearchFullString = conf.Server.Search.FullString
+ data.Config.SearchBackend = conf.Server.Search.Backend
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
data.Config.PreferSortTags = conf.Server.PreferSortTags
data.Config.BackupSchedule = conf.Server.Backup.Schedule
data.Config.BackupCount = conf.Server.Backup.Count
data.Config.DevActivityPanel = conf.Server.DevActivityPanel
data.Config.ScannerEnabled = conf.Server.Scanner.Enabled
+ data.Config.ScannerExtractor = conf.Server.Scanner.Extractor
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
+ data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
+ data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID
+ data.Config.HasCustomTags = len(conf.Server.Tags) > 0
return data
})
@@ -232,12 +260,33 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
if err != nil {
log.Trace(ctx, "Error reading radios count", err)
}
+ data.Library.Libraries, err = c.ds.Library(ctx).CountAll()
+ if err != nil {
+ log.Trace(ctx, "Error reading libraries count", err)
+ }
data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(model.QueryOptions{
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
})
if err != nil {
log.Trace(ctx, "Error reading active users count", err)
}
+ data.Library.FileSuffixes, err = c.ds.MediaFile(ctx).CountBySuffix()
+ if err != nil {
+ log.Trace(ctx, "Error reading file suffixes count", err)
+ }
+
+ // Check for smart playlists
+ data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx)
+ if err != nil {
+ log.Trace(ctx, "Error checking for smart playlists", err)
+ }
+
+ // Collect plugins if permitted and enabled
+ if conf.Server.DevEnablePluginsInsights && conf.Server.Plugins.Enabled {
+ data.Plugins = c.collectPlugins(ctx)
+ }
+
+ // Collect active players if permitted
if conf.Server.DevEnablePlayerInsights {
data.Library.ActivePlayers, err = c.ds.Player(ctx).CountByClient(model.QueryOptions{
Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)},
@@ -263,3 +312,27 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
}
return resp
}
+
+// hasSmartPlaylists checks if there are any smart playlists (playlists with rules)
+func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) {
+ count, err := c.ds.Playlist(ctx).CountAll(model.QueryOptions{
+ Filters: squirrel.And{squirrel.NotEq{"rules": ""}, squirrel.NotEq{"rules": nil}},
+ })
+ return count > 0, err
+}
+
+// collectPlugins collects information about installed plugins
+func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo {
+ // TODO Fix import/inject cycles
+ manager := plugins.GetManager(c.ds, events.GetBroker(), nil)
+ info := manager.GetPluginInfo()
+
+ result := make(map[string]insights.PluginInfo, len(info))
+ for name, p := range info {
+ result[name] = insights.PluginInfo{
+ Name: p.Name,
+ Version: p.Version,
+ }
+ }
+ return result
+}
diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go
index 9df547b4a..34648a49b 100644
--- a/core/metrics/insights/data.go
+++ b/core/metrics/insights/data.go
@@ -16,6 +16,7 @@ type Data struct {
Containerized bool `json:"containerized"`
Arch string `json:"arch"`
NumCPU int `json:"numCPU"`
+ Package string `json:"package,omitempty"`
} `json:"os"`
Mem struct {
Alloc uint64 `json:"alloc"`
@@ -36,14 +37,17 @@ type Data struct {
Playlists int64 `json:"playlists"`
Shares int64 `json:"shares"`
Radios int64 `json:"radios"`
+ Libraries int64 `json:"libraries"`
ActiveUsers int64 `json:"activeUsers"`
ActivePlayers map[string]int64 `json:"activePlayers,omitempty"`
+ FileSuffixes map[string]int64 `json:"fileSuffixes,omitempty"`
} `json:"library"`
Config struct {
LogLevel string `json:"logLevel,omitempty"`
LogFileConfigured bool `json:"logFileConfigured,omitempty"`
TLSConfigured bool `json:"tlsConfigured,omitempty"`
ScannerEnabled bool `json:"scannerEnabled,omitempty"`
+ ScannerExtractor string `json:"scannerExtractor,omitempty"`
ScanSchedule string `json:"scanSchedule,omitempty"`
ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"`
ScanOnStartup bool `json:"scanOnStartup,omitempty"`
@@ -55,20 +59,36 @@ type Data struct {
EnableStarRating bool `json:"enableStarRating,omitempty"`
EnableLastFM bool `json:"enableLastFM,omitempty"`
EnableListenBrainz bool `json:"enableListenBrainz,omitempty"`
+ EnableDeezer bool `json:"enableDeezer,omitempty"`
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
- EnableSpotify bool `json:"enableSpotify,omitempty"`
EnableJukebox bool `json:"enableJukebox,omitempty"`
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
+ EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"`
+ CoverArtQuality int `json:"coverArtQuality,omitempty"`
+ EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"`
+ UICoverArtSize int `json:"uiCoverArtSize,omitempty"`
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
+ EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
SearchFullString bool `json:"searchFullString,omitempty"`
+ SearchBackend string `json:"searchBackend,omitempty"`
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
PreferSortTags bool `json:"preferSortTags,omitempty"`
BackupSchedule string `json:"backupSchedule,omitempty"`
BackupCount int `json:"backupCount,omitempty"`
DevActivityPanel bool `json:"devActivityPanel,omitempty"`
DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"`
+ HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"`
+ ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"`
+ HasCustomPID bool `json:"hasCustomPID,omitempty"`
+ HasCustomTags bool `json:"hasCustomTags,omitempty"`
} `json:"config"`
+ Plugins map[string]PluginInfo `json:"plugins,omitempty"`
+}
+
+type PluginInfo struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
}
type FSInfo struct {
diff --git a/core/metrics/insights_linux.go b/core/metrics/insights_linux.go
index dbf3c277c..f37c945c1 100644
--- a/core/metrics/insights_linux.go
+++ b/core/metrics/insights_linux.go
@@ -42,6 +42,7 @@ type MountInfo struct {
var fsTypeMap = map[int64]string{
0x5346414f: "afs",
+ 0x187: "autofs",
0x61756673: "aufs",
0x9123683E: "btrfs",
0xc36400: "ceph",
@@ -55,9 +56,11 @@ var fsTypeMap = map[int64]string{
0x6a656a63: "fakeowner", // FS inside a container
0x65735546: "fuse",
0x4244: "hfs",
+ 0x482b: "hfs+",
0x9660: "iso9660",
0x3153464a: "jfs",
0x00006969: "nfs",
+ 0x5346544e: "ntfs", // NTFS_SB_MAGIC
0x7366746e: "ntfs",
0x794c7630: "overlayfs",
0x9fa0: "proc",
@@ -69,8 +72,16 @@ var fsTypeMap = map[int64]string{
0x01021997: "v9fs",
0x786f4256: "vboxsf",
0x4d44: "vfat",
+ 0xca451a4e: "virtiofs",
0x58465342: "xfs",
0x2FC12FC1: "zfs",
+ 0x7c7c6673: "prlfs", // Parallels Shared Folders
+
+ // Signed/unsigned conversion issues (negative hex values converted to uint32)
+ -0x6edc97c2: "btrfs", // 0x9123683e
+ -0x1acb2be: "smb2", // 0xfe534d42
+ -0xacb2be: "cifs", // 0xff534d42
+ -0xd0adff0: "f2fs", // 0xf2f52010
}
func getFilesystemType(path string) (string, error) {
diff --git a/core/metrics/prometheus.go b/core/metrics/prometheus.go
index 5dabf29ce..412483156 100644
--- a/core/metrics/prometheus.go
+++ b/core/metrics/prometheus.go
@@ -2,7 +2,6 @@ package metrics
import (
"context"
- "fmt"
"net/http"
"strconv"
"sync"
@@ -13,6 +12,7 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils/singleton"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
@@ -20,6 +20,8 @@ import (
type Metrics interface {
WriteInitialMetrics(ctx context.Context)
WriteAfterScanMetrics(ctx context.Context, success bool)
+ RecordRequest(ctx context.Context, endpoint, method, client string, status int32, elapsed int64)
+ RecordPluginRequest(ctx context.Context, plugin, method string, ok bool, elapsed int64)
GetHandler() http.Handler
}
@@ -27,11 +29,14 @@ type metrics struct {
ds model.DataStore
}
-func NewPrometheusInstance(ds model.DataStore) Metrics {
- if conf.Server.Prometheus.Enabled {
- return &metrics{ds: ds}
+func GetPrometheusInstance(ds model.DataStore) Metrics {
+ if !conf.Server.Prometheus.Enabled {
+ return noopMetrics{}
}
- return noopMetrics{}
+
+ return singleton.GetInstance(func() *metrics {
+ return &metrics{ds: ds}
+ })
}
func NewNoopInstance() Metrics {
@@ -51,6 +56,38 @@ func (m *metrics) WriteAfterScanMetrics(ctx context.Context, success bool) {
getPrometheusMetrics().mediaScansCounter.With(scanLabels).Inc()
}
+func (m *metrics) RecordRequest(_ context.Context, endpoint, method, client string, status int32, elapsed int64) {
+ httpLabel := prometheus.Labels{
+ "endpoint": endpoint,
+ "method": method,
+ "client": client,
+ "status": strconv.FormatInt(int64(status), 10),
+ }
+ getPrometheusMetrics().httpRequestCounter.With(httpLabel).Inc()
+
+ httpLatencyLabel := prometheus.Labels{
+ "endpoint": endpoint,
+ "method": method,
+ "client": client,
+ }
+ getPrometheusMetrics().httpRequestDuration.With(httpLatencyLabel).Observe(float64(elapsed))
+}
+
+func (m *metrics) RecordPluginRequest(_ context.Context, plugin, method string, ok bool, elapsed int64) {
+ pluginLabel := prometheus.Labels{
+ "plugin": plugin,
+ "method": method,
+ "ok": strconv.FormatBool(ok),
+ }
+ getPrometheusMetrics().pluginRequestCounter.With(pluginLabel).Inc()
+
+ pluginLatencyLabel := prometheus.Labels{
+ "plugin": plugin,
+ "method": method,
+ }
+ getPrometheusMetrics().pluginRequestDuration.With(pluginLatencyLabel).Observe(float64(elapsed))
+}
+
func (m *metrics) GetHandler() http.Handler {
r := chi.NewRouter()
@@ -59,20 +96,31 @@ func (m *metrics) GetHandler() http.Handler {
consts.PrometheusAuthUser: conf.Server.Prometheus.Password,
}))
}
- r.Handle("/", promhttp.Handler())
+ // Enable created at timestamp to handle zero counter on create.
+ // This requires --enable-feature=created-timestamp-zero-ingestion to be passed in Prometheus
+ r.Handle("/", promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{
+ EnableOpenMetrics: true,
+ EnableOpenMetricsTextCreatedSamples: true,
+ }))
return r
}
type prometheusMetrics struct {
- dbTotal *prometheus.GaugeVec
- versionInfo *prometheus.GaugeVec
- lastMediaScan *prometheus.GaugeVec
- mediaScansCounter *prometheus.CounterVec
+ dbTotal *prometheus.GaugeVec
+ versionInfo *prometheus.GaugeVec
+ lastMediaScan *prometheus.GaugeVec
+ mediaScansCounter *prometheus.CounterVec
+ httpRequestCounter *prometheus.CounterVec
+ httpRequestDuration *prometheus.SummaryVec
+ pluginRequestCounter *prometheus.CounterVec
+ pluginRequestDuration *prometheus.SummaryVec
}
// Prometheus' metrics requires initialization. But not more than once
var getPrometheusMetrics = sync.OnceValue(func() *prometheusMetrics {
+ quartilesToEstimate := map[float64]float64{0.5: 0.05, 0.75: 0.025, 0.9: 0.01, 0.99: 0.001}
+
instance := &prometheusMetrics{
dbTotal: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
@@ -102,23 +150,49 @@ var getPrometheusMetrics = sync.OnceValue(func() *prometheusMetrics {
},
[]string{"success"},
),
+ httpRequestCounter: prometheus.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "http_request_count",
+ Help: "Request types by status",
+ },
+ []string{"endpoint", "method", "client", "status"},
+ ),
+ httpRequestDuration: prometheus.NewSummaryVec(
+ prometheus.SummaryOpts{
+ Name: "http_request_latency",
+ Help: "Latency (in ms) of HTTP requests",
+ Objectives: quartilesToEstimate,
+ },
+ []string{"endpoint", "method", "client"},
+ ),
+ pluginRequestCounter: prometheus.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "plugin_request_count",
+ Help: "Plugin requests by method/status",
+ },
+ []string{"plugin", "method", "ok"},
+ ),
+ pluginRequestDuration: prometheus.NewSummaryVec(
+ prometheus.SummaryOpts{
+ Name: "plugin_request_latency",
+ Help: "Latency (in ms) of plugin requests",
+ Objectives: quartilesToEstimate,
+ },
+ []string{"plugin", "method"},
+ ),
}
- err := prometheus.DefaultRegisterer.Register(instance.dbTotal)
- if err != nil {
- log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register db_model_totals metrics: %w", err))
- }
- err = prometheus.DefaultRegisterer.Register(instance.versionInfo)
- if err != nil {
- log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register navidrome_info metrics: %w", err))
- }
- err = prometheus.DefaultRegisterer.Register(instance.lastMediaScan)
- if err != nil {
- log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register media_scan_last metrics: %w", err))
- }
- err = prometheus.DefaultRegisterer.Register(instance.mediaScansCounter)
- if err != nil {
- log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register media_scans metrics: %w", err))
- }
+
+ prometheus.DefaultRegisterer.MustRegister(
+ instance.dbTotal,
+ instance.versionInfo,
+ instance.lastMediaScan,
+ instance.mediaScansCounter,
+ instance.httpRequestCounter,
+ instance.httpRequestDuration,
+ instance.pluginRequestCounter,
+ instance.pluginRequestDuration,
+ )
+
return instance
})
@@ -159,4 +233,8 @@ func (n noopMetrics) WriteInitialMetrics(context.Context) {}
func (n noopMetrics) WriteAfterScanMetrics(context.Context, bool) {}
+func (n noopMetrics) RecordRequest(context.Context, string, string, string, int32, int64) {}
+
+func (n noopMetrics) RecordPluginRequest(context.Context, string, string, bool, int64) {}
+
func (n noopMetrics) GetHandler() http.Handler { return nil }
diff --git a/core/playback/mpv/mpv.go b/core/playback/mpv/mpv.go
index 495d27512..6696eca2a 100644
--- a/core/playback/mpv/mpv.go
+++ b/core/playback/mpv/mpv.go
@@ -12,9 +12,13 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/utils/shellquote"
)
func start(ctx context.Context, args []string) (Executor, error) {
+ if len(args) == 0 {
+ return Executor{}, fmt.Errorf("no command arguments provided")
+ }
log.Debug("Executing mpv command", "cmd", args)
j := Executor{args: args}
j.PipeReader, j.out = io.Pipe()
@@ -58,8 +62,7 @@ func (j *Executor) start(ctx context.Context) error {
func (j *Executor) wait() {
if err := j.cmd.Wait(); err != nil {
- var exitErr *exec.ExitError
- if errors.As(err, &exitErr) {
+ if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
} else {
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
@@ -71,28 +74,32 @@ func (j *Executor) wait() {
// Path will always be an absolute path
func createMPVCommand(deviceName string, filename string, socketName string) []string {
- split := strings.Split(fixCmd(conf.Server.MPVCmdTemplate), " ")
- for i, s := range split {
- s = strings.ReplaceAll(s, "%d", deviceName)
- s = strings.ReplaceAll(s, "%f", filename)
- s = strings.ReplaceAll(s, "%s", socketName)
- split[i] = s
+ // Parse the template structure using shell parsing to handle quoted arguments
+ templateArgs, err := shellquote.Split(conf.Server.MPVCmdTemplate)
+ if err != nil {
+ log.Error("Failed to parse MPV command template", "template", conf.Server.MPVCmdTemplate, err)
+ return nil
}
- return split
-}
-func fixCmd(cmd string) string {
- split := strings.Split(cmd, " ")
- var result []string
- cmdPath, _ := mpvCommand()
- for _, s := range split {
- if s == "mpv" || s == "mpv.exe" {
- result = append(result, cmdPath)
- } else {
- result = append(result, s)
+ // Replace placeholders in each parsed argument to preserve spaces in substituted values
+ for i, arg := range templateArgs {
+ arg = strings.ReplaceAll(arg, "%d", deviceName)
+ arg = strings.ReplaceAll(arg, "%f", filename)
+ arg = strings.ReplaceAll(arg, "%s", socketName)
+ templateArgs[i] = arg
+ }
+
+ // Replace mpv executable references with the configured path
+ if len(templateArgs) > 0 {
+ cmdPath, err := mpvCommand()
+ if err == nil {
+ if templateArgs[0] == "mpv" || templateArgs[0] == "mpv.exe" {
+ templateArgs[0] = cmdPath
+ }
}
}
- return strings.Join(result, " ")
+
+ return templateArgs
}
// This is a 1:1 copy of the stuff in ffmpeg.go, need to be unified.
diff --git a/core/playback/mpv/mpv_suite_test.go b/core/playback/mpv/mpv_suite_test.go
new file mode 100644
index 000000000..f8f827620
--- /dev/null
+++ b/core/playback/mpv/mpv_suite_test.go
@@ -0,0 +1,17 @@
+package mpv
+
+import (
+ "testing"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestMPV(t *testing.T) {
+ tests.Init(t, false)
+ log.SetLevel(log.LevelFatal)
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "MPV Suite")
+}
diff --git a/core/playback/mpv/mpv_test.go b/core/playback/mpv/mpv_test.go
new file mode 100644
index 000000000..6754b39ac
--- /dev/null
+++ b/core/playback/mpv/mpv_test.go
@@ -0,0 +1,394 @@
+package mpv
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("MPV", func() {
+ var (
+ testScript string
+ tempDir string
+ )
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+
+ // Reset MPV cache
+ mpvOnce = sync.Once{}
+ mpvPath = ""
+ mpvErr = nil
+
+ // Create temporary directory for test files
+ var err error
+ tempDir, err = os.MkdirTemp("", "mpv_test_*")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() { os.RemoveAll(tempDir) })
+
+ // Create mock MPV script that outputs arguments to stdout
+ testScript = createMockMPVScript(tempDir)
+
+ // Configure test MPV path
+ conf.Server.MPVPath = testScript
+ })
+
+ Describe("createMPVCommand", func() {
+ Context("with default template", func() {
+ BeforeEach(func() {
+ conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
+ })
+
+ It("creates correct command with simple paths", func() {
+ args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
+ Expect(args).To(Equal([]string{
+ testScript,
+ "--audio-device=auto",
+ "--no-audio-display",
+ "--pause",
+ "/music/test.mp3",
+ "--input-ipc-server=/tmp/socket",
+ }))
+ })
+
+ It("handles paths with spaces", func() {
+ args := createMPVCommand("auto", "/music/My Album/01 - Song.mp3", "/tmp/socket")
+ Expect(args).To(Equal([]string{
+ testScript,
+ "--audio-device=auto",
+ "--no-audio-display",
+ "--pause",
+ "/music/My Album/01 - Song.mp3",
+ "--input-ipc-server=/tmp/socket",
+ }))
+ })
+
+ It("handles complex device names", func() {
+ deviceName := "coreaudio/AppleUSBAudioEngine:Cambridge Audio :Cambridge Audio USB Audio 1.0:0000:1"
+ args := createMPVCommand(deviceName, "/music/test.mp3", "/tmp/socket")
+ Expect(args).To(Equal([]string{
+ testScript,
+ "--audio-device=" + deviceName,
+ "--no-audio-display",
+ "--pause",
+ "/music/test.mp3",
+ "--input-ipc-server=/tmp/socket",
+ }))
+ })
+ })
+
+ Context("with snapcast template (issue #3619)", func() {
+ BeforeEach(func() {
+ // This is the template that fails with naive space splitting
+ conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
+ })
+
+ It("creates correct command for snapcast integration", func() {
+ args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
+ Expect(args).To(Equal([]string{
+ testScript,
+ "--no-audio-display",
+ "--pause",
+ "/music/test.mp3",
+ "--input-ipc-server=/tmp/socket",
+ "--audio-channels=stereo",
+ "--audio-samplerate=48000",
+ "--audio-format=s16",
+ "--ao=pcm",
+ "--ao-pcm-file=/audio/snapcast_fifo",
+ }))
+ })
+ })
+
+ Context("with wrapper script template", func() {
+ BeforeEach(func() {
+ // Test case that would break with naive splitting due to quoted arguments
+ conf.Server.MPVCmdTemplate = `/tmp/mpv.sh --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo`
+ })
+
+ It("handles wrapper script paths", func() {
+ args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
+ Expect(args).To(Equal([]string{
+ "/tmp/mpv.sh",
+ "--no-audio-display",
+ "--pause",
+ "/music/test.mp3",
+ "--input-ipc-server=/tmp/socket",
+ "--audio-channels=stereo",
+ }))
+ })
+ })
+
+ Context("with extra spaces in template", func() {
+ BeforeEach(func() {
+ conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
+ })
+
+ It("handles extra spaces correctly", func() {
+ args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
+ Expect(args).To(Equal([]string{
+ testScript,
+ "--audio-device=auto",
+ "--no-audio-display",
+ "--pause",
+ "/music/test.mp3",
+ "--input-ipc-server=/tmp/socket",
+ }))
+ })
+ })
+ Context("with paths containing spaces in template arguments", func() {
+ BeforeEach(func() {
+ // Template with spaces in the path arguments themselves
+ conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --ao-pcm-file="/audio/my folder/snapcast_fifo" --input-ipc-server=%s`
+ })
+
+ It("handles spaces in quoted template argument paths", func() {
+ args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
+ // This test reveals the limitation of strings.Fields() - it will split on all spaces
+ // Expected behavior would be to keep the path as one argument
+ Expect(args).To(Equal([]string{
+ testScript,
+ "--no-audio-display",
+ "--pause",
+ "/music/test.mp3",
+ "--ao-pcm-file=/audio/my folder/snapcast_fifo", // This should be one argument
+ "--input-ipc-server=/tmp/socket",
+ }))
+ })
+ })
+
+ Context("with malformed template", func() {
+ BeforeEach(func() {
+ // Template with unmatched quotes that will cause shell parsing to fail
+ conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote`
+ })
+
+ It("returns nil when shell parsing fails", func() {
+ args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
+ Expect(args).To(BeNil())
+ })
+ })
+
+ Context("with empty template", func() {
+ BeforeEach(func() {
+ conf.Server.MPVCmdTemplate = ""
+ })
+
+ It("returns empty slice for empty template", func() {
+ args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
+ Expect(args).To(BeEmpty())
+ })
+ })
+ })
+
+ Describe("start", func() {
+ BeforeEach(func() {
+ conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
+ })
+
+ It("executes MPV command and captures arguments correctly", func() {
+ tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)")
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ deviceName := "auto"
+ filename := "/music/test.mp3"
+ socketName := "/tmp/test_socket"
+
+ args := createMPVCommand(deviceName, filename, socketName)
+ executor, err := start(ctx, args)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Read all the output from stdout (this will block until the process finishes or is canceled)
+ output, err := io.ReadAll(executor)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Parse the captured arguments
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+ Expect(lines).To(HaveLen(6))
+ Expect(lines[0]).To(Equal(testScript))
+ Expect(lines[1]).To(Equal("--audio-device=auto"))
+ Expect(lines[2]).To(Equal("--no-audio-display"))
+ Expect(lines[3]).To(Equal("--pause"))
+ Expect(lines[4]).To(Equal("/music/test.mp3"))
+ Expect(lines[5]).To(Equal("--input-ipc-server=/tmp/test_socket"))
+ })
+
+ It("handles file paths with spaces", func() {
+ tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)")
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ deviceName := "auto"
+ filename := "/music/My Album/01 - My Song.mp3"
+ socketName := "/tmp/test socket"
+
+ args := createMPVCommand(deviceName, filename, socketName)
+ executor, err := start(ctx, args)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Read all the output from stdout (this will block until the process finishes or is canceled)
+ output, err := io.ReadAll(executor)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Parse the captured arguments
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+ Expect(lines).To(ContainElement("/music/My Album/01 - My Song.mp3"))
+ Expect(lines).To(ContainElement("--input-ipc-server=/tmp/test socket"))
+ })
+
+ Context("with complex snapcast configuration", func() {
+ BeforeEach(func() {
+ conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
+ })
+
+ It("passes all snapcast arguments correctly", func() {
+ tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)")
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ deviceName := "auto"
+ filename := "/music/album/track.flac"
+ socketName := "/tmp/mpv-ctrl-test.socket"
+
+ args := createMPVCommand(deviceName, filename, socketName)
+ executor, err := start(ctx, args)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Read all the output from stdout (this will block until the process finishes or is canceled)
+ output, err := io.ReadAll(executor)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Parse the captured arguments
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+
+ // Verify all expected arguments are present
+ Expect(lines).To(ContainElement("--no-audio-display"))
+ Expect(lines).To(ContainElement("--pause"))
+ Expect(lines).To(ContainElement("/music/album/track.flac"))
+ Expect(lines).To(ContainElement("--input-ipc-server=/tmp/mpv-ctrl-test.socket"))
+ Expect(lines).To(ContainElement("--audio-channels=stereo"))
+ Expect(lines).To(ContainElement("--audio-samplerate=48000"))
+ Expect(lines).To(ContainElement("--audio-format=s16"))
+ Expect(lines).To(ContainElement("--ao=pcm"))
+ Expect(lines).To(ContainElement("--ao-pcm-file=/audio/snapcast_fifo"))
+ })
+ })
+
+ Context("with nil args", func() {
+ It("returns error when args is nil", func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+ defer cancel()
+
+ _, err := start(ctx, nil)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(Equal("no command arguments provided"))
+ })
+
+ It("returns error when args is empty", func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+ defer cancel()
+
+ _, err := start(ctx, []string{})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(Equal("no command arguments provided"))
+ })
+ })
+ })
+
+ Describe("mpvCommand", func() {
+ BeforeEach(func() {
+ // Reset the mpv command cache
+ mpvOnce = sync.Once{}
+ mpvPath = ""
+ mpvErr = nil
+ })
+
+ It("finds the configured MPV path", func() {
+ conf.Server.MPVPath = testScript
+ path, err := mpvCommand()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(path).To(Equal(testScript))
+ })
+ })
+
+ Describe("NewTrack integration", func() {
+ var testMediaFile model.MediaFile
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.MPVPath = testScript
+
+ // Create a test media file
+ testMediaFile = model.MediaFile{
+ ID: "test-id",
+ Path: "/music/test.mp3",
+ }
+ })
+
+ Context("with malformed template", func() {
+ BeforeEach(func() {
+ // Template with unmatched quotes that will cause shell parsing to fail
+ conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote`
+ })
+
+ It("returns error when createMPVCommand fails", func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+ defer cancel()
+
+ playbackDone := make(chan bool, 1)
+ _, err := NewTrack(ctx, playbackDone, "auto", testMediaFile)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(Equal("no mpv command arguments provided"))
+ })
+ })
+ })
+})
+
+// createMockMPVScript creates a mock script that outputs arguments to stdout
+func createMockMPVScript(tempDir string) string {
+ var scriptContent string
+ var scriptExt string
+
+ if runtime.GOOS == "windows" {
+ scriptExt = ".bat"
+ scriptContent = `@echo off
+echo %0
+:loop
+if "%~1"=="" goto end
+echo %~1
+shift
+goto loop
+:end
+`
+ } else {
+ scriptExt = ".sh"
+ scriptContent = `#!/bin/sh
+echo "$0"
+for arg in "$@"; do
+ echo "$arg"
+done
+`
+ }
+
+ scriptPath := filepath.Join(tempDir, "mock_mpv"+scriptExt)
+ err := os.WriteFile(scriptPath, []byte(scriptContent), 0755) // nolint:gosec
+ if err != nil {
+ panic(fmt.Sprintf("Failed to create mock script: %v", err))
+ }
+
+ return scriptPath
+}
diff --git a/core/playback/mpv/track.go b/core/playback/mpv/track.go
index b894ff3ad..1038b9190 100644
--- a/core/playback/mpv/track.go
+++ b/core/playback/mpv/track.go
@@ -34,7 +34,10 @@ func NewTrack(ctx context.Context, playbackDoneChannel chan bool, deviceName str
tmpSocketName := socketName("mpv-ctrl-", ".socket")
- args := createMPVCommand(deviceName, mf.Path, tmpSocketName)
+ args := createMPVCommand(deviceName, mf.AbsolutePath(), tmpSocketName)
+ if len(args) == 0 {
+ return nil, fmt.Errorf("no mpv command arguments provided")
+ }
exe, err := start(ctx, args)
if err != nil {
log.Error("Error starting mpv process", err)
@@ -203,7 +206,7 @@ func (t *MpvTrack) IsPlaying() bool {
func waitForSocket(path string, timeout time.Duration, pause time.Duration) error {
start := time.Now()
end := start.Add(timeout)
- var retries int = 0
+ var retries = 0
for {
fileInfo, err := os.Stat(path)
diff --git a/core/playback/queue.go b/core/playback/queue.go
index 0c230a61f..d15eaad96 100644
--- a/core/playback/queue.go
+++ b/core/playback/queue.go
@@ -3,6 +3,7 @@ package playback
import (
"fmt"
"math/rand"
+ "strings"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -21,11 +22,11 @@ func NewQueue() *Queue {
}
func (pd *Queue) String() string {
- filenames := ""
+ var filenames strings.Builder
for idx, item := range pd.Items {
- filenames += fmt.Sprint(idx) + ":" + item.Path + " "
+ filenames.WriteString(fmt.Sprint(idx) + ":" + item.Path + " ")
}
- return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames)
+ return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames.String())
}
// returns the current mediafile or nil
diff --git a/core/playlists.go b/core/playlists.go
deleted file mode 100644
index 4cdab0d38..000000000
--- a/core/playlists.go
+++ /dev/null
@@ -1,382 +0,0 @@
-package core
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/url"
- "os"
- "path/filepath"
- "regexp"
- "strings"
- "time"
-
- "github.com/RaveNoX/go-jsoncommentstrip"
- "github.com/bmatcuk/doublestar/v4"
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/criteria"
- "github.com/navidrome/navidrome/model/request"
- "github.com/navidrome/navidrome/utils/slice"
-)
-
-type Playlists interface {
- ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
- Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
- ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
-}
-
-type playlists struct {
- ds model.DataStore
-}
-
-func NewPlaylists(ds model.DataStore) Playlists {
- return &playlists{ds: ds}
-}
-
-func InPlaylistsPath(folder model.Folder) bool {
- if conf.Server.PlaylistsPath == "" {
- return true
- }
- rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
- for _, path := range strings.Split(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
- if match, _ := doublestar.Match(path, rel); match {
- return true
- }
- }
- return false
-}
-
-func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
- pls, err := s.parsePlaylist(ctx, filename, folder)
- if err != nil {
- log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
- return nil, err
- }
- log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
- err = s.updatePlaylist(ctx, pls)
- if err != nil {
- log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
- }
- return pls, err
-}
-
-func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
- owner, _ := request.UserFrom(ctx)
- pls := &model.Playlist{
- OwnerID: owner.ID,
- Public: false,
- Sync: false,
- }
- err := s.parseM3U(ctx, pls, nil, reader)
- if err != nil {
- log.Error(ctx, "Error parsing playlist", err)
- return nil, err
- }
- err = s.ds.Playlist(ctx).Put(pls)
- if err != nil {
- log.Error(ctx, "Error saving playlist", err)
- return nil, err
- }
- return pls, nil
-}
-
-func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
- pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
- if err != nil {
- return nil, err
- }
-
- file, err := os.Open(pls.Path)
- if err != nil {
- return nil, err
- }
- defer file.Close()
-
- extension := strings.ToLower(filepath.Ext(playlistFile))
- switch extension {
- case ".nsp":
- err = s.parseNSP(ctx, pls, file)
- default:
- err = s.parseM3U(ctx, pls, folder, file)
- }
- return pls, err
-}
-
-func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
- playlistPath := filepath.Join(baseDir, playlistFile)
- info, err := os.Stat(playlistPath)
- if err != nil {
- return nil, err
- }
-
- var extension = filepath.Ext(playlistFile)
- var name = playlistFile[0 : len(playlistFile)-len(extension)]
-
- pls := &model.Playlist{
- Name: name,
- Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
- Public: false,
- Path: playlistPath,
- Sync: true,
- UpdatedAt: info.ModTime(),
- }
- return pls, nil
-}
-
-func getPositionFromOffset(data []byte, offset int64) (line, column int) {
- line = 1
- for _, b := range data[:offset] {
- if b == '\n' {
- line++
- column = 1
- } else {
- column++
- }
- }
- return
-}
-
-func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
- nsp := &nspFile{}
- reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
- reader = jsoncommentstrip.NewReader(reader)
- input, err := io.ReadAll(reader)
- if err != nil {
- return fmt.Errorf("reading SmartPlaylist: %w", err)
- }
- err = json.Unmarshal(input, nsp)
- if err != nil {
- var syntaxErr *json.SyntaxError
- if errors.As(err, &syntaxErr) {
- line, col := getPositionFromOffset(input, syntaxErr.Offset)
- return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
- }
- return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
- }
- pls.Rules = &nsp.Criteria
- if nsp.Name != "" {
- pls.Name = nsp.Name
- }
- if nsp.Comment != "" {
- pls.Comment = nsp.Comment
- }
- return nil
-}
-
-func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
- mediaFileRepository := s.ds.MediaFile(ctx)
- var mfs model.MediaFiles
- for lines := range slice.CollectChunks(slice.LinesFrom(reader), 400) {
- filteredLines := make([]string, 0, len(lines))
- for _, line := range lines {
- line := strings.TrimSpace(line)
- if strings.HasPrefix(line, "#PLAYLIST:") {
- pls.Name = line[len("#PLAYLIST:"):]
- continue
- }
- // Skip empty lines and extended info
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- if strings.HasPrefix(line, "file://") {
- line = strings.TrimPrefix(line, "file://")
- line, _ = url.QueryUnescape(line)
- }
- if !model.IsAudioFile(line) {
- continue
- }
- filteredLines = append(filteredLines, line)
- }
- paths, err := s.normalizePaths(ctx, pls, folder, filteredLines)
- if err != nil {
- log.Warn(ctx, "Error normalizing paths in playlist", "playlist", pls.Name, err)
- continue
- }
- found, err := mediaFileRepository.FindByPaths(paths)
- if err != nil {
- log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
- continue
- }
- existing := make(map[string]int, len(found))
- for idx := range found {
- existing[strings.ToLower(found[idx].Path)] = idx
- }
- for _, path := range paths {
- idx, ok := existing[strings.ToLower(path)]
- if ok {
- mfs = append(mfs, found[idx])
- } else {
- log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path)
- }
- }
- }
- if pls.Name == "" {
- pls.Name = time.Now().Format(time.RFC3339)
- }
- pls.Tracks = nil
- pls.AddMediaFiles(mfs)
-
- return nil
-}
-
-// TODO This won't work for multiple libraries
-func (s *playlists) normalizePaths(ctx context.Context, pls *model.Playlist, folder *model.Folder, lines []string) ([]string, error) {
- libRegex, err := s.compileLibraryPaths(ctx)
- if err != nil {
- return nil, err
- }
-
- res := make([]string, 0, len(lines))
- for idx, line := range lines {
- var libPath string
- var filePath string
-
- if folder != nil && !filepath.IsAbs(line) {
- libPath = folder.LibraryPath
- filePath = filepath.Join(folder.AbsolutePath(), line)
- } else {
- cleanLine := filepath.Clean(line)
- if libPath = libRegex.FindString(cleanLine); libPath != "" {
- filePath = cleanLine
- }
- }
-
- if libPath != "" {
- if rel, err := filepath.Rel(libPath, filePath); err == nil {
- res = append(res, rel)
- } else {
- log.Debug(ctx, "Error getting relative path", "playlist", pls.Name, "path", line, "libPath", libPath,
- "filePath", filePath, err)
- }
- } else {
- log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
- }
- }
- return slice.Map(res, filepath.ToSlash), nil
-}
-
-func (s *playlists) compileLibraryPaths(ctx context.Context) (*regexp.Regexp, error) {
- libs, err := s.ds.Library(ctx).GetAll()
- if err != nil {
- return nil, err
- }
-
- // Create regex patterns for each library path
- patterns := make([]string, len(libs))
- for i, lib := range libs {
- cleanPath := filepath.Clean(lib.Path)
- escapedPath := regexp.QuoteMeta(cleanPath)
- patterns[i] = fmt.Sprintf("^%s(?:/|$)", escapedPath)
- }
- // Combine all patterns into a single regex
- combinedPattern := strings.Join(patterns, "|")
- re, err := regexp.Compile(combinedPattern)
- if err != nil {
- return nil, fmt.Errorf("compiling library paths `%s`: %w", combinedPattern, err)
- }
- return re, nil
-}
-
-func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
- owner, _ := request.UserFrom(ctx)
-
- pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
- if err != nil && !errors.Is(err, model.ErrNotFound) {
- return err
- }
- if err == nil && !pls.Sync {
- log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
- return nil
- }
-
- if err == nil {
- log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
- newPls.ID = pls.ID
- newPls.Name = pls.Name
- newPls.Comment = pls.Comment
- newPls.OwnerID = pls.OwnerID
- newPls.Public = pls.Public
- newPls.EvaluatedAt = &time.Time{}
- } else {
- log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
- newPls.OwnerID = owner.ID
- newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
- }
- return s.ds.Playlist(ctx).Put(newPls)
-}
-
-func (s *playlists) Update(ctx context.Context, playlistID string,
- name *string, comment *string, public *bool,
- idsToAdd []string, idxToRemove []int) error {
- needsInfoUpdate := name != nil || comment != nil || public != nil
- needsTrackRefresh := len(idxToRemove) > 0
-
- return s.ds.WithTxImmediate(func(tx model.DataStore) error {
- var pls *model.Playlist
- var err error
- repo := tx.Playlist(ctx)
- tracks := repo.Tracks(playlistID, true)
- if tracks == nil {
- return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
- }
- if needsTrackRefresh {
- pls, err = repo.GetWithTracks(playlistID, true, false)
- pls.RemoveTracks(idxToRemove)
- pls.AddTracks(idsToAdd)
- } else {
- if len(idsToAdd) > 0 {
- _, err = tracks.Add(idsToAdd)
- if err != nil {
- return err
- }
- }
- if needsInfoUpdate {
- pls, err = repo.Get(playlistID)
- }
- }
- if err != nil {
- return err
- }
- if !needsTrackRefresh && !needsInfoUpdate {
- return nil
- }
-
- if name != nil {
- pls.Name = *name
- }
- if comment != nil {
- pls.Comment = *comment
- }
- if public != nil {
- pls.Public = *public
- }
- // Special case: The playlist is now empty
- if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
- if err = tracks.DeleteAll(); err != nil {
- return err
- }
- }
- return repo.Put(pls)
- })
-}
-
-type nspFile struct {
- criteria.Criteria
- Name string `json:"name"`
- Comment string `json:"comment"`
-}
-
-func (i *nspFile) UnmarshalJSON(data []byte) error {
- m := map[string]interface{}{}
- err := json.Unmarshal(data, &m)
- if err != nil {
- return err
- }
- i.Name, _ = m["name"].(string)
- i.Comment, _ = m["comment"].(string)
- return json.Unmarshal(data, &i.Criteria)
-}
diff --git a/core/playlists/import.go b/core/playlists/import.go
new file mode 100644
index 000000000..9d3ecabc5
--- /dev/null
+++ b/core/playlists/import.go
@@ -0,0 +1,200 @@
+package playlists
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/utils/ioutils"
+ "golang.org/x/text/unicode/norm"
+)
+
+func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error) {
+ absPath, err := filepath.Abs(absolutePath)
+ if err != nil {
+ return nil, fmt.Errorf("resolving absolute path: %w", err)
+ }
+
+ dir := filepath.Dir(absPath)
+ filename := filepath.Base(absPath)
+
+ folder, err := s.resolveFolder(ctx, dir)
+ if err != nil && !errors.Is(err, errNotInLibrary) {
+ return nil, err
+ }
+ if err == nil {
+ pls, err := s.importFromFolder(ctx, folder, filename, sync)
+ if err != nil {
+ return nil, err
+ }
+ if pls.ID != "" && pls.Sync != sync {
+ pls.Sync = sync
+ if putErr := s.ds.Playlist(ctx).Put(pls); putErr != nil {
+ return nil, putErr
+ }
+ }
+ return pls, nil
+ }
+
+ log.Debug(ctx, "Playlist file is outside all libraries, using path-based import", "path", absPath)
+ pls, err := s.newSyncedPlaylist(dir, filename)
+ if err != nil {
+ return nil, fmt.Errorf("reading playlist file: %w", err)
+ }
+ pls.Sync = sync
+
+ file, err := os.Open(absPath)
+ if err != nil {
+ return nil, fmt.Errorf("opening playlist file: %w", err)
+ }
+ defer file.Close()
+
+ reader := ioutils.UTF8Reader(file)
+ if err := s.parseM3U(ctx, pls, nil, reader); err != nil {
+ return nil, err
+ }
+ if err := s.updatePlaylist(ctx, pls, sync); err != nil {
+ return nil, err
+ }
+ return pls, nil
+}
+
+var errNotInLibrary = fmt.Errorf("path not in any library")
+
+func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folder, error) {
+ libs, err := s.ds.Library(ctx).GetAll()
+ if err != nil {
+ return nil, err
+ }
+ matcher := newLibraryMatcher(libs)
+ lib, ok := matcher.findLibrary(dir)
+ if !ok {
+ return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir)
+ }
+
+ folder, err := s.ds.Folder(ctx).GetByPath(lib, dir)
+ if err != nil {
+ return nil, fmt.Errorf("resolving folder for path %s: %w", dir, err)
+ }
+ folder.LibraryPath = lib.Path
+ return folder, nil
+}
+
+func (s *playlists) ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
+ return s.importFromFolder(ctx, folder, filename, false)
+}
+
+func (s *playlists) importFromFolder(ctx context.Context, folder *model.Folder, filename string, forceSync bool) (*model.Playlist, error) {
+ pls, err := s.parsePlaylist(ctx, filename, folder)
+ if err != nil {
+ log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
+ return nil, err
+ }
+ log.Debug(ctx, "Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
+ err = s.updatePlaylist(ctx, pls, forceSync)
+ if err != nil {
+ log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
+ }
+ return pls, err
+}
+
+func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
+ owner, _ := request.UserFrom(ctx)
+ pls := &model.Playlist{
+ OwnerID: owner.ID,
+ Public: false,
+ Sync: false,
+ }
+ err := s.parseM3U(ctx, pls, nil, reader)
+ if err != nil {
+ log.Error(ctx, "Error parsing playlist", err)
+ return nil, err
+ }
+ err = s.ds.Playlist(ctx).Put(pls)
+ if err != nil {
+ log.Error(ctx, "Error saving playlist", err)
+ return nil, err
+ }
+ return pls, nil
+}
+
+func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
+ pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
+ if err != nil {
+ return nil, err
+ }
+
+ file, err := os.Open(pls.Path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ reader := ioutils.UTF8Reader(file)
+ extension := strings.ToLower(filepath.Ext(playlistFile))
+ switch extension {
+ case ".nsp":
+ err = s.parseNSP(ctx, pls, reader)
+ default:
+ err = s.parseM3U(ctx, pls, folder, reader)
+ }
+ return pls, err
+}
+
+// findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode
+// normalization forms to handle cross-platform filesystem differences.
+func (s *playlists) findByPathNormalized(ctx context.Context, path string) (*model.Playlist, error) {
+ pls, err := s.ds.Playlist(ctx).FindByPath(path)
+ if errors.Is(err, model.ErrNotFound) {
+ altPath := norm.NFD.String(path)
+ if altPath == path {
+ altPath = norm.NFC.String(path)
+ }
+ if altPath != path {
+ pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
+ }
+ }
+ return pls, err
+}
+
+func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, forceSync bool) error {
+ owner, _ := request.UserFrom(ctx)
+
+ pls, err := s.findByPathNormalized(ctx, newPls.Path)
+ if err != nil && !errors.Is(err, model.ErrNotFound) {
+ return err
+ }
+ alreadyImportedAndNotSynced := err == nil && !pls.Sync && !forceSync
+ if alreadyImportedAndNotSynced {
+ log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
+ return nil
+ }
+
+ if err == nil {
+ log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
+ newPls.ID = pls.ID
+ newPls.Name = pls.Name
+ newPls.Comment = pls.Comment
+ newPls.OwnerID = pls.OwnerID
+ newPls.Public = pls.Public
+ newPls.UploadedImage = pls.UploadedImage // Preserve manual upload
+ newPls.EvaluatedAt = &time.Time{}
+ } else {
+ log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
+ newPls.OwnerID = owner.ID
+ // For NSP files, Public may already be set from the file; for M3U, use server default
+ if !newPls.IsSmartPlaylist() {
+ newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
+ }
+ }
+ return s.ds.Playlist(ctx).Put(newPls)
+}
diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go
new file mode 100644
index 000000000..f2866fb60
--- /dev/null
+++ b/core/playlists/import_test.go
@@ -0,0 +1,1085 @@
+package playlists_test
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core"
+ "github.com/navidrome/navidrome/core/playlists"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/criteria"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "golang.org/x/text/unicode/norm"
+)
+
+var _ = Describe("Playlists - Import", func() {
+ var ds *tests.MockDataStore
+ var ps playlists.Playlists
+ var mockPlsRepo *tests.MockPlaylistRepo
+ var mockLibRepo *tests.MockLibraryRepo
+ ctx := context.Background()
+
+ BeforeEach(func() {
+ mockPlsRepo = tests.CreateMockPlaylistRepo()
+ mockLibRepo = &tests.MockLibraryRepo{}
+ ds = &tests.MockDataStore{
+ MockedPlaylist: mockPlsRepo,
+ MockedLibrary: mockLibRepo,
+ }
+ ctx = request.WithUser(ctx, model.User{ID: "123"})
+ })
+
+ Describe("ImportFromFolder", func() {
+ var folder *model.Folder
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+ ds.MockedMediaFile = &mockedMediaFileRepo{}
+ libPath, _ := os.Getwd()
+ // Set up library with the actual library path that matches the folder
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: libPath}})
+ folder = &model.Folder{
+ ID: "1",
+ LibraryID: 1,
+ LibraryPath: libPath,
+ Path: "tests/fixtures",
+ Name: "playlists",
+ }
+ })
+
+ Describe("M3U", func() {
+ It("parses well-formed playlists", func() {
+ pls, err := ps.ImportFromFolder(ctx, folder, "pls1.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.OwnerID).To(Equal("123"))
+ Expect(pls.Tracks).To(HaveLen(2))
+ Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
+ Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
+ Expect(mockPlsRepo.Last).To(Equal(pls))
+ })
+
+ It("parses playlists using LF ending", func() {
+ pls, err := ps.ImportFromFolder(ctx, folder, "lf-ended.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(2))
+ })
+
+ It("parses playlists using CR ending (old Mac format)", func() {
+ pls, err := ps.ImportFromFolder(ctx, folder, "cr-ended.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(2))
+ })
+
+ It("parses playlists with UTF-8 BOM marker", func() {
+ pls, err := ps.ImportFromFolder(ctx, folder, "bom-test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.OwnerID).To(Equal("123"))
+ Expect(pls.Name).To(Equal("Test Playlist"))
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
+ })
+
+ It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() {
+ pls, err := ps.ImportFromFolder(ctx, folder, "bom-test-utf16.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.OwnerID).To(Equal("123"))
+ Expect(pls.Name).To(Equal("UTF-16 Test Playlist"))
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
+ })
+
+ It("parses #EXTALBUMARTURL with HTTP URL", func() {
+ conf.Server.EnableM3UExternalAlbumArt = true
+
+ pls, err := ps.ImportFromFolder(ctx, folder, "pls-with-art-url.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg"))
+ Expect(pls.Tracks).To(HaveLen(2))
+ })
+
+ It("parses #EXTALBUMARTURL with absolute local path", func() {
+ tmpDir := GinkgoT().TempDir()
+ imgPath := filepath.Join(tmpDir, "cover.jpg")
+ Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
+
+ m3u := fmt.Sprintf("#EXTALBUMARTURL:%s\ntest.mp3\ntest.ogg\n", imgPath)
+ plsFile := filepath.Join(tmpDir, "test.m3u")
+ Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
+
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}}
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.ExternalImageURL).To(Equal(imgPath))
+ })
+
+ It("parses #EXTALBUMARTURL with relative local path", func() {
+ tmpDir := GinkgoT().TempDir()
+ Expect(os.WriteFile(filepath.Join(tmpDir, "cover.jpg"), []byte("fake image"), 0600)).To(Succeed())
+
+ m3u := "#EXTALBUMARTURL:cover.jpg\ntest.mp3\n"
+ plsFile := filepath.Join(tmpDir, "test.m3u")
+ Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
+
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.ExternalImageURL).To(Equal(filepath.Join(tmpDir, "cover.jpg")))
+ })
+
+ It("parses #EXTALBUMARTURL with file:// URL", func() {
+ tmpDir := GinkgoT().TempDir()
+ imgPath := filepath.Join(tmpDir, "my cover.jpg")
+ Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
+
+ m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", strings.ReplaceAll(imgPath, " ", "%20"))
+ plsFile := filepath.Join(tmpDir, "test.m3u")
+ Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
+
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.ExternalImageURL).To(Equal(imgPath))
+ })
+
+ It("preserves + in file:// URLs (PathUnescape, not QueryUnescape)", func() {
+ tmpDir := GinkgoT().TempDir()
+ imgPath := filepath.Join(tmpDir, "A+B.jpg")
+ Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
+
+ m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", imgPath)
+ plsFile := filepath.Join(tmpDir, "test.m3u")
+ Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
+
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.ExternalImageURL).To(Equal(imgPath))
+ })
+
+ It("rejects #EXTALBUMARTURL with absolute path outside library boundaries", func() {
+ tests.SkipOnWindows("relies on Unix /etc filesystem")
+ tmpDir := GinkgoT().TempDir()
+
+ m3u := "#EXTALBUMARTURL:/etc/passwd\ntest.mp3\n"
+ plsFile := filepath.Join(tmpDir, "test.m3u")
+ Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
+
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.ExternalImageURL).To(BeEmpty())
+ })
+
+ It("rejects #EXTALBUMARTURL with file:// URL outside library boundaries", func() {
+ tmpDir := GinkgoT().TempDir()
+
+ m3u := "#EXTALBUMARTURL:file:///etc/passwd\ntest.mp3\n"
+ plsFile := filepath.Join(tmpDir, "test.m3u")
+ Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
+
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.ExternalImageURL).To(BeEmpty())
+ })
+
+ It("rejects #EXTALBUMARTURL with relative path escaping library", func() {
+ tmpDir := GinkgoT().TempDir()
+
+ m3u := "#EXTALBUMARTURL:../../etc/passwd\ntest.mp3\n"
+ plsFile := filepath.Join(tmpDir, "test.m3u")
+ Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
+
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.ExternalImageURL).To(BeEmpty())
+ })
+
+ It("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() {
+ conf.Server.EnableM3UExternalAlbumArt = false
+
+ tmpDir := GinkgoT().TempDir()
+ m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\ntest.mp3\n"
+ plsFile := filepath.Join(tmpDir, "test.m3u")
+ Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
+
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.ExternalImageURL).To(BeEmpty())
+ })
+
+ It("updates ExternalImageURL on re-scan even when UploadedImage is set", func() {
+ conf.Server.EnableM3UExternalAlbumArt = true
+
+ tmpDir := GinkgoT().TempDir()
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n"
+ plsFile := filepath.Join(tmpDir, "test.m3u")
+ Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
+
+ existingPls := &model.Playlist{
+ ID: "existing-id",
+ Name: "Existing Playlist",
+ Path: plsFile,
+ Sync: true,
+ UploadedImage: "existing-id.jpg",
+ ExternalImageURL: "https://example.com/old-cover.jpg",
+ }
+ mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
+
+ plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.UploadedImage).To(Equal("existing-id.jpg"))
+ Expect(pls.ExternalImageURL).To(Equal("https://example.com/new-cover.jpg"))
+ })
+
+ It("skips non-synced playlist on re-import (respects user's choice)", func() {
+ tmpDir := GinkgoT().TempDir()
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ plsFile := filepath.Join(tmpDir, "test.m3u")
+ Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
+
+ existingPls := &model.Playlist{
+ ID: "existing-id",
+ Name: "Existing Playlist",
+ Path: plsFile,
+ Sync: false,
+ OwnerID: "123",
+ }
+ mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
+
+ plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ // updatePlaylist skips the non-synced playlist, so the returned
+ // playlist has no ID (was never persisted/updated).
+ Expect(pls.ID).To(BeEmpty())
+ })
+
+ It("clears ExternalImageURL on re-scan when directive is removed", func() {
+ tmpDir := GinkgoT().TempDir()
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ m3u := "test.mp3\n"
+ plsFile := filepath.Join(tmpDir, "test.m3u")
+ Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
+
+ existingPls := &model.Playlist{
+ ID: "existing-id",
+ Name: "Existing Playlist",
+ Path: plsFile,
+ Sync: true,
+ ExternalImageURL: "https://example.com/old-cover.jpg",
+ }
+ mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
+
+ plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.ExternalImageURL).To(BeEmpty())
+ })
+ })
+
+ Describe("NSP", func() {
+ It("parses well-formed playlists", func() {
+ pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last).To(Equal(pls))
+ Expect(pls.OwnerID).To(Equal("123"))
+ Expect(pls.Name).To(Equal("Recently Played"))
+ Expect(pls.Comment).To(Equal("Recently played tracks"))
+ Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
+ Expect(pls.Rules.Order).To(Equal("desc"))
+ Expect(pls.Rules.Limit).To(Equal(100))
+ Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
+ })
+ It("returns an error if the playlist is not well-formed", func() {
+ tests.SkipOnWindows("line-ending differences affect JSON error offset")
+ _, err := ps.ImportFromFolder(ctx, folder, "invalid_json.nsp")
+ Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
+ })
+ It("parses NSP with public: true and creates public playlist", func() {
+ pls, err := ps.ImportFromFolder(ctx, folder, "public_playlist.nsp")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Name).To(Equal("Public Playlist"))
+ Expect(pls.Public).To(BeTrue())
+ })
+ It("parses NSP with public: false and creates private playlist", func() {
+ pls, err := ps.ImportFromFolder(ctx, folder, "private_playlist.nsp")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Name).To(Equal("Private Playlist"))
+ Expect(pls.Public).To(BeFalse())
+ })
+ It("uses server default when public field is absent", func() {
+ conf.Server.DefaultPlaylistPublicVisibility = true
+
+ pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Name).To(Equal("Recently Played"))
+ Expect(pls.Public).To(BeTrue()) // Should be true since server default is true
+ })
+ })
+
+ DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)",
+ func(storedForm, filesystemForm string) {
+ tests.SkipOnWindows("/tmp hardcoded in test")
+ // Use Polish characters that decompose: ó (U+00F3) -> o + combining acute (U+006F + U+0301)
+ plsNameNFC := "Piosenki_Polskie_zółć" // NFC form (composed)
+ plsNameNFD := norm.NFD.String(plsNameNFC)
+ Expect(plsNameNFD).ToNot(Equal(plsNameNFC)) // Verify they differ
+
+ nameByForm := map[string]string{"NFC": plsNameNFC, "NFD": plsNameNFD}
+ storedName := nameByForm[storedForm]
+ filesystemName := nameByForm[filesystemForm]
+
+ tmpDir := GinkgoT().TempDir()
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ // Create the playlist file on disk with the filesystem's normalization form
+ plsFile := tmpDir + "/" + filesystemName + ".m3u"
+ Expect(os.WriteFile(plsFile, []byte("#PLAYLIST:Test\n"), 0600)).To(Succeed())
+
+ // Pre-populate mock repo with the stored normalization form
+ storedPath := tmpDir + "/" + storedName + ".m3u"
+ existingPls := &model.Playlist{
+ ID: "existing-id",
+ Name: "Existing Playlist",
+ Path: storedPath,
+ Sync: true,
+ }
+ mockPlsRepo.PathMap = map[string]*model.Playlist{storedPath: existingPls}
+
+ // Import using the filesystem's normalization form
+ plsFolder := &model.Folder{
+ ID: "1",
+ LibraryID: 1,
+ LibraryPath: tmpDir,
+ Path: "",
+ Name: "",
+ }
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, filesystemName+".m3u")
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should update existing playlist, not create new one
+ Expect(pls.ID).To(Equal("existing-id"))
+ Expect(pls.Name).To(Equal("Existing Playlist"))
+ },
+ Entry("finds NFD-stored playlist when filesystem provides NFC path", "NFD", "NFC"),
+ Entry("finds NFC-stored playlist when filesystem provides NFD path", "NFC", "NFD"),
+ )
+
+ Describe("Cross-library relative paths", func() {
+ var tmpDir, plsDir, songsDir string
+
+ BeforeEach(func() {
+ // Create temp directory structure
+ tmpDir = GinkgoT().TempDir()
+ plsDir = tmpDir + "/playlists"
+ songsDir = tmpDir + "/songs"
+ Expect(os.Mkdir(plsDir, 0755)).To(Succeed())
+ Expect(os.Mkdir(songsDir, 0755)).To(Succeed())
+
+ // Setup two different libraries with paths matching our temp structure
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: songsDir},
+ {ID: 2, Path: plsDir},
+ })
+
+ // Create a mock media file repository that returns files for both libraries
+ // Note: The paths are relative to their respective library roots
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{
+ data: []string{
+ "abc.mp3", // This is songs/abc.mp3 relative to songsDir
+ "def.mp3", // This is playlists/def.mp3 relative to plsDir
+ },
+ }
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+ })
+
+ It("handles relative paths that reference files in other libraries", func() {
+ // Create a temporary playlist file with relative path
+ plsContent := "#PLAYLIST:Cross Library Test\n../songs/abc.mp3\ndef.mp3"
+ plsFile := plsDir + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ // Playlist is in the Playlists library folder
+ // Important: Path should be relative to LibraryPath, and Name is the folder name
+ plsFolder := &model.Folder{
+ ID: "2",
+ LibraryID: 2,
+ LibraryPath: plsDir,
+ Path: "",
+ Name: "",
+ }
+
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(2))
+ Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
+ Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library
+ })
+
+ It("ignores paths that point outside all libraries", func() {
+ // Create a temporary playlist file with path outside libraries
+ plsContent := "#PLAYLIST:Outside Test\n../../outside.mp3\nabc.mp3"
+ plsFile := plsDir + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ plsFolder := &model.Folder{
+ ID: "2",
+ LibraryID: 2,
+ LibraryPath: plsDir,
+ Path: "",
+ Name: "",
+ }
+
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ // Should only find abc.mp3, not outside.mp3
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal("abc.mp3"))
+ })
+
+ It("handles relative paths with multiple '../' components", func() {
+ // Create a nested structure: tmpDir/playlists/subfolder/test.m3u
+ subFolder := plsDir + "/subfolder"
+ Expect(os.Mkdir(subFolder, 0755)).To(Succeed())
+
+ // Create the media file in the subfolder directory
+ // The mock will return it as "def.mp3" relative to plsDir
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{
+ data: []string{
+ "abc.mp3", // From songsDir library
+ "def.mp3", // From plsDir library root
+ },
+ }
+
+ // From subfolder, ../../songs/abc.mp3 should resolve to songs library
+ // ../def.mp3 should resolve to plsDir/def.mp3
+ plsContent := "#PLAYLIST:Nested Test\n../../songs/abc.mp3\n../def.mp3"
+ plsFile := subFolder + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ // The folder: AbsolutePath = LibraryPath + Path + Name
+ // So for /playlists/subfolder: LibraryPath=/playlists, Path="", Name="subfolder"
+ plsFolder := &model.Folder{
+ ID: "2",
+ LibraryID: 2,
+ LibraryPath: plsDir,
+ Path: "", // Empty because subfolder is directly under library root
+ Name: "subfolder", // The folder name
+ }
+
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(2))
+ Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
+ Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library root
+ })
+
+ It("correctly resolves libraries when one path is a prefix of another", func() {
+ // This tests the bug where /music would match before /music-classical
+ // Create temp directory structure with prefix conflict
+ tmpDir := GinkgoT().TempDir()
+ musicDir := tmpDir + "/music"
+ musicClassicalDir := tmpDir + "/music-classical"
+ Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
+ Expect(os.Mkdir(musicClassicalDir, 0755)).To(Succeed())
+
+ // Setup two libraries where one is a prefix of the other
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: musicDir}, // /tmp/xxx/music
+ {ID: 2, Path: musicClassicalDir}, // /tmp/xxx/music-classical
+ })
+
+ // Mock will return tracks from both libraries
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{
+ data: []string{
+ "rock.mp3", // From music library
+ "bach.mp3", // From music-classical library
+ },
+ }
+
+ // Create playlist in music library that references music-classical
+ plsContent := "#PLAYLIST:Cross Prefix Test\nrock.mp3\n../music-classical/bach.mp3"
+ plsFile := musicDir + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ plsFolder := &model.Folder{
+ ID: "1",
+ LibraryID: 1,
+ LibraryPath: musicDir,
+ Path: "",
+ Name: "",
+ }
+
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(2))
+ Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library
+ Expect(pls.Tracks[1].Path).To(Equal("bach.mp3")) // From music-classical library (not music!)
+ })
+
+ It("correctly handles identical relative paths from different libraries", func() {
+ // This tests the bug where two libraries have files at the same relative path
+ // and only one appears in the playlist
+ tmpDir := GinkgoT().TempDir()
+ musicDir := tmpDir + "/music"
+ classicalDir := tmpDir + "/classical"
+ Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
+ Expect(os.Mkdir(classicalDir, 0755)).To(Succeed())
+ Expect(os.MkdirAll(musicDir+"/album", 0755)).To(Succeed())
+ Expect(os.MkdirAll(classicalDir+"/album", 0755)).To(Succeed())
+ // Create placeholder files so paths resolve correctly
+ Expect(os.WriteFile(musicDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
+ Expect(os.WriteFile(classicalDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
+
+ // Both libraries have a file at "album/track.mp3"
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: musicDir},
+ {ID: 2, Path: classicalDir},
+ })
+
+ // Mock returns files with same relative path but different IDs and library IDs
+ // Keys use the library-qualified format: "libraryID:path"
+ ds.MockedMediaFile = &mockedMediaFileRepo{
+ data: map[string]model.MediaFile{
+ "1:album/track.mp3": {ID: "music-track", Path: "album/track.mp3", LibraryID: 1, Title: "Rock Song"},
+ "2:album/track.mp3": {ID: "classical-track", Path: "album/track.mp3", LibraryID: 2, Title: "Classical Piece"},
+ },
+ }
+ // Recreate playlists service to pick up new mock
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ // Create playlist in music library that references both tracks
+ plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
+ plsFile := musicDir + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ plsFolder := &model.Folder{
+ ID: "1",
+ LibraryID: 1,
+ LibraryPath: musicDir,
+ Path: "",
+ Name: "",
+ }
+
+ pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should have BOTH tracks, not just one
+ Expect(pls.Tracks).To(HaveLen(2), "Playlist should contain both tracks with same relative path")
+
+ // Verify we got tracks from DIFFERENT libraries (the key fix!)
+ // Collect the library IDs
+ libIDs := make(map[int]bool)
+ for _, track := range pls.Tracks {
+ libIDs[track.LibraryID] = true
+ }
+ Expect(libIDs).To(HaveLen(2), "Tracks should come from two different libraries")
+ Expect(libIDs[1]).To(BeTrue(), "Should have track from library 1")
+ Expect(libIDs[2]).To(BeTrue(), "Should have track from library 2")
+
+ // Both tracks should have the same relative path
+ Expect(pls.Tracks[0].Path).To(Equal("album/track.mp3"))
+ Expect(pls.Tracks[1].Path).To(Equal("album/track.mp3"))
+ })
+ })
+ })
+
+ Describe("ImportFile", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}}
+ })
+
+ It("resolves file inside a library and imports it", func() {
+ tmpDir := GinkgoT().TempDir()
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+
+ mockFolderRepo := &mockFolderRepoForImport{
+ folder: &model.Folder{
+ ID: "1",
+ LibraryID: 1,
+ LibraryPath: tmpDir,
+ Path: "",
+ Name: "",
+ },
+ }
+ ds.MockedFolder = mockFolderRepo
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ plsContent := "#PLAYLIST:My Playlist\ntest.mp3\ntest.ogg\n"
+ plsFile := filepath.Join(tmpDir, "my-playlist.m3u")
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ pls, err := ps.ImportFile(ctx, plsFile, true)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Name).To(Equal("My Playlist"))
+ Expect(pls.Tracks).To(HaveLen(2))
+ Expect(pls.Path).To(Equal(plsFile))
+ Expect(pls.Sync).To(BeTrue())
+ })
+
+ It("records path for files outside all libraries", func() {
+ tmpDir := GinkgoT().TempDir()
+ libDir := filepath.Join(tmpDir, "music")
+ Expect(os.Mkdir(libDir, 0755)).To(Succeed())
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: libDir}})
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ plsContent := "#PLAYLIST:External Playlist\n" + libDir + "/test.mp3\n"
+ plsFile := filepath.Join(tmpDir, "external.m3u")
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ pls, err := ps.ImportFile(ctx, plsFile, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Name).To(Equal("External Playlist"))
+ Expect(pls.Path).To(Equal(plsFile))
+ Expect(pls.Sync).To(BeFalse())
+ })
+
+ It("imports with Sync=false", func() {
+ tmpDir := GinkgoT().TempDir()
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+
+ mockFolderRepo := &mockFolderRepoForImport{
+ folder: &model.Folder{
+ ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
+ },
+ }
+ ds.MockedFolder = mockFolderRepo
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ plsFile := filepath.Join(tmpDir, "test.m3u")
+ Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
+
+ pls, err := ps.ImportFile(ctx, plsFile, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Sync).To(BeFalse())
+ })
+
+ It("imports with Sync=true", func() {
+ tmpDir := GinkgoT().TempDir()
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+
+ mockFolderRepo := &mockFolderRepoForImport{
+ folder: &model.Folder{
+ ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
+ },
+ }
+ ds.MockedFolder = mockFolderRepo
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ plsFile := filepath.Join(tmpDir, "test.m3u")
+ Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
+
+ pls, err := ps.ImportFile(ctx, plsFile, true)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Sync).To(BeTrue())
+ })
+
+ It("upgrades non-synced playlist to synced on re-import with sync=true", func() {
+ tmpDir := GinkgoT().TempDir()
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
+
+ mockFolderRepo := &mockFolderRepoForImport{
+ folder: &model.Folder{
+ ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
+ },
+ }
+ ds.MockedFolder = mockFolderRepo
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+
+ plsFile := filepath.Join(tmpDir, "test.m3u")
+ Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
+
+ existingPls := &model.Playlist{
+ ID: "existing-id", Name: "Existing", Path: plsFile,
+ Sync: false, OwnerID: "123",
+ }
+ mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
+
+ pls, err := ps.ImportFile(ctx, plsFile, true)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.ID).To(Equal("existing-id"))
+ Expect(pls.Sync).To(BeTrue())
+ })
+ })
+
+ Describe("ImportM3U", func() {
+ var repo *mockedMediaFileFromListRepo
+ BeforeEach(func() {
+ repo = &mockedMediaFileFromListRepo{}
+ ds.MockedMediaFile = repo
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
+ ctx = request.WithUser(ctx, model.User{ID: "123"})
+ })
+
+ It("parses well-formed playlists", func() {
+ repo.data = []string{
+ "tests/test.mp3",
+ "tests/test.ogg",
+ "tests/01 Invisible (RED) Edit Version.mp3",
+ "downloads/newfile.flac",
+ }
+ m3u := strings.Join([]string{
+ "#PLAYLIST:playlist 1",
+ "/music/tests/test.mp3",
+ "/music/tests/test.ogg",
+ "/new/downloads/newfile.flac",
+ "file:///music/tests/01%20Invisible%20(RED)%20Edit%20Version.mp3",
+ }, "\n")
+ f := strings.NewReader(m3u)
+
+ pls, err := ps.ImportM3U(ctx, f)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.OwnerID).To(Equal("123"))
+ Expect(pls.Name).To(Equal("playlist 1"))
+ Expect(pls.Sync).To(BeFalse())
+ Expect(pls.Tracks).To(HaveLen(4))
+ Expect(pls.Tracks[0].Path).To(Equal("tests/test.mp3"))
+ Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
+ Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
+ Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
+ Expect(mockPlsRepo.Last).To(Equal(pls))
+ })
+
+ It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
+ repo.data = []string{
+ "tests/test.mp3",
+ "tests/test.ogg",
+ "/tests/01 Invisible (RED) Edit Version.mp3",
+ }
+ m3u := strings.Join([]string{
+ "/music/tests/test.mp3",
+ "/music/tests/test.ogg",
+ }, "\n")
+ f := strings.NewReader(m3u)
+ pls, err := ps.ImportM3U(ctx, f)
+ Expect(err).ToNot(HaveOccurred())
+ _, err = time.Parse(time.RFC3339, pls.Name)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(2))
+ })
+
+ It("returns only tracks that exist in the database and in the same order as the m3u", func() {
+ repo.data = []string{
+ "album1/test1.mp3",
+ "album2/test2.mp3",
+ "album3/test3.mp3",
+ }
+ m3u := strings.Join([]string{
+ "/music/album3/test3.mp3",
+ "/music/album1/test1.mp3",
+ "/music/album4/test4.mp3",
+ "/music/album2/test2.mp3",
+ }, "\n")
+ f := strings.NewReader(m3u)
+ pls, err := ps.ImportM3U(ctx, f)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(3))
+ Expect(pls.Tracks[0].Path).To(Equal("album3/test3.mp3"))
+ Expect(pls.Tracks[1].Path).To(Equal("album1/test1.mp3"))
+ Expect(pls.Tracks[2].Path).To(Equal("album2/test2.mp3"))
+ })
+
+ It("is case-insensitive when comparing paths", func() {
+ repo.data = []string{
+ "abc/tEsT1.Mp3",
+ }
+ m3u := strings.Join([]string{
+ "/music/ABC/TeSt1.mP3",
+ }, "\n")
+ f := strings.NewReader(m3u)
+ pls, err := ps.ImportM3U(ctx, f)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
+ })
+
+ It("parses #EXTALBUMARTURL with HTTP URL via ImportM3U", func() {
+ conf.Server.EnableM3UExternalAlbumArt = true
+
+ repo.data = []string{"tests/test.mp3"}
+ m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\n/music/tests/test.mp3\n"
+ pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg"))
+ })
+
+ It("ignores relative #EXTALBUMARTURL when imported via API (no folder context)", func() {
+ repo.data = []string{"tests/test.mp3"}
+ m3u := "#EXTALBUMARTURL:cover.jpg\n/music/tests/test.mp3\n"
+ pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.ExternalImageURL).To(BeEmpty())
+ })
+
+ // Fullwidth characters (e.g., ABCD) are not handled by SQLite's NOCASE collation,
+ // so we need exact matching for non-ASCII characters.
+ It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() {
+ // Fullwidth uppercase ACROSS (U+FF21, U+FF23, U+FF32, U+FF2F, U+FF33, U+FF33)
+ repo.data = []string{
+ "plex/02 - ACROSS.flac",
+ }
+ m3u := "/music/plex/02 - ACROSS.flac\n"
+ f := strings.NewReader(m3u)
+ pls, err := ps.ImportM3U(ctx, f)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal("plex/02 - ACROSS.flac"))
+ })
+
+ // Unicode normalization tests: NFC (composed) vs NFD (decomposed) forms
+ // macOS stores paths in NFD, Linux/Windows use NFC. Playlists may use either form.
+ DescribeTable("matches paths across Unicode NFC/NFD normalization",
+ func(description, pathNFC string, dbForm, playlistForm norm.Form) {
+ pathNFD := norm.NFD.String(pathNFC)
+ Expect(pathNFD).ToNot(Equal(pathNFC), "test path should have decomposable characters")
+
+ // Set up DB with specified normalization form
+ var dbPath string
+ if dbForm == norm.NFC {
+ dbPath = pathNFC
+ } else {
+ dbPath = pathNFD
+ }
+ repo.data = []string{dbPath}
+
+ // Set up playlist with specified normalization form
+ var playlistPath string
+ if playlistForm == norm.NFC {
+ playlistPath = pathNFC
+ } else {
+ playlistPath = pathNFD
+ }
+ m3u := "/music/" + playlistPath + "\n"
+ f := strings.NewReader(m3u)
+
+ pls, err := ps.ImportM3U(ctx, f)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal(dbPath))
+ },
+ // French: è (U+00E8) decomposes to e + combining grave (U+0065 + U+0300)
+ Entry("French diacritics - DB:NFD, playlist:NFC",
+ "macOS DB with Apple Music playlist",
+ "artist/Michèle/song.mp3", norm.NFD, norm.NFC),
+
+ // Japanese Katakana: ド (U+30C9) decomposes to ト (U+30C8) + combining dakuten (U+3099)
+ Entry("Japanese Katakana with dakuten - DB:NFC, playlist:NFC (#4884)",
+ "Linux/Windows DB with NFC playlist",
+ "artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFC, norm.NFC),
+ Entry("Japanese Katakana with dakuten - DB:NFD, playlist:NFC (#4884)",
+ "macOS DB with NFC playlist",
+ "artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFD, norm.NFC),
+
+ // Cyrillic: й (U+0439) decomposes to и (U+0438) + combining breve (U+0306)
+ Entry("Cyrillic characters - DB:NFD, playlist:NFC (#4791)",
+ "macOS DB with NFC playlist",
+ "Жуки/Батарейка/01 - Разлюбила.mp3", norm.NFD, norm.NFC),
+
+ // Polish: ó (U+00F3) decomposes to o + combining acute (U+0301)
+ Entry("Polish diacritics - DB:NFD, playlist:NFC (#4663)",
+ "macOS DB with NFC playlist",
+ "Zespół/Człowiek/Piosenka o miłości.mp3", norm.NFD, norm.NFC),
+ Entry("Polish diacritics - DB:NFC, playlist:NFD",
+ "Linux/Windows DB with macOS-exported playlist",
+ "Zespół/Człowiek/Piosenka o miłości.mp3", norm.NFC, norm.NFD),
+ )
+
+ })
+
+ Describe("InPath", func() {
+ var folder model.Folder
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ folder = model.Folder{
+ LibraryPath: "/music",
+ Path: "playlists/abc",
+ Name: "folder1",
+ }
+ })
+
+ It("returns true if PlaylistsPath is empty", func() {
+ conf.Server.PlaylistsPath = ""
+ Expect(playlists.InPath(folder)).To(BeTrue())
+ })
+
+ It("returns true if PlaylistsPath is any (**/**)", func() {
+ conf.Server.PlaylistsPath = "**/**"
+ Expect(playlists.InPath(folder)).To(BeTrue())
+ })
+
+ It("returns true if folder is in PlaylistsPath", func() {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
+ conf.Server.PlaylistsPath = "other/**:playlists/**"
+ Expect(playlists.InPath(folder)).To(BeTrue())
+ })
+
+ It("returns false if folder is not in PlaylistsPath", func() {
+ conf.Server.PlaylistsPath = "other"
+ Expect(playlists.InPath(folder)).To(BeFalse())
+ })
+
+ It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
+ conf.Server.PlaylistsPath = "."
+ Expect(playlists.InPath(folder)).To(BeFalse())
+
+ folder2 := model.Folder{
+ LibraryPath: "/music",
+ Path: "",
+ Name: ".",
+ }
+
+ Expect(playlists.InPath(folder2)).To(BeTrue())
+ })
+ })
+})
+
+// mockedMediaFileRepo's FindByPaths method returns MediaFiles for the given paths.
+// If data map is provided, looks up files by key; otherwise creates them from paths.
+type mockedMediaFileRepo struct {
+ model.MediaFileRepository
+ data map[string]model.MediaFile
+}
+
+func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
+ var mfs model.MediaFiles
+
+ // If data map provided, look up files
+ if r.data != nil {
+ for _, path := range paths {
+ if mf, ok := r.data[path]; ok {
+ mfs = append(mfs, mf)
+ }
+ }
+ return mfs, nil
+ }
+
+ // Otherwise, create MediaFiles from paths
+ for idx, path := range paths {
+ // Strip library qualifier if present (format: "libraryID:path")
+ actualPath := path
+ libraryID := 1
+ if parts := strings.SplitN(path, ":", 2); len(parts) == 2 {
+ if id, err := strconv.Atoi(parts[0]); err == nil {
+ libraryID = id
+ actualPath = parts[1]
+ }
+ }
+
+ mfs = append(mfs, model.MediaFile{
+ ID: strconv.Itoa(idx),
+ Path: actualPath,
+ LibraryID: libraryID,
+ })
+ }
+ return mfs, nil
+}
+
+// mockedMediaFileFromListRepo's FindByPaths method returns a list of MediaFiles based on the data field
+type mockedMediaFileFromListRepo struct {
+ model.MediaFileRepository
+ data []string
+}
+
+func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
+ var mfs model.MediaFiles
+
+ for idx, dataPath := range r.data {
+ for _, requestPath := range paths {
+ // Strip library qualifier if present (format: "libraryID:path")
+ actualPath := requestPath
+ libraryID := 1
+ if parts := strings.SplitN(requestPath, ":", 2); len(parts) == 2 {
+ if id, err := strconv.Atoi(parts[0]); err == nil {
+ libraryID = id
+ actualPath = parts[1]
+ }
+ }
+
+ // Case-insensitive comparison (like SQL's "collate nocase"), but with no
+ // implicit Unicode normalization (SQLite does not normalize NFC/NFD).
+ if strings.EqualFold(actualPath, dataPath) {
+ mfs = append(mfs, model.MediaFile{
+ ID: strconv.Itoa(idx),
+ Path: dataPath, // Return original path from DB
+ LibraryID: libraryID,
+ })
+ break
+ }
+ }
+ }
+ return mfs, nil
+}
+
+type mockFolderRepoForImport struct {
+ model.FolderRepository
+ folder *model.Folder
+}
+
+func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.Folder, error) {
+ if m.folder != nil {
+ return m.folder, nil
+ }
+ return nil, model.ErrNotFound
+}
diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go
new file mode 100644
index 000000000..a64c337c9
--- /dev/null
+++ b/core/playlists/parse_m3u.go
@@ -0,0 +1,333 @@
+package playlists
+
+import (
+ "cmp"
+ "context"
+ "fmt"
+ "io"
+ "net/url"
+ "path/filepath"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils/slice"
+ "golang.org/x/text/unicode/norm"
+)
+
+func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
+ mediaFileRepository := s.ds.MediaFile(ctx)
+ resolver, err := newPathResolver(ctx, s.ds)
+ if err != nil {
+ return err
+ }
+ var mfs model.MediaFiles
+ // Chunk size of 100 lines, as each line can generate up to 4 lookup candidates
+ // (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000.
+ for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) {
+ filteredLines := make([]string, 0, len(lines))
+ for _, line := range lines {
+ line := strings.TrimSpace(line)
+ if after, ok := strings.CutPrefix(line, "#PLAYLIST:"); ok {
+ pls.Name = after
+ continue
+ }
+ if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
+ pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher)
+ continue
+ }
+ // Skip empty lines and extended info
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ if after, ok := strings.CutPrefix(line, "file://"); ok {
+ line = after
+ line, _ = url.PathUnescape(line)
+ }
+ if !model.IsAudioFile(line) {
+ continue
+ }
+ filteredLines = append(filteredLines, line)
+ }
+ resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines)
+ if err != nil {
+ log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
+ continue
+ }
+
+ // SQLite comparisons do not perform Unicode normalization, and filesystem normalization
+ // differs across platforms (macOS often yields NFD, while Linux/Windows typically use NFC).
+ // Generate lookup candidates for both forms so playlist entries match DB paths regardless
+ // of the original normalization. See https://github.com/navidrome/navidrome/issues/4884
+ //
+ // We also include the original (non-lowercased) paths because SQLite's COLLATE NOCASE
+ // only handles ASCII case-insensitivity. Non-ASCII characters like fullwidth letters
+ // (e.g., ABCD vs abcd) are not matched case-insensitively by NOCASE.
+ lookupCandidates := make([]string, 0, len(resolvedPaths)*4)
+ seen := make(map[string]struct{}, len(resolvedPaths)*4)
+ for _, path := range resolvedPaths {
+ // Add original paths first (for exact matching of non-ASCII characters)
+ nfcRaw := norm.NFC.String(path)
+ if _, ok := seen[nfcRaw]; !ok {
+ seen[nfcRaw] = struct{}{}
+ lookupCandidates = append(lookupCandidates, nfcRaw)
+ }
+ nfdRaw := norm.NFD.String(path)
+ if _, ok := seen[nfdRaw]; !ok {
+ seen[nfdRaw] = struct{}{}
+ lookupCandidates = append(lookupCandidates, nfdRaw)
+ }
+
+ // Add lowercased paths (for ASCII case-insensitive matching via NOCASE)
+ nfc := strings.ToLower(nfcRaw)
+ if _, ok := seen[nfc]; !ok {
+ seen[nfc] = struct{}{}
+ lookupCandidates = append(lookupCandidates, nfc)
+ }
+ nfd := strings.ToLower(nfdRaw)
+ if _, ok := seen[nfd]; !ok {
+ seen[nfd] = struct{}{}
+ lookupCandidates = append(lookupCandidates, nfd)
+ }
+ }
+
+ found, err := mediaFileRepository.FindByPaths(lookupCandidates)
+ if err != nil {
+ log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
+ continue
+ }
+
+ // Build lookup map with library-qualified keys, normalized for comparison.
+ // Canonicalize to NFC so NFD/NFC become comparable.
+ existing := make(map[string]int, len(found))
+ for idx := range found {
+ key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(norm.NFC.String(found[idx].Path)))
+ existing[key] = idx
+ }
+
+ // Find media files in the order of the resolved paths, to keep playlist order.
+ // Both `existing` keys and `resolvedPaths` use the library-qualified format "libraryID:relativePath",
+ // so normalizing the full string produces matching keys (digits and ':' are ASCII-invariant).
+ for _, path := range resolvedPaths {
+ key := strings.ToLower(norm.NFC.String(path))
+ idx, ok := existing[key]
+ if ok {
+ mfs = append(mfs, found[idx])
+ } else {
+ // Prefer logging a composed representation when possible to avoid confusing output
+ // with decomposed combining marks.
+ log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", norm.NFC.String(path))
+ }
+ }
+ }
+ if pls.Name == "" {
+ pls.Name = time.Now().Format(time.RFC3339)
+ }
+ pls.Tracks = nil
+ pls.AddMediaFiles(mfs)
+
+ return nil
+}
+
+// pathResolution holds the result of resolving a playlist path to a library-relative path.
+type pathResolution struct {
+ absolutePath string
+ libraryPath string
+ libraryID int
+ valid bool
+}
+
+// ToQualifiedString converts the path resolution to a library-qualified string with forward slashes.
+// Format: "libraryID:relativePath" with forward slashes for path separators.
+func (r pathResolution) ToQualifiedString() (string, error) {
+ if !r.valid {
+ return "", fmt.Errorf("invalid path resolution")
+ }
+ relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath)
+ if err != nil {
+ return "", err
+ }
+ // Convert path separators to forward slashes
+ return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
+}
+
+// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching.
+type libraryMatcher struct {
+ libraries model.Libraries
+ cleanedPaths []string
+}
+
+// findLibraryForPath finds which library contains the given absolute path.
+// Returns library ID and path, or 0 and empty string if not found.
+func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) {
+ lib, ok := lm.findLibrary(absolutePath)
+ if !ok {
+ return 0, ""
+ }
+ return lib.ID, filepath.Clean(lib.Path)
+}
+
+// findLibrary checks if the absolute path is under any of the library paths.
+func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) {
+ // Check sorted libraries (longest path first) to find the best match
+ for i, cleanLibPath := range lm.cleanedPaths {
+ // Check if absolutePath is under this library path
+ if strings.HasPrefix(absolutePath, cleanLibPath) {
+ // Ensure it's a proper path boundary (not just a prefix)
+ if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator {
+ return lm.libraries[i], true
+ }
+ }
+ }
+ return model.Library{}, false
+}
+
+// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first).
+// This ensures correct matching when library paths are prefixes of each other.
+// Example: /music-classical must be checked before /music
+// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical
+func newLibraryMatcher(libs model.Libraries) *libraryMatcher {
+ // Sort libraries by path length (descending) to ensure longest paths match first.
+ slices.SortFunc(libs, func(i, j model.Library) int {
+ return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending
+ })
+
+ // Pre-clean all library paths once for efficient matching
+ cleanedPaths := make([]string, len(libs))
+ for i, lib := range libs {
+ cleanedPaths[i] = filepath.Clean(lib.Path)
+ }
+ return &libraryMatcher{
+ libraries: libs,
+ cleanedPaths: cleanedPaths,
+ }
+}
+
+// pathResolver handles path resolution logic for playlist imports.
+type pathResolver struct {
+ matcher *libraryMatcher
+}
+
+// newPathResolver creates a pathResolver with libraries loaded from the datastore.
+func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) {
+ libs, err := ds.Library(ctx).GetAll()
+ if err != nil {
+ return nil, err
+ }
+ matcher := newLibraryMatcher(libs)
+ return &pathResolver{matcher: matcher}, nil
+}
+
+// resolvePath determines the absolute path and library path for a playlist entry.
+// For absolute paths, it uses them directly.
+// For relative paths, it resolves them relative to the playlist's folder location.
+// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3"
+//
+// resolves to /music/songs/abc.mp3
+func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolution {
+ var absolutePath string
+ if folder != nil && !filepath.IsAbs(line) {
+ // Resolve relative path to absolute path based on playlist location
+ absolutePath = filepath.Clean(filepath.Join(folder.AbsolutePath(), line))
+ } else {
+ // Use absolute path directly after cleaning
+ absolutePath = filepath.Clean(line)
+ }
+
+ return r.findInLibraries(absolutePath)
+}
+
+// findInLibraries matches an absolute path against all known libraries and returns
+// a pathResolution with the library information. Returns an invalid resolution if
+// the path is not found in any library.
+func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
+ libID, libPath := r.matcher.findLibraryForPath(absolutePath)
+ if libID == 0 {
+ return pathResolution{valid: false}
+ }
+ return pathResolution{
+ absolutePath: absolutePath,
+ libraryPath: libPath,
+ libraryID: libID,
+ valid: true,
+ }
+}
+
+// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
+// For relative paths, it resolves them to absolute paths first, then determines which
+// library they belong to. This allows playlists to reference files across library boundaries.
+func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
+ results := make([]string, 0, len(lines))
+ for idx, line := range lines {
+ resolution := r.resolvePath(line, folder)
+
+ if !resolution.valid {
+ log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
+ continue
+ }
+
+ qualifiedPath, err := resolution.ToQualifiedString()
+ if err != nil {
+ log.Debug(ctx, "Error getting library-qualified path", "path", line,
+ "libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err)
+ continue
+ }
+
+ results = append(results, qualifiedPath)
+ }
+
+ return results, nil
+}
+
+// resolveImageURL resolves an #EXTALBUMARTURL value to a storable string.
+// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt).
+// Local paths (file://, absolute, or relative) are resolved to an absolute path
+// and validated against known library boundaries via matcher.
+func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+
+ // HTTP(S) URLs — store as-is, but only if external album art is enabled
+ if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
+ if !conf.Server.EnableM3UExternalAlbumArt {
+ return ""
+ }
+ return value
+ }
+
+ // Resolve to local absolute path
+ localPath, ok := resolveLocalPath(value, folder)
+ if !ok {
+ return ""
+ }
+
+ // Validate path is within a known library
+ if libID, _ := matcher.findLibraryForPath(localPath); libID == 0 {
+ return ""
+ }
+ return localPath
+}
+
+// resolveLocalPath converts a file://, absolute, or relative path to a clean absolute path.
+// Returns ("", false) if the path cannot be resolved.
+func resolveLocalPath(value string, folder *model.Folder) (string, bool) {
+ if after, ok := strings.CutPrefix(value, "file://"); ok {
+ decoded, err := url.PathUnescape(after)
+ if err != nil {
+ return "", false
+ }
+ return filepath.Clean(decoded), true
+ }
+ if filepath.IsAbs(value) {
+ return filepath.Clean(value), true
+ }
+ if folder == nil {
+ return "", false
+ }
+ return filepath.Clean(filepath.Join(folder.AbsolutePath(), value)), true
+}
diff --git a/core/playlists/parse_m3u_test.go b/core/playlists/parse_m3u_test.go
new file mode 100644
index 000000000..d7fd5e001
--- /dev/null
+++ b/core/playlists/parse_m3u_test.go
@@ -0,0 +1,408 @@
+package playlists
+
+import (
+ "context"
+
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("libraryMatcher", func() {
+ var ds *tests.MockDataStore
+ var mockLibRepo *tests.MockLibraryRepo
+ ctx := context.Background()
+
+ BeforeEach(func() {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
+ mockLibRepo = &tests.MockLibraryRepo{}
+ ds = &tests.MockDataStore{
+ MockedLibrary: mockLibRepo,
+ }
+ })
+
+ // Helper function to create a libraryMatcher from the mock datastore
+ createMatcher := func(ds model.DataStore) *libraryMatcher {
+ libs, err := ds.Library(ctx).GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ return newLibraryMatcher(libs)
+ }
+
+ Describe("Longest library path matching", func() {
+ It("matches the longest library path when multiple libraries share a prefix", func() {
+ // Setup libraries with prefix conflicts
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music"},
+ {ID: 2, Path: "/music-classical"},
+ {ID: 3, Path: "/music-classical/opera"},
+ })
+
+ matcher := createMatcher(ds)
+
+ // Test that longest path matches first and returns correct library ID
+ testCases := []struct {
+ path string
+ expectedLibID int
+ expectedLibPath string
+ }{
+ {"/music-classical/opera/track.mp3", 3, "/music-classical/opera"},
+ {"/music-classical/track.mp3", 2, "/music-classical"},
+ {"/music/track.mp3", 1, "/music"},
+ {"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"},
+ }
+
+ for _, tc := range testCases {
+ libID, libPath := matcher.findLibraryForPath(tc.path)
+ Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID)
+ Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath)
+ }
+ })
+
+ It("handles libraries with similar prefixes but different structures", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/home/user/music"},
+ {ID: 2, Path: "/home/user/music-backup"},
+ })
+
+ matcher := createMatcher(ds)
+
+ // Test that music-backup library is matched correctly
+ libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3")
+ Expect(libID).To(Equal(2))
+ Expect(libPath).To(Equal("/home/user/music-backup"))
+
+ // Test that music library is still matched correctly
+ libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3")
+ Expect(libID).To(Equal(1))
+ Expect(libPath).To(Equal("/home/user/music"))
+ })
+
+ It("matches path that is exactly the library root", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music"},
+ {ID: 2, Path: "/music-classical"},
+ })
+
+ matcher := createMatcher(ds)
+
+ // Exact library path should match
+ libID, libPath := matcher.findLibraryForPath("/music-classical")
+ Expect(libID).To(Equal(2))
+ Expect(libPath).To(Equal("/music-classical"))
+ })
+
+ It("handles complex nested library structures", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/media"},
+ {ID: 2, Path: "/media/audio"},
+ {ID: 3, Path: "/media/audio/classical"},
+ {ID: 4, Path: "/media/audio/classical/baroque"},
+ })
+
+ matcher := createMatcher(ds)
+
+ testCases := []struct {
+ path string
+ expectedLibID int
+ expectedLibPath string
+ }{
+ {"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"},
+ {"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"},
+ {"/media/audio/rock/track.mp3", 2, "/media/audio"},
+ {"/media/video/movie.mp4", 1, "/media"},
+ }
+
+ for _, tc := range testCases {
+ libID, libPath := matcher.findLibraryForPath(tc.path)
+ Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
+ Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath)
+ }
+ })
+ })
+
+ Describe("Edge cases", func() {
+ It("handles empty library list", func() {
+ mockLibRepo.SetData([]model.Library{})
+
+ matcher := createMatcher(ds)
+ Expect(matcher).ToNot(BeNil())
+
+ // Should not match anything
+ libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
+ Expect(libID).To(Equal(0))
+ Expect(libPath).To(BeEmpty())
+ })
+
+ It("handles single library", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music"},
+ })
+
+ matcher := createMatcher(ds)
+
+ libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
+ Expect(libID).To(Equal(1))
+ Expect(libPath).To(Equal("/music"))
+ })
+
+ It("handles libraries with special characters in paths", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music[test]"},
+ {ID: 2, Path: "/music(backup)"},
+ })
+
+ matcher := createMatcher(ds)
+ Expect(matcher).ToNot(BeNil())
+
+ // Special characters should match literally
+ libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3")
+ Expect(libID).To(Equal(1))
+ Expect(libPath).To(Equal("/music[test]"))
+ })
+ })
+
+ Describe("Path matching order", func() {
+ It("ensures longest paths match first", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/a"},
+ {ID: 2, Path: "/ab"},
+ {ID: 3, Path: "/abc"},
+ })
+
+ matcher := createMatcher(ds)
+
+ // Verify that longer paths match correctly (not cut off by shorter prefix)
+ testCases := []struct {
+ path string
+ expectedLibID int
+ }{
+ {"/abc/file.mp3", 3},
+ {"/ab/file.mp3", 2},
+ {"/a/file.mp3", 1},
+ }
+
+ for _, tc := range testCases {
+ libID, _ := matcher.findLibraryForPath(tc.path)
+ Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
+ }
+ })
+ })
+})
+
+var _ = Describe("pathResolver", func() {
+ var ds *tests.MockDataStore
+ var mockLibRepo *tests.MockLibraryRepo
+ var resolver *pathResolver
+ ctx := context.Background()
+
+ BeforeEach(func() {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
+ mockLibRepo = &tests.MockLibraryRepo{}
+ ds = &tests.MockDataStore{
+ MockedLibrary: mockLibRepo,
+ }
+
+ // Setup test libraries
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music"},
+ {ID: 2, Path: "/music-classical"},
+ {ID: 3, Path: "/podcasts"},
+ })
+
+ var err error
+ resolver, err = newPathResolver(ctx, ds)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ Describe("resolvePath", func() {
+ Context("basic", func() {
+ It("resolves absolute paths", func() {
+ resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(1))
+ Expect(resolution.libraryPath).To(Equal("/music"))
+ Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
+ })
+
+ It("resolves relative paths when folder is provided", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(1))
+ Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
+ })
+
+ It("returns invalid resolution for paths outside any library", func() {
+ resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeFalse())
+ })
+ })
+
+ Context("cross-library", func() {
+ It("resolves path within a library", func() {
+ resolution := resolver.resolvePath("/music/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(1))
+ Expect(resolution.libraryPath).To(Equal("/music"))
+ Expect(resolution.absolutePath).To(Equal("/music/track.mp3"))
+ })
+
+ It("resolves path to the longest matching library", func() {
+ resolution := resolver.resolvePath("/music-classical/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(2))
+ Expect(resolution.libraryPath).To(Equal("/music-classical"))
+ })
+
+ It("returns invalid resolution for path outside libraries", func() {
+ resolution := resolver.resolvePath("/videos/movie.mp4", nil)
+
+ Expect(resolution.valid).To(BeFalse())
+ })
+
+ It("cleans the path before matching", func() {
+ resolution := resolver.resolvePath("/music//artist/../artist/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.absolutePath).To(Equal("/music/artist/track.mp3"))
+ })
+ })
+
+ Context("With relative paths", func() {
+ It("resolves relative path within same library", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ resolution := resolver.resolvePath("../songs/track.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(1))
+ Expect(resolution.absolutePath).To(Equal("/music/songs/track.mp3"))
+ })
+
+ It("resolves relative path to different library", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ // Path goes up and into a different library
+ resolution := resolver.resolvePath("../../podcasts/episode.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(3))
+ Expect(resolution.libraryPath).To(Equal("/podcasts"))
+ })
+
+ It("uses matcher to find correct library for resolved path", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ // This relative path resolves to music-classical library
+ resolution := resolver.resolvePath("../../music-classical/track.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(2))
+ Expect(resolution.libraryPath).To(Equal("/music-classical"))
+ })
+
+ It("returns invalid for relative paths escaping all libraries", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ resolution := resolver.resolvePath("../../../../etc/passwd", folder)
+
+ Expect(resolution.valid).To(BeFalse())
+ })
+ })
+ })
+
+ Describe("Cross-library resolution scenarios", func() {
+ It("handles playlist in library A referencing file in library B", func() {
+ // Playlist is in /music/playlists
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ // Relative path that goes to /podcasts library
+ resolution := resolver.resolvePath("../../podcasts/show/episode.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(3), "Should resolve to podcasts library")
+ Expect(resolution.libraryPath).To(Equal("/podcasts"))
+ })
+
+ It("prefers longer library paths when resolving", func() {
+ // Ensure /music-classical is matched instead of /music
+ resolution := resolver.resolvePath("/music-classical/baroque/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(2), "Should match /music-classical, not /music")
+ })
+ })
+})
+
+var _ = Describe("pathResolution", func() {
+ Describe("ToQualifiedString", func() {
+ It("converts valid resolution to qualified string with forward slashes", func() {
+ resolution := pathResolution{
+ absolutePath: "/music/artist/album/track.mp3",
+ libraryPath: "/music",
+ libraryID: 1,
+ valid: true,
+ }
+
+ qualifiedStr, err := resolution.ToQualifiedString()
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(qualifiedStr).To(Equal("1:artist/album/track.mp3"))
+ })
+
+ It("handles Windows-style paths by converting to forward slashes", func() {
+ resolution := pathResolution{
+ absolutePath: "/music/artist/album/track.mp3",
+ libraryPath: "/music",
+ libraryID: 2,
+ valid: true,
+ }
+
+ qualifiedStr, err := resolution.ToQualifiedString()
+
+ Expect(err).ToNot(HaveOccurred())
+ // Should always use forward slashes regardless of OS
+ Expect(qualifiedStr).To(ContainSubstring("2:"))
+ Expect(qualifiedStr).ToNot(ContainSubstring("\\"))
+ })
+
+ It("returns error for invalid resolution", func() {
+ resolution := pathResolution{valid: false}
+
+ _, err := resolution.ToQualifiedString()
+
+ Expect(err).To(HaveOccurred())
+ })
+ })
+})
diff --git a/core/playlists/parse_nsp.go b/core/playlists/parse_nsp.go
new file mode 100644
index 000000000..a5b8b7c02
--- /dev/null
+++ b/core/playlists/parse_nsp.go
@@ -0,0 +1,102 @@
+package playlists
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/criteria"
+ "github.com/navidrome/navidrome/utils/jsoncommentstrip"
+)
+
+func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
+ playlistPath := filepath.Join(baseDir, playlistFile)
+ info, err := os.Stat(playlistPath)
+ if err != nil {
+ return nil, err
+ }
+
+ var extension = filepath.Ext(playlistFile)
+ var name = playlistFile[0 : len(playlistFile)-len(extension)]
+
+ pls := &model.Playlist{
+ Name: name,
+ Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
+ Public: false,
+ Path: playlistPath,
+ Sync: true,
+ UpdatedAt: info.ModTime(),
+ }
+ return pls, nil
+}
+
+func getPositionFromOffset(data []byte, offset int64) (line, column int) {
+ line = 1
+ for _, b := range data[:offset] {
+ if b == '\n' {
+ line++
+ column = 1
+ } else {
+ column++
+ }
+ }
+ return
+}
+
+func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
+ nsp := &nspFile{}
+ reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
+ reader = jsoncommentstrip.NewReader(reader)
+ input, err := io.ReadAll(reader)
+ if err != nil {
+ return fmt.Errorf("reading SmartPlaylist: %w", err)
+ }
+ err = json.Unmarshal(input, nsp)
+ if err != nil {
+ if syntaxErr, ok := errors.AsType[*json.SyntaxError](err); ok {
+ line, col := getPositionFromOffset(input, syntaxErr.Offset)
+ return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
+ }
+ return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
+ }
+ pls.Rules = &nsp.Criteria
+ if nsp.Name != "" {
+ pls.Name = nsp.Name
+ }
+ if nsp.Comment != "" {
+ pls.Comment = nsp.Comment
+ }
+ if nsp.Public != nil {
+ pls.Public = *nsp.Public
+ } else {
+ pls.Public = conf.Server.DefaultPlaylistPublicVisibility
+ }
+ return nil
+}
+
+type nspFile struct {
+ criteria.Criteria
+ Name string `json:"name"`
+ Comment string `json:"comment"`
+ Public *bool `json:"public"`
+}
+
+func (i *nspFile) UnmarshalJSON(data []byte) error {
+ m := map[string]any{}
+ err := json.Unmarshal(data, &m)
+ if err != nil {
+ return err
+ }
+ i.Name, _ = m["name"].(string)
+ i.Comment, _ = m["comment"].(string)
+ if public, ok := m["public"].(bool); ok {
+ i.Public = &public
+ }
+ return json.Unmarshal(data, &i.Criteria)
+}
diff --git a/core/playlists/parse_nsp_test.go b/core/playlists/parse_nsp_test.go
new file mode 100644
index 000000000..516a5355d
--- /dev/null
+++ b/core/playlists/parse_nsp_test.go
@@ -0,0 +1,228 @@
+package playlists
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/criteria"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("parseNSP", func() {
+ var s *playlists
+ ctx := context.Background()
+
+ BeforeEach(func() {
+ s = &playlists{}
+ })
+
+ It("parses a well-formed NSP with all fields", func() {
+ nsp := `{
+ "name": "My Smart Playlist",
+ "comment": "A test playlist",
+ "public": true,
+ "all": [{"is": {"loved": true}}],
+ "sort": "title",
+ "order": "asc",
+ "limit": 50
+ }`
+ pls := &model.Playlist{Name: "default-name"}
+ err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Name).To(Equal("My Smart Playlist"))
+ Expect(pls.Comment).To(Equal("A test playlist"))
+ Expect(pls.Public).To(BeTrue())
+ Expect(pls.Rules).ToNot(BeNil())
+ Expect(pls.Rules.Sort).To(Equal("title"))
+ Expect(pls.Rules.Order).To(Equal("asc"))
+ Expect(pls.Rules.Limit).To(Equal(50))
+ Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
+ })
+
+ It("keeps existing name when NSP has no name field", func() {
+ nsp := `{"all": [{"is": {"loved": true}}]}`
+ pls := &model.Playlist{Name: "Original Name"}
+ err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Name).To(Equal("Original Name"))
+ })
+
+ It("keeps existing comment when NSP has no comment field", func() {
+ nsp := `{"all": [{"is": {"loved": true}}]}`
+ pls := &model.Playlist{Comment: "Original Comment"}
+ err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Comment).To(Equal("Original Comment"))
+ })
+
+ It("strips JSON comments before parsing", func() {
+ nsp := `{
+ // Line comment
+ "name": "Commented Playlist",
+ /* Block comment */
+ "all": [{"is": {"loved": true}}]
+ }`
+ pls := &model.Playlist{}
+ err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Name).To(Equal("Commented Playlist"))
+ })
+
+ It("uses server default when public field is absent", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.DefaultPlaylistPublicVisibility = true
+
+ nsp := `{"all": [{"is": {"loved": true}}]}`
+ pls := &model.Playlist{}
+ err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Public).To(BeTrue())
+ })
+
+ It("honors explicit public: false over server default", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.DefaultPlaylistPublicVisibility = true
+
+ nsp := `{"public": false, "all": [{"is": {"loved": true}}]}`
+ pls := &model.Playlist{}
+ err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Public).To(BeFalse())
+ })
+
+ It("returns a syntax error with line and column info", func() {
+ nsp := "{\n \"name\": \"Bad\",\n \"all\": [INVALID]\n}"
+ pls := &model.Playlist{}
+ err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("JSON syntax error in SmartPlaylist"))
+ Expect(err.Error()).To(MatchRegexp(`line \d+, column \d+`))
+ })
+
+ It("returns a parsing error for completely invalid JSON", func() {
+ nsp := `not json at all`
+ pls := &model.Playlist{}
+ err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("SmartPlaylist"))
+ })
+
+ It("gracefully handles non-string name field", func() {
+ nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}`
+ pls := &model.Playlist{Name: "Original"}
+ err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
+ Expect(err).ToNot(HaveOccurred())
+ // Type assertion in UnmarshalJSON fails silently; name stays as original
+ Expect(pls.Name).To(Equal("Original"))
+ })
+
+ It("parses limitPercent from NSP", func() {
+ nsp := `{
+ "all": [{"is": {"loved": true}}],
+ "sort": "playCount",
+ "order": "desc",
+ "limitPercent": 25
+ }`
+ pls := &model.Playlist{}
+ err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Rules).ToNot(BeNil())
+ Expect(pls.Rules.LimitPercent).To(Equal(25))
+ Expect(pls.Rules.Limit).To(Equal(0))
+ })
+
+ It("parses criteria with multiple rules", func() {
+ nsp := `{
+ "all": [
+ {"is": {"loved": true}},
+ {"contains": {"title": "rock"}}
+ ],
+ "sort": "lastPlayed",
+ "order": "desc",
+ "limit": 100
+ }`
+ pls := &model.Playlist{}
+ err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Rules).ToNot(BeNil())
+ Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
+ Expect(pls.Rules.Order).To(Equal("desc"))
+ Expect(pls.Rules.Limit).To(Equal(100))
+ })
+})
+
+var _ = Describe("getPositionFromOffset", func() {
+ It("returns correct position on first line", func() {
+ data := []byte("hello world")
+ line, col := getPositionFromOffset(data, 5)
+ Expect(line).To(Equal(1))
+ Expect(col).To(Equal(5))
+ })
+
+ It("returns correct position after newlines", func() {
+ data := []byte("line1\nline2\nline3")
+ // Offsets: l(0) i(1) n(2) e(3) 1(4) \n(5) l(6) i(7) n(8)
+ line, col := getPositionFromOffset(data, 8)
+ Expect(line).To(Equal(2))
+ Expect(col).To(Equal(3))
+ })
+
+ It("returns correct position at start of new line", func() {
+ data := []byte("line1\nline2")
+ // After \n at offset 5, col resets to 1; offset 6 is 'l' -> col=1
+ line, col := getPositionFromOffset(data, 6)
+ Expect(line).To(Equal(2))
+ Expect(col).To(Equal(1))
+ })
+
+ It("handles multiple newlines", func() {
+ data := []byte("a\nb\nc\nd")
+ // a(0) \n(1) b(2) \n(3) c(4) \n(5) d(6)
+ line, col := getPositionFromOffset(data, 6)
+ Expect(line).To(Equal(4))
+ Expect(col).To(Equal(1))
+ })
+})
+
+var _ = Describe("newSyncedPlaylist", func() {
+ var s *playlists
+
+ BeforeEach(func() {
+ s = &playlists{}
+ })
+
+ It("creates a synced playlist with correct attributes", func() {
+ tmpDir := GinkgoT().TempDir()
+ Expect(os.WriteFile(filepath.Join(tmpDir, "test.m3u"), []byte("content"), 0600)).To(Succeed())
+
+ pls, err := s.newSyncedPlaylist(tmpDir, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Name).To(Equal("test"))
+ Expect(pls.Comment).To(Equal("Auto-imported from 'test.m3u'"))
+ Expect(pls.Public).To(BeFalse())
+ Expect(pls.Path).To(Equal(filepath.Join(tmpDir, "test.m3u")))
+ Expect(pls.Sync).To(BeTrue())
+ Expect(pls.UpdatedAt).ToNot(BeZero())
+ })
+
+ It("strips extension from filename to derive name", func() {
+ tmpDir := GinkgoT().TempDir()
+ Expect(os.WriteFile(filepath.Join(tmpDir, "My Favorites.nsp"), []byte("{}"), 0600)).To(Succeed())
+
+ pls, err := s.newSyncedPlaylist(tmpDir, "My Favorites.nsp")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Name).To(Equal("My Favorites"))
+ })
+
+ It("returns error for non-existent file", func() {
+ tmpDir := GinkgoT().TempDir()
+ _, err := s.newSyncedPlaylist(tmpDir, "nonexistent.m3u")
+ Expect(err).To(HaveOccurred())
+ })
+})
diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go
new file mode 100644
index 000000000..3da24706c
--- /dev/null
+++ b/core/playlists/playlists.go
@@ -0,0 +1,322 @@
+package playlists
+
+import (
+ "context"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/bmatcuk/doublestar/v4"
+ "github.com/deluan/rest"
+ "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"
+)
+
+type Playlists interface {
+ // Reads
+ GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error)
+ Get(ctx context.Context, id string) (*model.Playlist, error)
+ GetWithTracks(ctx context.Context, id string) (*model.Playlist, error)
+ GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error)
+
+ // Mutations
+ Create(ctx context.Context, playlistId string, name string, ids []string) (string, error)
+ Delete(ctx context.Context, id string) error
+ Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
+
+ // Track management
+ AddTracks(ctx context.Context, playlistID string, ids []string) (int, error)
+ AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error)
+ AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error)
+ AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error)
+ RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error
+ ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error
+
+ // Cover art
+ SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error
+ RemoveImage(ctx context.Context, playlistID string) error
+
+ // Import
+ ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error)
+ ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
+ ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
+
+ // REST adapters
+ NewRepository(ctx context.Context) rest.Repository
+ TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
+}
+
+// ImageUploadService is a local interface satisfied by core.ImageUploadService.
+// Defined here to avoid an import cycle between core and core/playlists.
+type ImageUploadService interface {
+ SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
+ RemoveImage(ctx context.Context, path string) error
+}
+
+type playlists struct {
+ ds model.DataStore
+ imgUpload ImageUploadService
+}
+
+func NewPlaylists(ds model.DataStore, imgUpload ImageUploadService) Playlists {
+ return &playlists{ds: ds, imgUpload: imgUpload}
+}
+
+func InPath(folder model.Folder) bool {
+ if conf.Server.PlaylistsPath == "" {
+ return true
+ }
+ rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
+ for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
+ if match, _ := doublestar.Match(path, rel); match {
+ return true
+ }
+ }
+ return false
+}
+
+// --- Read operations ---
+
+func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) {
+ return s.ds.Playlist(ctx).GetAll(options...)
+}
+
+func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) {
+ return s.ds.Playlist(ctx).Get(id)
+}
+
+func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) {
+ return s.ds.Playlist(ctx).GetWithTracks(id, true, false)
+}
+
+func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) {
+ return s.ds.Playlist(ctx).GetPlaylists(mediaFileId)
+}
+
+// --- Mutation operations ---
+
+// Create creates a new playlist (when name is provided) or replaces tracks on an existing
+// playlist (when playlistId is provided). This matches the Subsonic createPlaylist semantics.
+func (s *playlists) Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) {
+ usr, _ := request.UserFrom(ctx)
+ err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
+ var pls *model.Playlist
+ var err error
+
+ if playlistId != "" {
+ pls, err = tx.Playlist(ctx).Get(playlistId)
+ if err != nil {
+ return err
+ }
+ if pls.IsSmartPlaylist() {
+ return model.ErrNotAuthorized
+ }
+ if !usr.IsAdmin && pls.OwnerID != usr.ID {
+ return model.ErrNotAuthorized
+ }
+ } else {
+ pls = &model.Playlist{Name: name}
+ pls.OwnerID = usr.ID
+ }
+ pls.Tracks = nil
+ pls.AddMediaFilesByID(ids)
+
+ err = tx.Playlist(ctx).Put(pls)
+ playlistId = pls.ID
+ return err
+ })
+ return playlistId, err
+}
+
+func (s *playlists) Delete(ctx context.Context, id string) error {
+ pls, err := s.checkWritable(ctx, id)
+ if err != nil {
+ return err
+ }
+
+ // Clean up custom cover image file if one exists
+ if path := pls.UploadedImagePath(); path != "" {
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ log.Warn(ctx, "Failed to remove playlist image on delete", "path", path, err)
+ }
+ }
+
+ return s.ds.Playlist(ctx).Delete(id)
+}
+
+func (s *playlists) Update(ctx context.Context, playlistID string,
+ name *string, comment *string, public *bool,
+ idsToAdd []string, idxToRemove []int) error {
+ var pls *model.Playlist
+ var err error
+ hasTrackChanges := len(idsToAdd) > 0 || len(idxToRemove) > 0
+ if hasTrackChanges {
+ pls, err = s.checkTracksEditable(ctx, playlistID)
+ } else {
+ pls, err = s.checkWritable(ctx, playlistID)
+ }
+ if err != nil {
+ return err
+ }
+ return s.ds.WithTxImmediate(func(tx model.DataStore) error {
+ repo := tx.Playlist(ctx)
+
+ if len(idxToRemove) > 0 {
+ tracksRepo := repo.Tracks(playlistID, false)
+ // Convert 0-based indices to 1-based position IDs and delete them directly,
+ // avoiding the need to load all tracks into memory.
+ positions := make([]string, len(idxToRemove))
+ for i, idx := range idxToRemove {
+ positions[i] = strconv.Itoa(idx + 1)
+ }
+ if err := tracksRepo.Delete(positions...); err != nil {
+ return err
+ }
+ if len(idsToAdd) > 0 {
+ if _, err := tracksRepo.Add(idsToAdd); err != nil {
+ return err
+ }
+ }
+ return s.updateMetadata(ctx, tx, pls, name, comment, public)
+ }
+
+ if len(idsToAdd) > 0 {
+ if _, err := repo.Tracks(playlistID, false).Add(idsToAdd); err != nil {
+ return err
+ }
+ }
+ if name == nil && comment == nil && public == nil {
+ return nil
+ }
+ // Reuse the playlist from checkWritable (no tracks loaded, so Put only refreshes counters)
+ return s.updateMetadata(ctx, tx, pls, name, comment, public)
+ })
+}
+
+// --- Permission helpers ---
+
+// checkWritable fetches the playlist and verifies the current user can modify it.
+func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) {
+ pls, err := s.ds.Playlist(ctx).Get(id)
+ if err != nil {
+ return nil, err
+ }
+ usr, _ := request.UserFrom(ctx)
+ if !usr.IsAdmin && pls.OwnerID != usr.ID {
+ return nil, model.ErrNotAuthorized
+ }
+ return pls, nil
+}
+
+// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist).
+func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) {
+ pls, err := s.checkWritable(ctx, playlistID)
+ if err != nil {
+ return nil, err
+ }
+ if pls.IsSmartPlaylist() {
+ return nil, model.ErrNotAuthorized
+ }
+ return pls, nil
+}
+
+// updateMetadata applies optional metadata changes to a playlist and persists it.
+// Accepts a DataStore parameter so it can be used inside transactions.
+// The caller is responsible for permission checks.
+func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls *model.Playlist, name *string, comment *string, public *bool) error {
+ if name != nil {
+ pls.Name = *name
+ }
+ if comment != nil {
+ pls.Comment = *comment
+ }
+ if public != nil {
+ pls.Public = *public
+ }
+ return ds.Playlist(ctx).Put(pls)
+}
+
+// --- Track management operations ---
+
+func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) {
+ if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
+ return 0, err
+ }
+ return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids)
+}
+
+func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) {
+ if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
+ return 0, err
+ }
+ return s.ds.Playlist(ctx).Tracks(playlistID, false).AddAlbums(albumIds)
+}
+
+func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) {
+ if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
+ return 0, err
+ }
+ return s.ds.Playlist(ctx).Tracks(playlistID, false).AddArtists(artistIds)
+}
+
+func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) {
+ if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
+ return 0, err
+ }
+ return s.ds.Playlist(ctx).Tracks(playlistID, false).AddDiscs(discs)
+}
+
+func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error {
+ if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
+ return err
+ }
+ return s.ds.WithTx(func(tx model.DataStore) error {
+ return tx.Playlist(ctx).Tracks(playlistID, false).Delete(trackIds...)
+ })
+}
+
+func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error {
+ if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
+ return err
+ }
+ return s.ds.WithTx(func(tx model.DataStore) error {
+ return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
+ })
+}
+
+// --- Cover art operations ---
+
+func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error {
+ pls, err := s.checkWritable(ctx, playlistID)
+ if err != nil {
+ return err
+ }
+
+ oldPath := pls.UploadedImagePath()
+ filename, err := s.imgUpload.SetImage(ctx, consts.EntityPlaylist, pls.ID, pls.Name, oldPath, reader, ext)
+ if err != nil {
+ return err
+ }
+
+ pls.UploadedImage = filename
+ return s.ds.Playlist(ctx).Put(pls)
+}
+
+func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error {
+ pls, err := s.checkWritable(ctx, playlistID)
+ if err != nil {
+ return err
+ }
+
+ if err := s.imgUpload.RemoveImage(ctx, pls.UploadedImagePath()); err != nil {
+ return err
+ }
+
+ pls.UploadedImage = ""
+ return s.ds.Playlist(ctx).Put(pls)
+}
diff --git a/core/playlists/playlists_suite_test.go b/core/playlists/playlists_suite_test.go
new file mode 100644
index 000000000..b57248490
--- /dev/null
+++ b/core/playlists/playlists_suite_test.go
@@ -0,0 +1,17 @@
+package playlists_test
+
+import (
+ "testing"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestPlaylists(t *testing.T) {
+ tests.Init(t, false)
+ log.SetLevel(log.LevelFatal)
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Playlists Suite")
+}
diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go
new file mode 100644
index 000000000..f849a0a21
--- /dev/null
+++ b/core/playlists/playlists_test.go
@@ -0,0 +1,413 @@
+package playlists_test
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core"
+ "github.com/navidrome/navidrome/core/playlists"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/criteria"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Playlists", func() {
+ var ds *tests.MockDataStore
+ var ps playlists.Playlists
+ var mockPlsRepo *tests.MockPlaylistRepo
+ ctx := context.Background()
+
+ BeforeEach(func() {
+ mockPlsRepo = tests.CreateMockPlaylistRepo()
+ ds = &tests.MockDataStore{
+ MockedPlaylist: mockPlsRepo,
+ MockedLibrary: &tests.MockLibraryRepo{},
+ }
+ ctx = request.WithUser(ctx, model.User{ID: "123"})
+ })
+
+ Describe("Delete", func() {
+ var mockTracks *tests.MockPlaylistTrackRepo
+
+ BeforeEach(func() {
+ mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 3}
+ mockPlsRepo.Data = map[string]*model.Playlist{
+ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
+ }
+ mockPlsRepo.TracksRepo = mockTracks
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+ })
+
+ It("allows owner to delete their playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.Delete(ctx, "pls-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
+ })
+
+ It("allows admin to delete any playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
+ err := ps.Delete(ctx, "pls-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
+ })
+
+ It("denies non-owner, non-admin from deleting", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
+ err := ps.Delete(ctx, "pls-1")
+ Expect(err).To(MatchError(model.ErrNotAuthorized))
+ Expect(mockPlsRepo.Deleted).To(BeEmpty())
+ })
+
+ It("returns error when playlist not found", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.Delete(ctx, "nonexistent")
+ Expect(err).To(Equal(model.ErrNotFound))
+ })
+ })
+
+ Describe("Create", func() {
+ BeforeEach(func() {
+ mockPlsRepo.Data = map[string]*model.Playlist{
+ "pls-1": {ID: "pls-1", Name: "Existing", OwnerID: "user-1"},
+ "pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
+ "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
+ Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
+ }
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+ })
+
+ It("creates a new playlist with owner set from context", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ id, err := ps.Create(ctx, "", "New Playlist", []string{"song-1", "song-2"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(id).ToNot(BeEmpty())
+ Expect(mockPlsRepo.Last.Name).To(Equal("New Playlist"))
+ Expect(mockPlsRepo.Last.OwnerID).To(Equal("user-1"))
+ })
+
+ It("replaces tracks on existing playlist when owner matches", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ id, err := ps.Create(ctx, "pls-1", "", []string{"song-3"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(id).To(Equal("pls-1"))
+ Expect(mockPlsRepo.Last.Tracks).To(HaveLen(1))
+ })
+
+ It("allows admin to replace tracks on any playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
+ id, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(id).To(Equal("pls-2"))
+ })
+
+ It("denies non-owner, non-admin from replacing tracks on existing playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ _, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
+ Expect(err).To(MatchError(model.ErrNotAuthorized))
+ })
+
+ It("returns error when existing playlistId not found", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ _, err := ps.Create(ctx, "nonexistent", "", []string{"song-1"})
+ Expect(err).To(Equal(model.ErrNotFound))
+ })
+
+ It("denies replacing tracks on a smart playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ _, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
+ Expect(err).To(MatchError(model.ErrNotAuthorized))
+ })
+ })
+
+ Describe("Update", func() {
+ var mockTracks *tests.MockPlaylistTrackRepo
+
+ BeforeEach(func() {
+ mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
+ mockPlsRepo.Data = map[string]*model.Playlist{
+ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
+ "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
+ "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
+ Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
+ }
+ mockPlsRepo.TracksRepo = mockTracks
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+ })
+
+ It("allows owner to update their playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("allows admin to update any playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
+ err := ps.Update(ctx, "pls-other", new("Updated Name"), nil, nil, nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("denies non-owner, non-admin from updating", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
+ err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil)
+ Expect(err).To(MatchError(model.ErrNotAuthorized))
+ })
+
+ It("returns error when playlist not found", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.Update(ctx, "nonexistent", new("Updated Name"), nil, nil, nil, nil)
+ Expect(err).To(Equal(model.ErrNotFound))
+ })
+
+ It("denies adding tracks to a smart playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil)
+ Expect(err).To(MatchError(model.ErrNotAuthorized))
+ })
+
+ It("denies removing tracks from a smart playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0})
+ Expect(err).To(MatchError(model.ErrNotAuthorized))
+ })
+
+ It("allows metadata updates on a smart playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil)
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ Describe("AddTracks", func() {
+ var mockTracks *tests.MockPlaylistTrackRepo
+
+ BeforeEach(func() {
+ mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
+ mockPlsRepo.Data = map[string]*model.Playlist{
+ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
+ "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
+ Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
+ "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
+ }
+ mockPlsRepo.TracksRepo = mockTracks
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+ })
+
+ It("allows owner to add tracks", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ count, err := ps.AddTracks(ctx, "pls-1", []string{"song-1", "song-2"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(Equal(2))
+ Expect(mockTracks.AddedIds).To(ConsistOf("song-1", "song-2"))
+ })
+
+ It("allows admin to add tracks to any playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
+ count, err := ps.AddTracks(ctx, "pls-other", []string{"song-1"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(Equal(2))
+ })
+
+ It("denies non-owner, non-admin", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
+ _, err := ps.AddTracks(ctx, "pls-1", []string{"song-1"})
+ Expect(err).To(MatchError(model.ErrNotAuthorized))
+ })
+
+ It("denies editing smart playlists", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ _, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"})
+ Expect(err).To(MatchError(model.ErrNotAuthorized))
+ })
+
+ It("returns error when playlist not found", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ _, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"})
+ Expect(err).To(Equal(model.ErrNotFound))
+ })
+ })
+
+ Describe("RemoveTracks", func() {
+ var mockTracks *tests.MockPlaylistTrackRepo
+
+ BeforeEach(func() {
+ mockTracks = &tests.MockPlaylistTrackRepo{}
+ mockPlsRepo.Data = map[string]*model.Playlist{
+ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
+ "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
+ Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
+ }
+ mockPlsRepo.TracksRepo = mockTracks
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+ })
+
+ It("allows owner to remove tracks", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1", "track-2"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockTracks.DeletedIds).To(ConsistOf("track-1", "track-2"))
+ })
+
+ It("denies on smart playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"})
+ Expect(err).To(MatchError(model.ErrNotAuthorized))
+ })
+
+ It("denies non-owner", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
+ err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"})
+ Expect(err).To(MatchError(model.ErrNotAuthorized))
+ })
+ })
+
+ Describe("ReorderTrack", func() {
+ var mockTracks *tests.MockPlaylistTrackRepo
+
+ BeforeEach(func() {
+ mockTracks = &tests.MockPlaylistTrackRepo{}
+ mockPlsRepo.Data = map[string]*model.Playlist{
+ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
+ "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
+ Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
+ }
+ mockPlsRepo.TracksRepo = mockTracks
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+ })
+
+ It("allows owner to reorder", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.ReorderTrack(ctx, "pls-1", 1, 3)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockTracks.Reordered).To(BeTrue())
+ })
+
+ It("denies on smart playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
+ Expect(err).To(MatchError(model.ErrNotAuthorized))
+ })
+ })
+
+ Describe("SetImage", func() {
+ var tmpDir string
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ tmpDir = GinkgoT().TempDir()
+ conf.Server.DataFolder = conf.NewDir(tmpDir)
+
+ mockPlsRepo.Data = map[string]*model.Playlist{
+ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
+ "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
+ }
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+ })
+
+ It("saves image file and updates UploadedImage", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ reader := strings.NewReader("fake image data")
+ err := ps.SetImage(ctx, "pls-1", reader, ".jpg")
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(mockPlsRepo.Last.UploadedImage).To(Equal("pls-1_my_playlist.jpg"))
+ absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg")
+ data, err := os.ReadFile(absPath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("fake image data"))
+ })
+
+ It("removes old image when replacing", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+
+ // Upload first image
+ err := ps.SetImage(ctx, "pls-1", strings.NewReader("first"), ".png")
+ Expect(err).ToNot(HaveOccurred())
+ oldPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.png")
+ Expect(oldPath).To(BeAnExistingFile())
+
+ // Upload replacement image
+ err = ps.SetImage(ctx, "pls-1", strings.NewReader("second"), ".jpg")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(oldPath).ToNot(BeAnExistingFile())
+ newPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg")
+ Expect(newPath).To(BeAnExistingFile())
+ })
+
+ It("allows admin to set image on any playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
+ err := ps.SetImage(ctx, "pls-other", strings.NewReader("data"), ".jpg")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("denies non-owner", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
+ err := ps.SetImage(ctx, "pls-1", strings.NewReader("data"), ".jpg")
+ Expect(err).To(MatchError(model.ErrNotAuthorized))
+ })
+
+ It("returns error when playlist not found", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.SetImage(ctx, "nonexistent", strings.NewReader("data"), ".jpg")
+ Expect(err).To(Equal(model.ErrNotFound))
+ })
+ })
+
+ Describe("RemoveImage", func() {
+ var tmpDir string
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ tmpDir = GinkgoT().TempDir()
+ conf.Server.DataFolder = conf.NewDir(tmpDir)
+
+ // Create a real image file on disk
+ imgDir := filepath.Join(tmpDir, "artwork", "playlist")
+ Expect(os.MkdirAll(imgDir, 0755)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(imgDir, "pls-1.jpg"), []byte("img data"), 0600)).To(Succeed())
+
+ mockPlsRepo.Data = map[string]*model.Playlist{
+ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1", UploadedImage: "pls-1.jpg"},
+ "pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"},
+ "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
+ }
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+ })
+
+ It("removes file and clears UploadedImage", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.RemoveImage(ctx, "pls-1")
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty())
+ absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg")
+ Expect(absPath).ToNot(BeAnExistingFile())
+ })
+
+ It("succeeds even if playlist has no image", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.RemoveImage(ctx, "pls-empty")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty())
+ })
+
+ It("denies non-owner", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
+ err := ps.RemoveImage(ctx, "pls-1")
+ Expect(err).To(MatchError(model.ErrNotAuthorized))
+ })
+
+ It("returns error when playlist not found", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ err := ps.RemoveImage(ctx, "nonexistent")
+ Expect(err).To(Equal(model.ErrNotFound))
+ })
+ })
+})
diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go
new file mode 100644
index 000000000..3f886aadd
--- /dev/null
+++ b/core/playlists/rest_adapter.go
@@ -0,0 +1,200 @@
+package playlists
+
+import (
+ "context"
+ "errors"
+ "reflect"
+ "strings"
+
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/criteria"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/utils/slice"
+)
+
+// --- REST adapter (follows Share/Library pattern) ---
+
+func (s *playlists) NewRepository(ctx context.Context) rest.Repository {
+ return &playlistRepositoryWrapper{
+ ctx: ctx,
+ PlaylistRepository: s.ds.Playlist(ctx),
+ service: s,
+ }
+}
+
+// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter.
+// It satisfies rest.Repository through the embedded PlaylistRepository (via ResourceRepository),
+// and rest.Persistable by delegating to service methods for all mutations.
+type playlistRepositoryWrapper struct {
+ model.PlaylistRepository
+ ctx context.Context
+ service *playlists
+}
+
+func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
+ return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
+}
+
+func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
+ return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
+}
+
+func (r *playlistRepositoryWrapper) Delete(id string) error {
+ err := r.service.Delete(r.ctx, id)
+ switch {
+ case errors.Is(err, model.ErrNotFound):
+ return rest.ErrNotFound
+ case errors.Is(err, model.ErrNotAuthorized):
+ return rest.ErrPermissionDenied
+ default:
+ return err
+ }
+}
+
+func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository {
+ repo := s.ds.Playlist(ctx)
+ tracks := repo.Tracks(playlistId, refreshSmartPlaylist)
+ if tracks == nil {
+ return nil
+ }
+ return tracks.(rest.Repository)
+}
+
+// savePlaylist creates a new playlist, assigning the owner from context.
+// Only Name, Comment, Public, and Rules are user-settable via the REST API.
+func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) {
+ usr, _ := request.UserFrom(ctx)
+ pls.OwnerID = usr.ID
+ pls.ID = "" // Force new creation
+ pls.Path = "" // Server-managed (M3U file path)
+ pls.Sync = false // Server-managed (M3U sync flag)
+ pls.UploadedImage = "" // Managed by image upload endpoint
+ pls.ExternalImageURL = "" // Managed by M3U import / plugins only
+ pls.EvaluatedAt = nil // Server-managed
+ err := s.ds.Playlist(ctx).Put(pls)
+ if err != nil {
+ return "", err
+ }
+ return pls.ID, nil
+}
+
+// updatePlaylistEntity updates playlist metadata with permission checks.
+// Used by the REST API wrapper.
+//
+// cols names the fields the client actually sent in the JSON body (extracted by
+// rest.Put). When non-empty, fields outside cols are not considered changed and
+// are left untouched — this prevents partial requests like bulk "Make Public"
+// (body: {"public": true}) from wiping fields that just happen to be zero in
+// the deserialized entity (see issue #5541). An empty cols means "treat the
+// entity as a complete record" — preserved for callers that don't use the REST
+// wrapper.
+func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
+ current, err := s.checkWritable(ctx, id)
+ if err != nil {
+ switch {
+ case errors.Is(err, model.ErrNotFound):
+ return rest.ErrNotFound
+ case errors.Is(err, model.ErrNotAuthorized):
+ return rest.ErrPermissionDenied
+ default:
+ return err
+ }
+ }
+
+ sent := sentFields(cols)
+
+ usr, _ := request.UserFrom(ctx)
+ ownerChanged := sent("ownerId") && entity.OwnerID != "" && entity.OwnerID != current.OwnerID
+ if !usr.IsAdmin && ownerChanged {
+ return rest.ErrPermissionDenied
+ }
+
+ nameChanged := sent("name") && entity.Name != current.Name
+ commentChanged := sent("comment") && entity.Comment != current.Comment
+ rulesChanged := sent("rules") && !rulesEqual(current.Rules, entity.Rules)
+
+ if nameChanged || commentChanged || ownerChanged || rulesChanged {
+ return s.applyContentUpdate(ctx, current, entity, sent,
+ nameChanged, commentChanged, ownerChanged, rulesChanged)
+ }
+ return s.applyFlagsOnly(ctx, current, entity, sent)
+}
+
+// applyContentUpdate handles updates that change at least one of name/comment/
+// owner/rules. It goes through updateMetadata, which always bumps updatedAt
+// (invalidating cached cover-art URLs). namePtr/commentPtr are nil when the
+// field is absent from the request OR present-but-unchanged (so updateMetadata
+// skips them); publicPtr is nil only when public is absent from the request
+// (an idempotent public value is still forwarded).
+func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *model.Playlist,
+ sent func(string) bool, nameChanged, commentChanged, ownerChanged, rulesChanged bool,
+) error {
+ if ownerChanged {
+ current.OwnerID = entity.OwnerID
+ }
+ if rulesChanged {
+ current.Rules = entity.Rules
+ }
+ if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
+ current.Sync = entity.Sync
+ }
+ var namePtr, commentPtr *string
+ var publicPtr *bool
+ if nameChanged {
+ namePtr = &entity.Name
+ }
+ if commentChanged {
+ commentPtr = &entity.Comment
+ }
+ if sent("public") {
+ publicPtr = &entity.Public
+ }
+ return s.updateMetadata(ctx, s.ds, current, namePtr, commentPtr, publicPtr)
+}
+
+// applyFlagsOnly handles updates that only toggle sync/public — skips
+// updatedAt so cover art URLs stay stable.
+func (s *playlists) applyFlagsOnly(ctx context.Context, current, entity *model.Playlist,
+ sent func(string) bool,
+) error {
+ var updateCols []string
+ if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
+ current.Sync = entity.Sync
+ updateCols = append(updateCols, "sync")
+ }
+ if sent("public") && current.Public != entity.Public {
+ current.Public = entity.Public
+ updateCols = append(updateCols, "public")
+ }
+ if len(updateCols) == 0 {
+ return nil
+ }
+ return s.ds.Playlist(ctx).Put(current, updateCols...)
+}
+
+// sentFields returns a predicate that reports whether a JSON field was present
+// in the request body. Matching is case-insensitive to mirror Go's json
+// decoder, which populates struct fields from case-variant keys like
+// {"Name":"x"} or {"OWNERID":"y"}. An empty cols list means "treat the entity
+// as a full record" — every field is considered sent.
+func sentFields(cols []string) func(string) bool {
+ if len(cols) == 0 {
+ return func(string) bool { return true }
+ }
+ set := slice.ToMap(cols, func(c string) (string, struct{}) { return strings.ToLower(c), struct{}{} })
+ return func(field string) bool {
+ _, ok := set[strings.ToLower(field)]
+ return ok
+ }
+}
+
+func rulesEqual(a, b *criteria.Criteria) bool {
+ if a == b {
+ return true
+ }
+ if a == nil || b == nil {
+ return false
+ }
+ return reflect.DeepEqual(a, b)
+}
diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go
new file mode 100644
index 000000000..79d72d147
--- /dev/null
+++ b/core/playlists/rest_adapter_test.go
@@ -0,0 +1,409 @@
+package playlists_test
+
+import (
+ "context"
+ "time"
+
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/core"
+ "github.com/navidrome/navidrome/core/playlists"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/criteria"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("REST Adapter", func() {
+ var ds *tests.MockDataStore
+ var ps playlists.Playlists
+ var mockPlsRepo *tests.MockPlaylistRepo
+ ctx := context.Background()
+
+ BeforeEach(func() {
+ mockPlsRepo = tests.CreateMockPlaylistRepo()
+ ds = &tests.MockDataStore{
+ MockedPlaylist: mockPlsRepo,
+ MockedLibrary: &tests.MockLibraryRepo{},
+ }
+ ctx = request.WithUser(ctx, model.User{ID: "123"})
+ })
+
+ Describe("NewRepository", func() {
+ var repo rest.Persistable
+
+ BeforeEach(func() {
+ mockPlsRepo.Data = map[string]*model.Playlist{
+ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
+ }
+ ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
+ })
+
+ Describe("Save", func() {
+ It("sets the owner from the context user", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ pls := &model.Playlist{Name: "New Playlist"}
+ id, err := repo.Save(pls)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(id).ToNot(BeEmpty())
+ Expect(pls.OwnerID).To(Equal("user-1"))
+ })
+
+ It("forces a new creation by clearing ID", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ pls := &model.Playlist{ID: "should-be-cleared", Name: "New"}
+ _, err := repo.Save(pls)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.ID).ToNot(Equal("should-be-cleared"))
+ })
+
+ It("clears server-managed fields to prevent injection via REST API", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ pls := &model.Playlist{
+ Name: "Legit Playlist",
+ Comment: "A comment",
+ Public: true,
+ Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}},
+ Path: "/some/path/playlist.m3u",
+ Sync: true,
+ UploadedImage: "injected-image-path",
+ ExternalImageURL: "http://evil.example.com/ssrf",
+ EvaluatedAt: new(time.Now()),
+ }
+ _, err := repo.Save(pls)
+ Expect(err).ToNot(HaveOccurred())
+
+ saved := mockPlsRepo.Last
+ // User-settable fields are preserved
+ Expect(saved.Name).To(Equal("Legit Playlist"))
+ Expect(saved.Comment).To(Equal("A comment"))
+ Expect(saved.Public).To(BeTrue())
+ Expect(saved.Rules).ToNot(BeNil())
+ // Server-managed fields are cleared
+ Expect(saved.Path).To(BeEmpty())
+ Expect(saved.Sync).To(BeFalse())
+ Expect(saved.UploadedImage).To(BeEmpty())
+ Expect(saved.ExternalImageURL).To(BeEmpty())
+ Expect(saved.EvaluatedAt).To(BeNil())
+ })
+ })
+
+ Describe("Update", func() {
+ It("allows owner to update their playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ pls := &model.Playlist{Name: "Updated"}
+ err := repo.Update("pls-1", pls)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("allows admin to update any playlist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ pls := &model.Playlist{Name: "Updated"}
+ err := repo.Update("pls-1", pls)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("denies non-owner, non-admin", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ pls := &model.Playlist{Name: "Updated"}
+ err := repo.Update("pls-1", pls)
+ Expect(err).To(Equal(rest.ErrPermissionDenied))
+ })
+
+ It("denies regular user from changing ownership", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"}
+ err := repo.Update("pls-1", pls)
+ Expect(err).To(Equal(rest.ErrPermissionDenied))
+ })
+
+ DescribeTable("denies regular user from changing ownership under any case-variant JSON key",
+ func(colName string) {
+ // rest.Put's field-name extraction is case-sensitive, but Go's
+ // json decoder is case-insensitive on struct fields, so any
+ // {"OwnerId":"x"} / {"OWNERID":"x"} / {"ownerid":"x"} populates
+ // entity.OwnerID. sentFields normalizes both sides so the
+ // permission gate fires regardless of casing.
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ pls := &model.Playlist{OwnerID: "other-user"}
+ err := repo.Update("pls-1", pls, colName)
+ Expect(err).To(Equal(rest.ErrPermissionDenied))
+ },
+ Entry("canonical camelCase", "ownerId"),
+ Entry("PascalCase", "OwnerId"),
+ Entry("all upper", "OWNERID"),
+ Entry("all lower", "ownerid"),
+ )
+
+ It("updates smart playlist rules", func() {
+ mockPlsRepo.Data["smart-1"] = &model.Playlist{
+ ID: "smart-1",
+ Name: "Smart Playlist",
+ OwnerID: "user-1",
+ Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}},
+ }
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}}
+ pls := &model.Playlist{Name: "Smart Playlist", Rules: newRules}
+ err := repo.Update("smart-1", pls)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
+ })
+
+ It("allows toggling sync for file-backed playlists", func() {
+ originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
+ mockPlsRepo.Data["file-pls"] = &model.Playlist{
+ ID: "file-pls",
+ Name: "File Playlist",
+ OwnerID: "user-1",
+ Path: "/music/playlist.m3u",
+ Sync: true,
+ UpdatedAt: originalTime,
+ }
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ pls := &model.Playlist{Name: "File Playlist", Sync: false}
+ err := repo.Update("file-pls", pls)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last.Sync).To(BeFalse())
+ Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime))
+ })
+
+ It("does not allow setting sync on non-file-backed playlists", func() {
+ mockPlsRepo.Data["manual-pls"] = &model.Playlist{
+ ID: "manual-pls",
+ Name: "Manual Playlist",
+ OwnerID: "user-1",
+ Path: "",
+ Sync: false,
+ }
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ pls := &model.Playlist{Name: "Manual Playlist", Sync: true}
+ err := repo.Update("manual-pls", pls)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last).To(BeNil())
+ })
+
+ It("does not bump updatedAt when only public changes", func() {
+ originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
+ mockPlsRepo.Data["pls-pub"] = &model.Playlist{
+ ID: "pls-pub",
+ Name: "My Playlist",
+ OwnerID: "user-1",
+ Public: false,
+ UpdatedAt: originalTime,
+ }
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ pls := &model.Playlist{Name: "My Playlist", Public: true}
+ err := repo.Update("pls-pub", pls)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last.Public).To(BeTrue())
+ Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime))
+ })
+
+ It("bumps updatedAt when name changes along with sync", func() {
+ mockPlsRepo.Data["file-pls2"] = &model.Playlist{
+ ID: "file-pls2",
+ Name: "Old Name",
+ OwnerID: "user-1",
+ Path: "/music/playlist.m3u",
+ Sync: true,
+ }
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ pls := &model.Playlist{Name: "New Name", Sync: false}
+ err := repo.Update("file-pls2", pls)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last.Name).To(Equal("New Name"))
+ Expect(mockPlsRepo.Last.Sync).To(BeFalse())
+ })
+
+ It("returns rest.ErrNotFound when playlist doesn't exist", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ pls := &model.Playlist{Name: "Updated"}
+ err := repo.Update("nonexistent", pls)
+ Expect(err).To(Equal(rest.ErrNotFound))
+ })
+
+ // Regression tests for #5541: partial REST updates (e.g. bulk "Make Public")
+ // must only touch the fields the client actually sent. The cols list from
+ // rest.Put names those fields; fields outside it must be left alone, even
+ // when the deserialized entity has zero values for them.
+ Context("with partial updates (cols)", func() {
+ BeforeEach(func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ mockPlsRepo.Data["partial"] = &model.Playlist{
+ ID: "partial",
+ Name: "Original Name",
+ Comment: "Original comment",
+ OwnerID: "user-1",
+ Public: false,
+ }
+ })
+
+ It("preserves name and comment when only public is sent (bulk Make Public)", func() {
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ err := repo.Update("partial", &model.Playlist{Public: true}, "public")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last.Name).To(Equal("Original Name"))
+ Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
+ Expect(mockPlsRepo.Last.Public).To(BeTrue())
+ })
+
+ It("preserves name when only sync is sent for a file-backed playlist", func() {
+ mockPlsRepo.Data["file-partial"] = &model.Playlist{
+ ID: "file-partial",
+ Name: "Keep Me",
+ OwnerID: "user-1",
+ Path: "/music/p.m3u",
+ Sync: true,
+ }
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ err := repo.Update("file-partial", &model.Playlist{Sync: false}, "sync")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last.Name).To(Equal("Keep Me"))
+ Expect(mockPlsRepo.Last.Sync).To(BeFalse())
+ })
+
+ It("renames the playlist when only name is sent", func() {
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "name")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last.Name).To(Equal("Renamed"))
+ Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
+ Expect(mockPlsRepo.Last.Public).To(BeFalse())
+ })
+
+ It("clears the comment when an empty comment is sent explicitly", func() {
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ err := repo.Update("partial", &model.Playlist{Comment: ""}, "comment")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last.Comment).To(BeEmpty())
+ Expect(mockPlsRepo.Last.Name).To(Equal("Original Name"))
+ })
+
+ It("updates rules-only on a smart playlist (Feishin-style edit)", func() {
+ mockPlsRepo.Data["smart-partial"] = &model.Playlist{
+ ID: "smart-partial",
+ Name: "Smart Original",
+ Comment: "smart comment",
+ OwnerID: "user-1",
+ Public: true,
+ Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
+ }
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"}
+ err := repo.Update("smart-partial", &model.Playlist{Rules: newRules}, "rules")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
+ Expect(mockPlsRepo.Last.Name).To(Equal("Smart Original"))
+ Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment"))
+ Expect(mockPlsRepo.Last.Public).To(BeTrue())
+ })
+
+ It("updates name and rules together (smart-playlist Edit form)", func() {
+ mockPlsRepo.Data["smart-edit"] = &model.Playlist{
+ ID: "smart-edit",
+ Name: "Smart Original",
+ Comment: "smart comment",
+ OwnerID: "user-1",
+ Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
+ }
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"}
+ err := repo.Update("smart-edit",
+ &model.Playlist{Name: "Smart Renamed", Rules: newRules},
+ "name", "rules")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last.Name).To(Equal("Smart Renamed"))
+ Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
+ Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment"))
+ })
+
+ It("does not bump the saved rules on an idempotent rules-only PUT", func() {
+ rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
+ mockPlsRepo.Data["smart-idempotent"] = &model.Playlist{
+ ID: "smart-idempotent",
+ Name: "Smart Idempotent",
+ OwnerID: "user-1",
+ Rules: rules,
+ }
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ // Same rules sent back — rulesEqual should report no change and
+ // the request should no-op (no Put call).
+ sameRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
+ err := repo.Update("smart-idempotent", &model.Playlist{Rules: sameRules}, "rules")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last).To(BeNil()) // no Put happened
+ })
+
+ It("preserves rules when only public is sent (smart playlist + bulk Make Public)", func() {
+ rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
+ mockPlsRepo.Data["smart-public"] = &model.Playlist{
+ ID: "smart-public",
+ Name: "Smart Public",
+ OwnerID: "user-1",
+ Public: false,
+ Rules: rules,
+ }
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ err := repo.Update("smart-public", &model.Playlist{Public: true}, "public")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last.Public).To(BeTrue())
+ Expect(mockPlsRepo.Last.Rules).To(Equal(rules))
+ Expect(mockPlsRepo.Last.Name).To(Equal("Smart Public"))
+ })
+
+ It("does not treat a missing ownerId as an ownership transfer attempt", func() {
+ // A non-admin user sending only {public:true} should not be blocked
+ // just because OwnerID is the zero value in the deserialized entity.
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ err := repo.Update("partial", &model.Playlist{Public: true}, "public")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("matches cols case-insensitively (mirrors json decoder behavior)", func() {
+ // Go's json decoder populates struct fields from case-variant keys
+ // like {"Name":"x"}, but rest.Put's field-name extraction is
+ // case-sensitive. sentFields normalizes both sides so a request
+ // with {"Name":"Renamed"} is honored, not silently ignored.
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "Name")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Last.Name).To(Equal("Renamed"))
+ Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
+ })
+ })
+ })
+
+ Describe("Delete", func() {
+ It("delegates to service Delete with permission checks", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ err := repo.Delete("pls-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
+ })
+
+ It("denies non-owner", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
+ repo = ps.NewRepository(ctx).(rest.Persistable)
+ err := repo.Delete("pls-1")
+ Expect(err).To(Equal(rest.ErrPermissionDenied))
+ })
+ })
+ })
+})
diff --git a/core/playlists_test.go b/core/playlists_test.go
deleted file mode 100644
index 3a3c9aafc..000000000
--- a/core/playlists_test.go
+++ /dev/null
@@ -1,283 +0,0 @@
-package core
-
-import (
- "context"
- "os"
- "strconv"
- "strings"
- "time"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/conf/configtest"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/criteria"
- "github.com/navidrome/navidrome/model/request"
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("Playlists", func() {
- var ds *tests.MockDataStore
- var ps Playlists
- var mockPlsRepo mockedPlaylistRepo
- var mockLibRepo *tests.MockLibraryRepo
- ctx := context.Background()
-
- BeforeEach(func() {
- mockPlsRepo = mockedPlaylistRepo{}
- mockLibRepo = &tests.MockLibraryRepo{}
- ds = &tests.MockDataStore{
- MockedPlaylist: &mockPlsRepo,
- MockedLibrary: mockLibRepo,
- }
- ctx = request.WithUser(ctx, model.User{ID: "123"})
- // Path should be libPath, but we want to match the root folder referenced in the m3u, which is `/`
- mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/"}})
- })
-
- Describe("ImportFile", func() {
- var folder *model.Folder
- BeforeEach(func() {
- ps = NewPlaylists(ds)
- ds.MockedMediaFile = &mockedMediaFileRepo{}
- libPath, _ := os.Getwd()
- folder = &model.Folder{
- ID: "1",
- LibraryID: 1,
- LibraryPath: libPath,
- Path: "tests/fixtures",
- Name: "playlists",
- }
- })
-
- Describe("M3U", func() {
- It("parses well-formed playlists", func() {
- pls, err := ps.ImportFile(ctx, folder, "pls1.m3u")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.OwnerID).To(Equal("123"))
- Expect(pls.Tracks).To(HaveLen(2))
- Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
- Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
- Expect(mockPlsRepo.last).To(Equal(pls))
- })
-
- It("parses playlists using LF ending", func() {
- pls, err := ps.ImportFile(ctx, folder, "lf-ended.m3u")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(2))
- })
-
- It("parses playlists using CR ending (old Mac format)", func() {
- pls, err := ps.ImportFile(ctx, folder, "cr-ended.m3u")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(2))
- })
- })
-
- Describe("NSP", func() {
- It("parses well-formed playlists", func() {
- pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
- Expect(err).ToNot(HaveOccurred())
- Expect(mockPlsRepo.last).To(Equal(pls))
- Expect(pls.OwnerID).To(Equal("123"))
- Expect(pls.Name).To(Equal("Recently Played"))
- Expect(pls.Comment).To(Equal("Recently played tracks"))
- Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
- Expect(pls.Rules.Order).To(Equal("desc"))
- Expect(pls.Rules.Limit).To(Equal(100))
- Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
- })
- It("returns an error if the playlist is not well-formed", func() {
- _, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
- Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
- })
- })
- })
-
- Describe("ImportM3U", func() {
- var repo *mockedMediaFileFromListRepo
- BeforeEach(func() {
- repo = &mockedMediaFileFromListRepo{}
- ds.MockedMediaFile = repo
- ps = NewPlaylists(ds)
- mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
- ctx = request.WithUser(ctx, model.User{ID: "123"})
- })
-
- It("parses well-formed playlists", func() {
- repo.data = []string{
- "tests/test.mp3",
- "tests/test.ogg",
- "tests/01 Invisible (RED) Edit Version.mp3",
- "downloads/newfile.flac",
- }
- m3u := strings.Join([]string{
- "#PLAYLIST:playlist 1",
- "/music/tests/test.mp3",
- "/music/tests/test.ogg",
- "/new/downloads/newfile.flac",
- "file:///music/tests/01%20Invisible%20(RED)%20Edit%20Version.mp3",
- }, "\n")
- f := strings.NewReader(m3u)
-
- pls, err := ps.ImportM3U(ctx, f)
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.OwnerID).To(Equal("123"))
- Expect(pls.Name).To(Equal("playlist 1"))
- Expect(pls.Sync).To(BeFalse())
- Expect(pls.Tracks).To(HaveLen(4))
- Expect(pls.Tracks[0].Path).To(Equal("tests/test.mp3"))
- Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
- Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
- Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
- Expect(mockPlsRepo.last).To(Equal(pls))
- })
-
- It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
- repo.data = []string{
- "tests/test.mp3",
- "tests/test.ogg",
- "/tests/01 Invisible (RED) Edit Version.mp3",
- }
- m3u := strings.Join([]string{
- "/music/tests/test.mp3",
- "/music/tests/test.ogg",
- }, "\n")
- f := strings.NewReader(m3u)
- pls, err := ps.ImportM3U(ctx, f)
- Expect(err).ToNot(HaveOccurred())
- _, err = time.Parse(time.RFC3339, pls.Name)
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(2))
- })
-
- It("returns only tracks that exist in the database and in the same other as the m3u", func() {
- repo.data = []string{
- "album1/test1.mp3",
- "album2/test2.mp3",
- "album3/test3.mp3",
- }
- m3u := strings.Join([]string{
- "/music/album3/test3.mp3",
- "/music/album1/test1.mp3",
- "/music/album4/test4.mp3",
- "/music/album2/test2.mp3",
- }, "\n")
- f := strings.NewReader(m3u)
- pls, err := ps.ImportM3U(ctx, f)
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(3))
- Expect(pls.Tracks[0].Path).To(Equal("album3/test3.mp3"))
- Expect(pls.Tracks[1].Path).To(Equal("album1/test1.mp3"))
- Expect(pls.Tracks[2].Path).To(Equal("album2/test2.mp3"))
- })
-
- It("is case-insensitive when comparing paths", func() {
- repo.data = []string{
- "abc/tEsT1.Mp3",
- }
- m3u := strings.Join([]string{
- "/music/ABC/TeSt1.mP3",
- }, "\n")
- f := strings.NewReader(m3u)
- pls, err := ps.ImportM3U(ctx, f)
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(1))
- Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
- })
- })
-
- Describe("InPlaylistsPath", func() {
- var folder model.Folder
-
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- folder = model.Folder{
- LibraryPath: "/music",
- Path: "playlists/abc",
- Name: "folder1",
- }
- })
-
- It("returns true if PlaylistsPath is empty", func() {
- conf.Server.PlaylistsPath = ""
- Expect(InPlaylistsPath(folder)).To(BeTrue())
- })
-
- It("returns true if PlaylistsPath is any (**/**)", func() {
- conf.Server.PlaylistsPath = "**/**"
- Expect(InPlaylistsPath(folder)).To(BeTrue())
- })
-
- It("returns true if folder is in PlaylistsPath", func() {
- conf.Server.PlaylistsPath = "other/**:playlists/**"
- Expect(InPlaylistsPath(folder)).To(BeTrue())
- })
-
- It("returns false if folder is not in PlaylistsPath", func() {
- conf.Server.PlaylistsPath = "other"
- Expect(InPlaylistsPath(folder)).To(BeFalse())
- })
-
- It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
- conf.Server.PlaylistsPath = "."
- Expect(InPlaylistsPath(folder)).To(BeFalse())
-
- folder2 := model.Folder{
- LibraryPath: "/music",
- Path: "",
- Name: ".",
- }
-
- Expect(InPlaylistsPath(folder2)).To(BeTrue())
- })
- })
-})
-
-// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
-type mockedMediaFileRepo struct {
- model.MediaFileRepository
-}
-
-func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
- var mfs model.MediaFiles
- for idx, path := range paths {
- mfs = append(mfs, model.MediaFile{
- ID: strconv.Itoa(idx),
- Path: path,
- })
- }
- return mfs, nil
-}
-
-// mockedMediaFileFromListRepo's FindByPaths method returns a list of MediaFiles based on the data field
-type mockedMediaFileFromListRepo struct {
- model.MediaFileRepository
- data []string
-}
-
-func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
- var mfs model.MediaFiles
- for idx, path := range r.data {
- mfs = append(mfs, model.MediaFile{
- ID: strconv.Itoa(idx),
- Path: path,
- })
- }
- return mfs, nil
-}
-
-type mockedPlaylistRepo struct {
- last *model.Playlist
- model.PlaylistRepository
-}
-
-func (r *mockedPlaylistRepo) FindByPath(string) (*model.Playlist, error) {
- return nil, model.ErrNotFound
-}
-
-func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error {
- r.last = pls
- return nil
-}
diff --git a/core/publicurl/publicurl.go b/core/publicurl/publicurl.go
new file mode 100644
index 000000000..b0865e78b
--- /dev/null
+++ b/core/publicurl/publicurl.go
@@ -0,0 +1,84 @@
+package publicurl
+
+import (
+ "cmp"
+ "net/http"
+ "net/url"
+ "path"
+ "strconv"
+ "strings"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/core/auth"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+)
+
+// ImageURL generates a public URL for artwork images.
+// It creates a signed token for the artwork ID and builds a complete public URL.
+func ImageURL(req *http.Request, artID model.ArtworkID, size int) string {
+ token, _ := auth.CreatePublicToken(auth.Claims{ID: artID.String()})
+ uri := path.Join(consts.URLPathPublicImages, token)
+ params := url.Values{}
+ if size > 0 {
+ params.Add("size", strconv.Itoa(size))
+ }
+ return PublicURL(req, uri, params)
+}
+
+// PublicURL builds a full URL for public-facing resources.
+// It uses ShareURL from config if available, otherwise falls back to extracting
+// the scheme and host from the provided http.Request.
+// If req is nil and ShareURL is not set, it defaults to http://localhost.
+func PublicURL(req *http.Request, u string, params url.Values) string {
+ if conf.Server.ShareURL == "" {
+ return AbsoluteURL(req, u, params)
+ }
+ shareUrl, err := url.Parse(conf.Server.ShareURL)
+ if err != nil {
+ return AbsoluteURL(req, u, params)
+ }
+ buildUrl, err := url.Parse(u)
+ if err != nil {
+ return AbsoluteURL(req, u, params)
+ }
+ buildUrl.Scheme = shareUrl.Scheme
+ buildUrl.Host = shareUrl.Host
+ if basePath := strings.TrimRight(shareUrl.Path, "/"); basePath != "" {
+ buildUrl.Path = path.Join(basePath, buildUrl.Path)
+ }
+ if len(params) > 0 {
+ buildUrl.RawQuery = params.Encode()
+ }
+ return buildUrl.String()
+}
+
+// AbsoluteURL builds an absolute URL from a relative path.
+// It uses BaseHost/BaseScheme from config if available, otherwise extracts
+// the scheme and host from the http.Request.
+// If req is nil and BaseHost is not set, it defaults to http://localhost.
+func AbsoluteURL(req *http.Request, u string, params url.Values) string {
+ buildUrl, err := url.Parse(u)
+ if err != nil {
+ log.Error(req.Context(), "Failed to parse URL path", "url", u, err)
+ return ""
+ }
+ if strings.HasPrefix(u, "/") {
+ buildUrl.Path = path.Join(conf.Server.BasePath, buildUrl.Path)
+ if conf.Server.BaseHost != "" {
+ buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http")
+ buildUrl.Host = conf.Server.BaseHost
+ } else if req != nil {
+ buildUrl.Scheme = req.URL.Scheme
+ buildUrl.Host = req.Host
+ } else {
+ buildUrl.Scheme = "http"
+ buildUrl.Host = "localhost"
+ }
+ }
+ if len(params) > 0 {
+ buildUrl.RawQuery = params.Encode()
+ }
+ return buildUrl.String()
+}
diff --git a/core/publicurl/publicurl_test.go b/core/publicurl/publicurl_test.go
new file mode 100644
index 000000000..a195fb9cd
--- /dev/null
+++ b/core/publicurl/publicurl_test.go
@@ -0,0 +1,199 @@
+package publicurl_test
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ "github.com/go-chi/jwtauth/v5"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core/auth"
+ "github.com/navidrome/navidrome/core/publicurl"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestPublicURL(t *testing.T) {
+ tests.Init(t, false)
+ log.SetLevel(log.LevelFatal)
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Public URL Suite")
+}
+
+var _ = Describe("Public URL Utilities", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ })
+
+ Describe("PublicURL", func() {
+ When("ShareURL is set", func() {
+ BeforeEach(func() {
+ conf.Server.ShareURL = "https://share.example.com"
+ })
+
+ It("uses ShareURL as the base", func() {
+ r, _ := http.NewRequest("GET", "http://localhost/test", nil)
+ result := publicurl.PublicURL(r, "/path/to/resource", nil)
+ Expect(result).To(Equal("https://share.example.com/path/to/resource"))
+ })
+
+ It("includes query parameters", func() {
+ r, _ := http.NewRequest("GET", "http://localhost/test", nil)
+ params := url.Values{"size": []string{"300"}, "format": []string{"png"}}
+ result := publicurl.PublicURL(r, "/image/123", params)
+ Expect(result).To(ContainSubstring("https://share.example.com/image/123"))
+ Expect(result).To(ContainSubstring("size=300"))
+ Expect(result).To(ContainSubstring("format=png"))
+ })
+
+ It("works without a request", func() {
+ result := publicurl.PublicURL(nil, "/path/to/resource", nil)
+ Expect(result).To(Equal("https://share.example.com/path/to/resource"))
+ })
+ })
+
+ When("ShareURL includes a path", func() {
+ BeforeEach(func() {
+ conf.Server.ShareURL = "https://example.com/navi"
+ })
+
+ It("prepends the ShareURL path to the resource", func() {
+ r, _ := http.NewRequest("GET", "http://localhost/test", nil)
+ result := publicurl.PublicURL(r, "/share/img/hash", nil)
+ Expect(result).To(Equal("https://example.com/navi/share/img/hash"))
+ })
+
+ It("prepends the ShareURL path and includes query parameters", func() {
+ r, _ := http.NewRequest("GET", "http://localhost/test", nil)
+ params := url.Values{"size": []string{"600"}}
+ result := publicurl.PublicURL(r, "/share/img/hash", params)
+ Expect(result).To(Equal("https://example.com/navi/share/img/hash?size=600"))
+ })
+
+ It("handles trailing slash in ShareURL path", func() {
+ conf.Server.ShareURL = "https://example.com/navi/"
+ result := publicurl.PublicURL(nil, "/share/img/hash", nil)
+ Expect(result).To(Equal("https://example.com/navi/share/img/hash"))
+ })
+ })
+
+ When("ShareURL is not set", func() {
+ BeforeEach(func() {
+ conf.Server.ShareURL = ""
+ })
+
+ It("falls back to AbsoluteURL with request", func() {
+ r, _ := http.NewRequest("GET", "https://myserver.com/test", nil)
+ r.Host = "myserver.com"
+ result := publicurl.PublicURL(r, "/path/to/resource", nil)
+ Expect(result).To(Equal("https://myserver.com/path/to/resource"))
+ })
+
+ It("falls back to localhost without request", func() {
+ result := publicurl.PublicURL(nil, "/path/to/resource", nil)
+ Expect(result).To(Equal("http://localhost/path/to/resource"))
+ })
+ })
+ })
+
+ Describe("AbsoluteURL", func() {
+ When("BaseHost is set", func() {
+ BeforeEach(func() {
+ conf.Server.BaseHost = "configured.example.com"
+ conf.Server.BaseScheme = "https"
+ conf.Server.BasePath = ""
+ })
+
+ It("uses BaseHost and BaseScheme", func() {
+ r, _ := http.NewRequest("GET", "http://localhost/test", nil)
+ result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
+ Expect(result).To(Equal("https://configured.example.com/path/to/resource"))
+ })
+
+ It("defaults to http scheme if BaseScheme is empty", func() {
+ conf.Server.BaseScheme = ""
+ r, _ := http.NewRequest("GET", "http://localhost/test", nil)
+ result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
+ Expect(result).To(Equal("http://configured.example.com/path/to/resource"))
+ })
+ })
+
+ When("BaseHost is not set", func() {
+ BeforeEach(func() {
+ conf.Server.BaseHost = ""
+ conf.Server.BasePath = ""
+ })
+
+ It("extracts host from request", func() {
+ r, _ := http.NewRequest("GET", "https://request.example.com/test", nil)
+ r.Host = "request.example.com"
+ result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
+ Expect(result).To(Equal("https://request.example.com/path/to/resource"))
+ })
+
+ It("falls back to localhost without request", func() {
+ result := publicurl.AbsoluteURL(nil, "/path/to/resource", nil)
+ Expect(result).To(Equal("http://localhost/path/to/resource"))
+ })
+ })
+
+ When("BasePath is set", func() {
+ BeforeEach(func() {
+ conf.Server.BasePath = "/navidrome"
+ conf.Server.BaseHost = "example.com"
+ conf.Server.BaseScheme = "https"
+ })
+
+ It("prepends BasePath to the URL", func() {
+ r, _ := http.NewRequest("GET", "http://localhost/test", nil)
+ result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
+ Expect(result).To(Equal("https://example.com/navidrome/path/to/resource"))
+ })
+ })
+
+ It("passes through absolute URLs unchanged", func() {
+ r, _ := http.NewRequest("GET", "http://localhost/test", nil)
+ result := publicurl.AbsoluteURL(r, "https://other.example.com/path", nil)
+ Expect(result).To(Equal("https://other.example.com/path"))
+ })
+
+ It("includes query parameters", func() {
+ conf.Server.BaseHost = "example.com"
+ conf.Server.BaseScheme = "https"
+ r, _ := http.NewRequest("GET", "http://localhost/test", nil)
+ params := url.Values{"key": []string{"value"}}
+ result := publicurl.AbsoluteURL(r, "/path", params)
+ Expect(result).To(Equal("https://example.com/path?key=value"))
+ })
+ })
+
+ Describe("ImageURL", func() {
+ BeforeEach(func() {
+ conf.Server.ShareURL = "https://share.example.com"
+ // Initialize JWT auth for token generation
+ auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
+ })
+
+ It("generates a URL with the artwork token", func() {
+ artID := model.NewArtworkID(model.KindAlbumArtwork, "album-123", nil)
+ result := publicurl.ImageURL(nil, artID, 0)
+ Expect(result).To(HavePrefix("https://share.example.com/share/img/"))
+ })
+
+ It("includes size parameter when provided", func() {
+ artID := model.NewArtworkID(model.KindArtistArtwork, "artist-1", nil)
+ result := publicurl.ImageURL(nil, artID, 300)
+ Expect(result).To(ContainSubstring("size=300"))
+ })
+
+ It("omits size parameter when zero", func() {
+ artID := model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil)
+ result := publicurl.ImageURL(nil, artID, 0)
+ Expect(result).ToNot(ContainSubstring("size="))
+ })
+ })
+})
diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go
index 047e43eef..67593e9eb 100644
--- a/core/scrobbler/buffered_scrobbler.go
+++ b/core/scrobbler/buffered_scrobbler.go
@@ -9,26 +9,65 @@ import (
"github.com/navidrome/navidrome/model"
)
+// Loader is a function that loads a scrobbler by name.
+// It returns the scrobbler and true if found, or nil and false if not available.
+// This allows the buffered scrobbler to always get the current plugin instance.
+type Loader func() (Scrobbler, bool)
+
+// newBufferedScrobbler creates a buffered scrobbler that wraps a static scrobbler instance.
+// Use this for builtin scrobblers that don't change.
func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler {
- b := &bufferedScrobbler{ds: ds, wrapped: s, service: service}
- b.wakeSignal = make(chan struct{}, 1)
- go b.run(context.TODO())
+ return newBufferedScrobblerWithLoader(ds, service, func() (Scrobbler, bool) {
+ return s, true
+ })
+}
+
+// newBufferedScrobblerWithLoader creates a buffered scrobbler that dynamically loads
+// the underlying scrobbler on each call. Use this for plugin scrobblers that may be
+// reloaded (e.g., after configuration changes).
+func newBufferedScrobblerWithLoader(ds model.DataStore, service string, loader Loader) *bufferedScrobbler {
+ ctx, cancel := context.WithCancel(context.Background())
+ b := &bufferedScrobbler{
+ ds: ds,
+ loader: loader,
+ service: service,
+ wakeSignal: make(chan struct{}, 1),
+ ctx: ctx,
+ cancel: cancel,
+ }
+ go b.run(ctx)
return b
}
type bufferedScrobbler struct {
ds model.DataStore
- wrapped Scrobbler
+ loader Loader
service string
wakeSignal chan struct{}
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+func (b *bufferedScrobbler) Stop() {
+ if b.cancel != nil {
+ b.cancel()
+ }
}
func (b *bufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
- return b.wrapped.IsAuthorized(ctx, userId)
+ s, ok := b.loader()
+ if !ok {
+ return false
+ }
+ return s.IsAuthorized(ctx, userId)
}
-func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
- return b.wrapped.NowPlaying(ctx, userId, track)
+func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
+ s, ok := b.loader()
+ if !ok {
+ return errors.New("scrobbler not available")
+ }
+ return s.NowPlaying(ctx, userId, track, position)
}
func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
@@ -41,6 +80,14 @@ func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrob
return nil
}
+func (b *bufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
+ s, ok := b.loader()
+ if !ok {
+ return errors.New("scrobbler not available")
+ }
+ return s.PlaybackReport(ctx, info)
+}
+
func (b *bufferedScrobbler) sendWakeSignal() {
// Don't block if the previous signal was not read yet
select {
@@ -92,8 +139,13 @@ func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string)
if entry == nil {
return true
}
+ s, ok := b.loader()
+ if !ok {
+ log.Warn(ctx, "Scrobbler not available, will retry later", "scrobbler", b.service)
+ return false
+ }
log.Debug(ctx, "Sending scrobble", "scrobbler", b.service, "track", entry.Title, "artist", entry.Artist)
- err = b.wrapped.Scrobble(ctx, entry.UserID, Scrobble{
+ err = s.Scrobble(ctx, entry.UserID, Scrobble{
MediaFile: entry.MediaFile,
TimeStamp: entry.PlayTime,
})
diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go
new file mode 100644
index 000000000..9fbca6f71
--- /dev/null
+++ b/core/scrobbler/buffered_scrobbler_test.go
@@ -0,0 +1,89 @@
+package scrobbler
+
+import (
+ "context"
+ "time"
+
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("BufferedScrobbler", func() {
+ var ds model.DataStore
+ var scr *fakeScrobbler
+ var bs *bufferedScrobbler
+ var ctx context.Context
+ var buffer *tests.MockedScrobbleBufferRepo
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ buffer = tests.CreateMockedScrobbleBufferRepo()
+ ds = &tests.MockDataStore{
+ MockedScrobbleBuffer: buffer,
+ }
+ scr = &fakeScrobbler{Authorized: true}
+ bs = newBufferedScrobbler(ds, scr, "test")
+ })
+
+ It("forwards IsAuthorized calls", func() {
+ scr.Authorized = true
+ Expect(bs.IsAuthorized(ctx, "user1")).To(BeTrue())
+
+ scr.Authorized = false
+ Expect(bs.IsAuthorized(ctx, "user1")).To(BeFalse())
+ })
+
+ It("forwards NowPlaying calls", func() {
+ track := &model.MediaFile{ID: "123", Title: "Test Track"}
+ Expect(bs.NowPlaying(ctx, "user1", track, 0)).To(Succeed())
+ Expect(scr.GetNowPlayingCalled()).To(BeTrue())
+ Expect(scr.GetUserID()).To(Equal("user1"))
+ Expect(scr.GetTrack()).To(Equal(track))
+ })
+
+ It("enqueues scrobbles to buffer", func() {
+ track := model.MediaFile{ID: "123", Title: "Test Track"}
+ now := time.Now()
+ scrobble := Scrobble{MediaFile: track, TimeStamp: now}
+ Expect(buffer.Length()).To(Equal(int64(0)))
+ Expect(scr.ScrobbleCalled.Load()).To(BeFalse())
+
+ Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed())
+
+ // Wait for the background goroutine to process the scrobble.
+ // We don't check buffer.Length() here because the background goroutine
+ // may dequeue the entry before we can observe it.
+ Eventually(scr.ScrobbleCalled.Load).Should(BeTrue())
+
+ lastScrobble := scr.LastScrobble.Load()
+ Expect(lastScrobble.MediaFile.ID).To(Equal("123"))
+ Expect(lastScrobble.TimeStamp).To(BeTemporally("==", now))
+ })
+
+ It("stops the background goroutine when Stop is called", func() {
+ // Replace the real run method with one that signals when it exits
+ done := make(chan struct{})
+
+ // Start our instrumented run function that will signal when it exits
+ go func() {
+ defer close(done)
+ bs.run(bs.ctx)
+ }()
+
+ // Wait a bit to ensure the goroutine is running
+ time.Sleep(10 * time.Millisecond)
+
+ // Call the real Stop method
+ bs.Stop()
+
+ // Wait for the goroutine to exit or timeout
+ select {
+ case <-done:
+ // Success, goroutine exited
+ case <-time.After(100 * time.Millisecond):
+ Fail("Goroutine did not exit in time after Stop was called")
+ }
+ })
+})
diff --git a/core/scrobbler/interfaces.go b/core/scrobbler/interfaces.go
index 90141f112..8a18bb37e 100644
--- a/core/scrobbler/interfaces.go
+++ b/core/scrobbler/interfaces.go
@@ -21,8 +21,9 @@ var (
type Scrobbler interface {
IsAuthorized(ctx context.Context, userId string) bool
- NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error
+ NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error
Scrobble(ctx context.Context, userId string, s Scrobble) error
+ PlaybackReport(ctx context.Context, info PlaybackSession) error
}
type Constructor func(ds model.DataStore) Scrobbler
diff --git a/core/scrobbler/nowplaying_worker.go b/core/scrobbler/nowplaying_worker.go
new file mode 100644
index 000000000..1bacec689
--- /dev/null
+++ b/core/scrobbler/nowplaying_worker.go
@@ -0,0 +1,78 @@
+package scrobbler
+
+import (
+ "context"
+ "time"
+
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+)
+
+func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) {
+ p.npMu.Lock()
+ defer p.npMu.Unlock()
+ ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
+ p.npQueue[playerId] = nowPlayingEntry{
+ ctx: ctx,
+ userId: userId,
+ track: track,
+ position: position,
+ }
+ p.sendNowPlayingSignal()
+}
+
+func (p *playTracker) sendNowPlayingSignal() {
+ // Don't block if the previous signal was not read yet
+ select {
+ case p.npSignal <- struct{}{}:
+ default:
+ }
+}
+
+func (p *playTracker) nowPlayingWorker() {
+ defer close(p.workerDone)
+ for {
+ select {
+ case <-p.shutdown:
+ return
+ case <-time.After(time.Second):
+ case <-p.npSignal:
+ }
+
+ p.npMu.Lock()
+ if len(p.npQueue) == 0 {
+ p.npMu.Unlock()
+ continue
+ }
+
+ // Keep a copy of the entries to process and clear the queue
+ entries := p.npQueue
+ p.npQueue = make(map[string]nowPlayingEntry)
+ p.npMu.Unlock()
+
+ // Process entries without holding lock
+ for _, entry := range entries {
+ p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
+ }
+ }
+}
+
+func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
+ if t.Artist == consts.UnknownArtist {
+ log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
+ return
+ }
+ allScrobblers := p.getActiveScrobblers()
+ for name, s := range allScrobblers {
+ if !s.IsAuthorized(ctx, userId) {
+ continue
+ }
+ log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position)
+ err := s.NowPlaying(ctx, userId, t, position)
+ if err != nil {
+ log.Error(ctx, "Error sending PlaybackSession", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
+ continue
+ }
+ }
+}
diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go
index 53f397647..860a80bce 100644
--- a/core/scrobbler/play_tracker.go
+++ b/core/scrobbler/play_tracker.go
@@ -2,9 +2,12 @@ package scrobbler
import (
"context"
- "sort"
+ "maps"
+ "slices"
+ "sync"
"time"
+ "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -14,12 +17,32 @@ import (
"github.com/navidrome/navidrome/utils/singleton"
)
-type NowPlayingInfo struct {
- MediaFile model.MediaFile
- Start time.Time
- Username string
- PlayerId string
- PlayerName string
+const (
+ StateStarting = "starting"
+ StatePlaying = "playing"
+ StatePaused = "paused"
+ StateStopped = "stopped"
+ StateExpired = "expired"
+)
+
+var ValidStates = map[string]bool{
+ StateStarting: true,
+ StatePlaying: true,
+ StatePaused: true,
+ StateStopped: true,
+}
+
+type PlaybackSession struct {
+ MediaFile model.MediaFile
+ Start time.Time
+ UserId string
+ Username string
+ PlayerId string
+ PlayerName string
+ State string
+ PositionMs int64
+ PlaybackRate float64
+ LastReport time.Time
}
type Submission struct {
@@ -27,31 +50,104 @@ type Submission struct {
Timestamp time.Time
}
+type ReportPlaybackParams struct {
+ MediaId string
+ PositionMs int64
+ State string
+ PlaybackRate float64
+ IgnoreScrobble bool
+ ClientId string
+ ClientName string
+}
+
+type nowPlayingEntry struct {
+ ctx context.Context
+ userId string
+ track *model.MediaFile
+ position int
+}
+
+type playbackReportEntry struct {
+ ctx context.Context
+ info PlaybackSession
+}
+
type PlayTracker interface {
- NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error
- GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
+ GetNowPlaying(ctx context.Context) ([]PlaybackSession, error)
Submit(ctx context.Context, submissions []Submission) error
+ ReportPlayback(ctx context.Context, params ReportPlaybackParams) error
+}
+
+// PluginLoader is a minimal interface for plugin manager usage in PlayTracker
+// (avoids import cycles)
+type PluginLoader interface {
+ PluginNames(capability string) []string
+ LoadScrobbler(name string) (Scrobbler, bool)
}
type playTracker struct {
- ds model.DataStore
- broker events.Broker
- playMap cache.SimpleCache[string, NowPlayingInfo]
- scrobblers map[string]Scrobbler
+ ds model.DataStore
+ broker events.Broker
+ playMap cache.SimpleCache[string, PlaybackSession]
+ builtinScrobblers map[string]Scrobbler
+ pluginScrobblers map[string]Scrobbler
+ pluginLoader PluginLoader
+ mu sync.RWMutex
+ npQueue map[string]nowPlayingEntry
+ npMu sync.Mutex
+ npSignal chan struct{}
+ shutdown chan struct{}
+ workerDone chan struct{}
+ prQueue []playbackReportEntry
+ prMu sync.Mutex
+ prSignal chan struct{}
+ prWorkerDone chan struct{}
}
-func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
+func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
return singleton.GetInstance(func() *playTracker {
- return newPlayTracker(ds, broker)
+ return newPlayTracker(ds, broker, pluginManager)
})
}
-// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by
-// the GetPlayTracker function above
-func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
- m := cache.NewSimpleCache[string, NowPlayingInfo]()
- p := &playTracker{ds: ds, playMap: m, broker: broker}
- p.scrobblers = make(map[string]Scrobbler)
+// NewPlayTracker creates a new PlayTracker instance. For normal usage, the PlayTracker has to be a singleton,
+// returned by the GetPlayTracker function above. This constructor is exported for testing.
+func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
+ return newPlayTracker(ds, broker, pluginManager)
+}
+
+func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker {
+ m := cache.NewSimpleCache[string, PlaybackSession]()
+ p := &playTracker{
+ ds: ds,
+ playMap: m,
+ broker: broker,
+ builtinScrobblers: make(map[string]Scrobbler),
+ pluginScrobblers: make(map[string]Scrobbler),
+ pluginLoader: pluginManager,
+ npQueue: make(map[string]nowPlayingEntry),
+ npSignal: make(chan struct{}, 1),
+ shutdown: make(chan struct{}),
+ workerDone: make(chan struct{}),
+ prSignal: make(chan struct{}, 1),
+ prWorkerDone: make(chan struct{}),
+ }
+ enableNowPlaying := conf.Server.EnableNowPlaying
+ m.OnExpiration(func(_ string, info PlaybackSession) {
+ log.Debug("PlaybackSession expired", "clientId", info.PlayerId, "mediaId", info.MediaFile.ID, "state",
+ info.State, "username", info.Username, "userId", info.UserId)
+ if enableNowPlaying {
+ broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()})
+ }
+ ctx := request.WithUser(context.Background(), model.User{ID: info.UserId, UserName: info.Username})
+ if info.State != StateStopped {
+ log.Trace("Enqueueing PlaybackReport for expired session", "session", info)
+ info.State = StateExpired
+ info.LastReport = time.Now()
+ p.enqueuePlaybackReport(ctx, info)
+ }
+ })
+
var enabled []string
for name, constructor := range constructors {
s := constructor(ds)
@@ -61,60 +157,250 @@ func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
}
enabled = append(enabled, name)
s = newBufferedScrobbler(ds, s, name)
- p.scrobblers[name] = s
+ p.builtinScrobblers[name] = s
}
- log.Debug("List of scrobblers enabled", "names", enabled)
+ log.Debug("List of builtin scrobblers enabled", "names", enabled)
+ go p.nowPlayingWorker()
+ go p.playbackReportWorker()
return p
}
-func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error {
- mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId)
- if err != nil {
- log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
- return err
+// stopBackgroundWorkers stops the background workers. This is primarily for testing.
+func (p *playTracker) stopBackgroundWorkers() {
+ close(p.shutdown)
+ <-p.workerDone // Wait for nowPlaying worker to finish
+ <-p.prWorkerDone // Wait for playbackReport worker to finish
+}
+
+// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers.
+func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool {
+ if len(pluginNames) != len(scrobblers) {
+ return false
+ }
+ for _, name := range pluginNames {
+ if _, ok := scrobblers[name]; !ok {
+ return false
+ }
+ }
+ return true
+}
+
+// refreshPluginScrobblers updates the pluginScrobblers map to match the current set of plugin scrobblers.
+// The buffered scrobblers use a loader function to dynamically get the current plugin instance,
+// so we only need to add/remove scrobblers when plugins are added/removed (not when reloaded).
+func (p *playTracker) refreshPluginScrobblers() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.pluginLoader == nil {
+ return
}
- user, _ := request.UserFrom(ctx)
- info := NowPlayingInfo{
- MediaFile: *mf,
- Start: time.Now(),
- Username: user.UserName,
- PlayerId: playerId,
- PlayerName: playerName,
+ // Get the list of available plugin names
+ pluginNames := p.pluginLoader.PluginNames("Scrobbler")
+
+ // Early return if plugin names match existing scrobblers (no change)
+ if pluginNamesMatchScrobblers(pluginNames, p.pluginScrobblers) {
+ return
}
- ttl := time.Duration(int(mf.Duration)+5) * time.Second
- _ = p.playMap.AddWithTTL(playerId, info, ttl)
+ // Build a set of current plugins for faster lookups
+ current := make(map[string]struct{}, len(pluginNames))
+
+ // Process additions - add new plugins with a loader that dynamically fetches the current instance
+ for _, name := range pluginNames {
+ current[name] = struct{}{}
+ if _, exists := p.pluginScrobblers[name]; !exists {
+ // Capture the name for the closure
+ pluginName := name
+ loader := p.pluginLoader
+ p.pluginScrobblers[name] = newBufferedScrobblerWithLoader(p.ds, name, func() (Scrobbler, bool) {
+ return loader.LoadScrobbler(pluginName)
+ })
+ }
+ }
+
+ type stoppableScrobbler interface {
+ Scrobbler
+ Stop()
+ }
+
+ // Process removals - remove plugins that no longer exist
+ for name, scrobbler := range p.pluginScrobblers {
+ if _, exists := current[name]; !exists {
+ // If the scrobbler implements stoppableScrobbler, call Stop() before removing it
+ if stoppable, ok := scrobbler.(stoppableScrobbler); ok {
+ log.Debug("Stopping scrobbler", "name", name)
+ stoppable.Stop()
+ }
+ delete(p.pluginScrobblers, name)
+ }
+ }
+}
+
+// getActiveScrobblers refreshes plugin scrobblers, acquires a read lock,
+// combines builtin and plugin scrobblers into a new map, releases the lock,
+// and returns the combined map.
+func (p *playTracker) getActiveScrobblers() map[string]Scrobbler {
+ p.refreshPluginScrobblers()
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+ combined := maps.Clone(p.builtinScrobblers)
+ maps.Copy(combined, p.pluginScrobblers)
+ return combined
+}
+
+func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration {
+ if rate <= 0 {
+ rate = 1.0
+ }
+ remainingMs := float64(int64(durationSec*1000)-positionMs) / rate
+ remainingSec := max(int(remainingMs/1000), 0)
+ return time.Duration(remainingSec+5) * time.Second
+}
+
+func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackParams) error {
player, _ := request.PlayerFrom(ctx)
- if player.ScrobbleEnabled {
- p.dispatchNowPlaying(ctx, user.ID, mf)
+ user, _ := request.UserFrom(ctx)
+ clientId := params.ClientId
+ client := params.ClientName
+
+ now := time.Now()
+
+ switch params.State {
+ case StateStarting:
+ mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
+ if err != nil {
+ return err
+ }
+ info := PlaybackSession{
+ MediaFile: *mf,
+ Start: now,
+ UserId: user.ID,
+ Username: user.UserName,
+ PlayerId: clientId,
+ PlayerName: client,
+ State: params.State,
+ PositionMs: params.PositionMs,
+ PlaybackRate: params.PlaybackRate,
+ LastReport: now,
+ }
+ err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate))
+ if err != nil {
+ log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
+ }
+ p.enqueuePlaybackReport(ctx, info)
+
+ case StatePlaying, StatePaused:
+ info, getErr := p.playMap.Get(clientId)
+ if getErr != nil || info.MediaFile.ID != params.MediaId {
+ mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
+ if err != nil {
+ return err
+ }
+ info = PlaybackSession{
+ MediaFile: *mf,
+ Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond),
+ UserId: user.ID,
+ Username: user.UserName,
+ PlayerId: clientId,
+ PlayerName: client,
+ }
+ }
+ info.State = params.State
+ info.PositionMs = params.PositionMs
+ info.PlaybackRate = params.PlaybackRate
+ info.LastReport = now
+ ttl := 30 * time.Minute
+ if params.State == StatePlaying {
+ ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate)
+ }
+ log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl)
+ err := p.playMap.AddWithTTL(clientId, info, ttl)
+ if err != nil {
+ log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
+ }
+ p.enqueuePlaybackReport(ctx, info)
+
+ case StateStopped:
+ var loadedMF *model.MediaFile
+ if !params.IgnoreScrobble && player.ScrobbleEnabled {
+ mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
+ if err != nil {
+ return err
+ }
+ loadedMF = mf
+ trackDurationMs := int64(mf.Duration * 1000)
+ threshold := min(trackDurationMs*50/100, 240_000)
+ if params.PositionMs >= threshold {
+ err = p.incPlay(ctx, mf, now)
+ if err != nil {
+ log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err)
+ }
+ p.dispatchScrobble(ctx, mf, now)
+ }
+ }
+ stoppedInfo := PlaybackSession{
+ UserId: user.ID,
+ Username: user.UserName,
+ PlayerId: clientId,
+ PlayerName: client,
+ State: params.State,
+ PositionMs: params.PositionMs,
+ PlaybackRate: params.PlaybackRate,
+ LastReport: now,
+ }
+ if info, getErr := p.playMap.Get(clientId); getErr == nil {
+ stoppedInfo.MediaFile = info.MediaFile
+ stoppedInfo.Start = info.Start
+ } else {
+ mf := loadedMF
+ if mf == nil {
+ var mfErr error
+ mf, mfErr = p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
+ if mfErr != nil {
+ return mfErr
+ }
+ }
+ stoppedInfo.MediaFile = *mf
+ }
+ p.enqueuePlaybackReport(ctx, stoppedInfo)
+ p.playMap.Remove(clientId)
}
+
+ if conf.Server.EnableNowPlaying {
+ p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
+ }
+
+ // NowPlaying gating, by design distinct from scrobble submission:
+ // - IgnoreScrobble=true -> still send NowPlaying (suppresses only the
+ // scrobble submission/play-count above), mirroring the legacy scrobble
+ // endpoint's submission=false behavior.
+ // - player.ScrobbleEnabled=false -> never send NowPlaying.
+ // External agents here are the active scrobblers (Last.fm, ListenBrainz, and
+ // scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying.
+ if player.ScrobbleEnabled &&
+ (params.State == StateStarting || params.State == StatePlaying) {
+ if info, err := p.playMap.Get(clientId); err == nil {
+ p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000))
+ }
+ }
+
return nil
}
-func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile) {
- if t.Artist == consts.UnknownArtist {
- log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
- return
- }
- for name, s := range p.scrobblers {
- if !s.IsAuthorized(ctx, userId) {
- continue
- }
- log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist)
- err := s.NowPlaying(ctx, userId, t)
- if err != nil {
- log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
- continue
- }
- }
-}
-
-func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) {
+func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) {
res := p.playMap.Values()
- sort.Slice(res, func(i, j int) bool {
- return res[i].Start.After(res[j].Start)
+ slices.SortFunc(res, func(a, b PlaybackSession) int {
+ return b.Start.Compare(a.Start)
})
+ for i := range res {
+ if res[i].State == StatePlaying {
+ elapsed := time.Since(res[i].LastReport).Milliseconds()
+ estimated := res[i].PositionMs + int64(float64(elapsed)*res[i].PlaybackRate)
+ trackDurationMs := int64(res[i].MediaFile.Duration * 1000)
+ res[i].PositionMs = min(estimated, trackDurationMs)
+ }
+ }
return res, nil
}
@@ -164,8 +450,14 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times
}
for _, artist := range track.Participants[model.RoleArtist] {
err = tx.Artist(ctx).IncPlayCount(artist.ID, timestamp)
+ if err != nil {
+ return err
+ }
}
- return err
+ if conf.Server.EnableScrobbleHistory {
+ return tx.Scrobble(ctx).RecordScrobble(track.ID, timestamp)
+ }
+ return nil
})
}
@@ -174,9 +466,11 @@ func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile,
log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist)
return
}
+
+ allScrobblers := p.getActiveScrobblers()
u, _ := request.UserFrom(ctx)
scrobble := Scrobble{MediaFile: *t, TimeStamp: playTime}
- for name, s := range p.scrobblers {
+ for name, s := range allScrobblers {
if !s.IsAuthorized(ctx, u.ID) {
continue
}
diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go
index 0ff025f15..b5a478c2a 100644
--- a/core/scrobbler/play_tracker_test.go
+++ b/core/scrobbler/play_tracker_test.go
@@ -3,8 +3,13 @@ package scrobbler
import (
"context"
"errors"
+ "net/http"
+ "sync"
+ "sync/atomic"
"time"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
@@ -15,30 +20,58 @@ import (
. "github.com/onsi/gomega"
)
+type mockPluginLoader struct {
+ mu sync.RWMutex
+ names []string
+ scrobblers map[string]Scrobbler
+}
+
+func (m *mockPluginLoader) PluginNames(service string) []string {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.names
+}
+
+func (m *mockPluginLoader) SetNames(names []string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.names = names
+}
+
+func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ s, ok := m.scrobblers[name]
+ return s, ok
+}
+
var _ = Describe("PlayTracker", func() {
var ctx context.Context
var ds model.DataStore
- var tracker PlayTracker
+ var tracker *playTracker
+ var eventBroker *fakeEventBroker
var track model.MediaFile
var album model.Album
var artist1 model.Artist
var artist2 model.Artist
- var fake fakeScrobbler
+ var fake *fakeScrobbler
BeforeEach(func() {
- ctx = context.Background()
+ DeferCleanup(configtest.SetupConfig())
+ ctx = GinkgoT().Context()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
- fake = fakeScrobbler{Authorized: true}
+ fake = &fakeScrobbler{Authorized: true}
Register("fake", func(model.DataStore) Scrobbler {
- return &fake
+ return fake
})
Register("disabled", func(model.DataStore) Scrobbler {
return nil
})
- tracker = newPlayTracker(ds, events.GetBroker())
- tracker.(*playTracker).scrobblers["fake"] = &fake // Bypass buffering for tests
+ eventBroker = &fakeEventBroker{}
+ tracker = newPlayTracker(ds, eventBroker, nil)
+ tracker.builtinScrobblers["fake"] = fake // Bypass buffering for tests
track = model.MediaFile{
ID: "123",
@@ -61,44 +94,14 @@ var _ = Describe("PlayTracker", func() {
_ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album)
})
- It("does not register disabled scrobblers", func() {
- Expect(tracker.(*playTracker).scrobblers).To(HaveKey("fake"))
- Expect(tracker.(*playTracker).scrobblers).ToNot(HaveKey("disabled"))
+ AfterEach(func() {
+ // Stop the worker goroutine to prevent data races between tests
+ tracker.stopBackgroundWorkers()
})
- Describe("NowPlaying", func() {
- It("sends track to agent", func() {
- err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
- Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeTrue())
- Expect(fake.UserID).To(Equal("u-1"))
- Expect(fake.Track.ID).To(Equal("123"))
- Expect(fake.Track.Participants).To(Equal(track.Participants))
- })
- It("does not send track to agent if user has not authorized", func() {
- fake.Authorized = false
-
- err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
-
- Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeFalse())
- })
- It("does not send track to agent if player is not enabled to send scrobbles", func() {
- ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
-
- err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
-
- Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeFalse())
- })
- It("does not send track to agent if artist is unknown", func() {
- track.Artist = consts.UnknownArtist
-
- err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
-
- Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeFalse())
- })
+ It("does not register disabled scrobblers", func() {
+ Expect(tracker.builtinScrobblers).To(HaveKey("fake"))
+ Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled"))
})
Describe("GetNowPlaying", func() {
@@ -106,10 +109,16 @@ var _ = Describe("PlayTracker", func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
- ctx = request.WithUser(context.Background(), model.User{UserName: "user-1"})
- _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123")
- ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"})
- _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456")
+ ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
+ ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true})
+ _ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", ClientName: "player-one",
+ })
+ ctx2 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
+ ctx2 = request.WithPlayer(ctx2, model.Player{ScrobbleEnabled: true})
+ _ = tracker.ReportPlayback(ctx2, ReportPlaybackParams{
+ MediaId: "456", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
+ })
playing, err := tracker.GetNowPlaying(ctx)
@@ -127,6 +136,64 @@ var _ = Describe("PlayTracker", func() {
})
})
+ Describe("Expiration events", func() {
+ It("sends event when entry expires", func() {
+ info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"}
+ _ = tracker.playMap.AddWithTTL("player-1", info, 10*time.Millisecond)
+ Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0))
+ eventList := eventBroker.getEvents()
+ evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount)
+ Expect(ok).To(BeTrue())
+ Expect(evt.Count).To(Equal(0))
+ })
+
+ It("does not send event when disabled", func() {
+ conf.Server.EnableNowPlaying = false
+ tracker = newPlayTracker(ds, eventBroker, nil)
+ info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"}
+ _ = tracker.playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
+ Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0))
+ })
+
+ It("sends expired playback report when session expires", func() {
+ info := PlaybackSession{
+ MediaFile: track,
+ Start: time.Now(),
+ UserId: "u-1",
+ Username: "user",
+ PlayerId: "player-3",
+ PlayerName: "test-player",
+ State: StatePlaying,
+ PositionMs: 5000,
+ }
+ _ = tracker.playMap.AddWithTTL("player-3", info, 10*time.Millisecond)
+ Eventually(func() *PlaybackSession {
+ return fake.LastPlaybackReport.Load()
+ }).ShouldNot(BeNil())
+ report := fake.LastPlaybackReport.Load()
+ Expect(report.State).To(Equal(StateExpired))
+ Expect(report.MediaFile.ID).To(Equal("123"))
+ Expect(report.PlayerId).To(Equal("player-3"))
+ })
+
+ It("does not send expired report when session was already stopped", func() {
+ info := PlaybackSession{
+ MediaFile: track,
+ Start: time.Now(),
+ UserId: "u-1",
+ Username: "user",
+ PlayerId: "player-4",
+ PlayerName: "test-player",
+ State: StateStopped,
+ PositionMs: 180000,
+ }
+ _ = tracker.playMap.AddWithTTL("player-4", info, 10*time.Millisecond)
+ Consistently(func() *PlaybackSession {
+ return fake.LastPlaybackReport.Load()
+ }).Should(BeNil())
+ })
+ })
+
Describe("Submit", func() {
It("sends track to agent", func() {
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
@@ -135,10 +202,12 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
Expect(err).ToNot(HaveOccurred())
- Expect(fake.ScrobbleCalled).To(BeTrue())
- Expect(fake.UserID).To(Equal("u-1"))
- Expect(fake.LastScrobble.ID).To(Equal("123"))
- Expect(fake.LastScrobble.Participants).To(Equal(track.Participants))
+ Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
+ Expect(fake.GetUserID()).To(Equal("u-1"))
+ lastScrobble := fake.LastScrobble.Load()
+ Expect(lastScrobble.TimeStamp).To(BeTemporally("~", ts, 1*time.Second))
+ Expect(lastScrobble.ID).To(Equal("123"))
+ Expect(lastScrobble.Participants).To(Equal(track.Participants))
})
It("increments play counts in the DB", func() {
@@ -162,7 +231,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
- Expect(fake.ScrobbleCalled).To(BeFalse())
+ Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
})
It("does not send track to agent if player is not enabled to send scrobbles", func() {
@@ -171,7 +240,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
- Expect(fake.ScrobbleCalled).To(BeFalse())
+ Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
})
It("does not send track to agent if artist is unknown", func() {
@@ -180,7 +249,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
- Expect(fake.ScrobbleCalled).To(BeFalse())
+ Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
})
It("increments play counts even if it cannot scrobble", func() {
@@ -189,7 +258,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
- Expect(fake.ScrobbleCalled).To(BeFalse())
+ Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
Expect(track.PlayCount).To(Equal(int64(1)))
Expect(album.PlayCount).To(Equal(int64(1)))
@@ -198,41 +267,876 @@ var _ = Describe("PlayTracker", func() {
Expect(artist1.PlayCount).To(Equal(int64(1)))
Expect(artist2.PlayCount).To(Equal(int64(1)))
})
+
+ Context("Scrobble History", func() {
+ It("records scrobble in repository", func() {
+ conf.Server.EnableScrobbleHistory = true
+ ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
+ ts := time.Now()
+
+ err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
+
+ Expect(err).ToNot(HaveOccurred())
+
+ mockDS := ds.(*tests.MockDataStore)
+ mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo)
+ Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1))
+ Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123"))
+ Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1"))
+ Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts))
+ })
+
+ It("does not record scrobble when history is disabled", func() {
+ conf.Server.EnableScrobbleHistory = false
+ ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
+ ts := time.Now()
+
+ err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
+
+ Expect(err).ToNot(HaveOccurred())
+ mockDS := ds.(*tests.MockDataStore)
+ mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo)
+ Expect(mockScrobble.RecordedScrobbles).To(HaveLen(0))
+ })
+ })
})
+ Describe("ReportPlayback", func() {
+ const defaultClientId = "client-1"
+
+ BeforeEach(func() {
+ ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: true})
+ })
+
+ It("creates entry on starting and removes on stopped", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].State).To(Equal("starting"))
+ Expect(playing[0].MediaFile.ID).To(Equal("123"))
+
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ IgnoreScrobble: true,
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ playing, err = tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(BeEmpty())
+ })
+
+ It("full lifecycle: starting -> playing -> paused -> playing -> stopped", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].State).To(Equal("playing"))
+ Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(10000)))
+
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ playing, err = tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing[0].State).To(Equal("paused"))
+ Expect(playing[0].PositionMs).To(Equal(int64(30000)))
+
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ playing, err = tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(BeEmpty())
+ })
+
+ It("starting replaces existing entry for same player", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].State).To(Equal("starting"))
+ Expect(playing[0].PositionMs).To(Equal(int64(0)))
+ })
+
+ It("multiple players have independent sessions", func() {
+ ctx1 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"})
+ ctx1 = request.WithPlayer(ctx1, model.Player{ID: "p1", ScrobbleEnabled: true})
+
+ ctx2 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"})
+ ctx2 = request.WithPlayer(ctx2, model.Player{ID: "p2", ScrobbleEnabled: true})
+
+ track2 := track
+ track2.ID = "456"
+ _ = ds.MediaFile(ctx).Put(&track2)
+
+ err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx2, ReportPlaybackParams{
+ MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-2",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(2))
+ })
+
+ Describe("SSE broadcast on state change", func() {
+ BeforeEach(func() {
+ eventBroker = &fakeEventBroker{}
+ tracker = newPlayTracker(ds, eventBroker, nil)
+ tracker.builtinScrobblers["fake"] = fake
+ })
+
+ It("broadcasts NowPlayingCount on every state change", func() {
+ // starting -> count should be 1
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ evts := eventBroker.getEvents()
+ Expect(evts).To(HaveLen(1))
+ Expect(evts[0].(*events.NowPlayingCount).Count).To(Equal(1))
+
+ // playing -> count should be 1
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ evts = eventBroker.getEvents()
+ Expect(evts).To(HaveLen(2))
+ Expect(evts[1].(*events.NowPlayingCount).Count).To(Equal(1))
+
+ // paused -> count should be 1
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ evts = eventBroker.getEvents()
+ Expect(evts).To(HaveLen(3))
+ Expect(evts[2].(*events.NowPlayingCount).Count).To(Equal(1))
+
+ // stopped -> count should be 0
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 30000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ IgnoreScrobble: true,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ evts = eventBroker.getEvents()
+ Expect(evts).To(HaveLen(4))
+ Expect(evts[3].(*events.NowPlayingCount).Count).To(Equal(0))
+ })
+
+ It("does NOT broadcast when EnableNowPlaying is false", func() {
+ conf.Server.EnableNowPlaying = false
+ tracker = newPlayTracker(ds, eventBroker, nil)
+ tracker.builtinScrobblers["fake"] = fake
+
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(eventBroker.getEvents()).To(BeEmpty())
+ })
+ })
+
+ Describe("auto-scrobble", func() {
+ It("scrobbles on stopped when positionMs >= 50% of track", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.PlayCount).To(Equal(int64(1)))
+ Expect(album.PlayCount).To(Equal(int64(1)))
+ Expect(artist1.PlayCount).To(Equal(int64(1)))
+ })
+
+ It("scrobbles on stopped when positionMs >= 4 min for long tracks", func() {
+ longTrack := model.MediaFile{
+ ID: "long", Title: "Long Song", Album: "Album", AlbumID: "al-1",
+ Duration: 600,
+ Participants: map[model.Role]model.ParticipantList{
+ model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")},
+ },
+ }
+ _ = ds.MediaFile(ctx).Put(&longTrack)
+
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "long", PositionMs: 240000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(longTrack.PlayCount).To(Equal(int64(1)))
+ })
+
+ It("does NOT scrobble when positionMs below threshold", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.PlayCount).To(Equal(int64(0)))
+ })
+
+ It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() {
+ fake.ScrobbleCalled.Store(false)
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ IgnoreScrobble: true,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.PlayCount).To(Equal(int64(0)))
+ Consistently(func() bool { return fake.ScrobbleCalled.Load() }).Should(BeFalse())
+ })
+
+ It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() {
+ ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false})
+
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.PlayCount).To(Equal(int64(0)))
+ })
+
+ It("scrobbles twice for two separate sessions of same song", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.PlayCount).To(Equal(int64(2)))
+ })
+
+ It("dispatches to external scrobblers on auto-scrobble", func() {
+ fake.ScrobbleCalled.Store(false)
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
+ })
+ })
+
+ Describe("position estimation", func() {
+ It("estimates position for playing state based on elapsed time", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ time.Sleep(50 * time.Millisecond)
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10000)))
+ })
+
+ It("does NOT estimate for paused", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ time.Sleep(50 * time.Millisecond)
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].PositionMs).To(Equal(int64(10000)))
+ })
+
+ It("does NOT estimate for starting", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ time.Sleep(50 * time.Millisecond)
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].PositionMs).To(Equal(int64(0)))
+ })
+
+ It("respects playbackRate", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 2.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ time.Sleep(100 * time.Millisecond)
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ // At 2x speed, 100ms real time = ~200ms playback time
+ Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10100)))
+ })
+
+ It("caps estimated position at track duration", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 179990, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ time.Sleep(50 * time.Millisecond)
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].PositionMs).To(Equal(int64(180000))) // track.Duration * 1000
+ })
+
+ })
+
+ Describe("resilience (no prior starting)", func() {
+ It("playing without prior starting creates entry with Start approx now - positionMs", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].State).To(Equal("playing"))
+ expectedStart := time.Now().Add(-30 * time.Second)
+ Expect(playing[0].Start).To(BeTemporally("~", expectedStart, 2*time.Second))
+ })
+
+ It("paused without prior starting creates entry", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].State).To(Equal("paused"))
+ })
+
+ It("stopped without prior starting auto-scrobbles if threshold met", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.PlayCount).To(Equal(int64(1)))
+ })
+
+ It("stopped without prior starting does NOT scrobble if below threshold", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.PlayCount).To(Equal(int64(0)))
+ })
+ })
+
+ Describe("external scrobbler dispatch", func() {
+ It("dispatches NowPlaying on starting", func() {
+ fake.nowPlayingCalled.Store(false)
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
+ })
+
+ It("dispatches NowPlaying on playing", func() {
+ fake.nowPlayingCalled.Store(false)
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
+ })
+
+ It("does NOT dispatch on paused", func() {
+ fake.nowPlayingCalled.Store(false)
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
+ })
+
+ It("still dispatches NowPlaying when ignoreScrobble=true", func() {
+ fake.nowPlayingCalled.Store(false)
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ IgnoreScrobble: true,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
+ })
+
+ It("does NOT dispatch when ScrobbleEnabled=false", func() {
+ fake.nowPlayingCalled.Store(false)
+ ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false})
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
+ })
+ })
+
+ Describe("PlaybackReport dispatch", func() {
+ It("dispatches PlaybackReport for starting state", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
+ ClientId: "client-1", ClientName: "Test Player",
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ Eventually(func() bool {
+ return fake.PlaybackReportCalled.Load()
+ }).Should(BeTrue())
+
+ info := fake.LastPlaybackReport.Load()
+ Expect(info).ToNot(BeNil())
+ Expect(info.MediaFile.ID).To(Equal("123"))
+ Expect(info.State).To(Equal(StateStarting))
+ Expect(info.PositionMs).To(Equal(int64(0)))
+ Expect(info.PlaybackRate).To(Equal(1.0))
+ Expect(info.PlayerId).To(Equal("client-1"))
+ Expect(info.PlayerName).To(Equal("Test Player"))
+ })
+
+ It("dispatches PlaybackReport for playing state", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
+ ClientId: "client-1", ClientName: "Test Player",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
+ fake.PlaybackReportCalled.Store(false)
+ fake.LastPlaybackReport.Store(nil)
+
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 30000, State: StatePlaying, PlaybackRate: 1.5,
+ ClientId: "client-1", ClientName: "Test Player",
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
+ info := fake.LastPlaybackReport.Load()
+ Expect(info.State).To(Equal(StatePlaying))
+ Expect(info.PositionMs).To(Equal(int64(30000)))
+ Expect(info.PlaybackRate).To(Equal(1.5))
+ })
+
+ It("dispatches PlaybackReport for paused state", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
+ ClientId: "client-1", ClientName: "Test Player",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
+ fake.PlaybackReportCalled.Store(false)
+ fake.LastPlaybackReport.Store(nil)
+
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 45000, State: StatePaused, PlaybackRate: 1.0,
+ ClientId: "client-1", ClientName: "Test Player",
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
+ info := fake.LastPlaybackReport.Load()
+ Expect(info.State).To(Equal(StatePaused))
+ Expect(info.PositionMs).To(Equal(int64(45000)))
+ })
+
+ It("dispatches PlaybackReport for stopped state", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
+ ClientId: "client-1", ClientName: "Test Player",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
+ fake.PlaybackReportCalled.Store(false)
+ fake.LastPlaybackReport.Store(nil)
+
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 100000, State: StateStopped, PlaybackRate: 1.0,
+ ClientId: "client-1", ClientName: "Test Player",
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
+ info := fake.LastPlaybackReport.Load()
+ Expect(info.State).To(Equal(StateStopped))
+ Expect(info.PositionMs).To(Equal(int64(100000)))
+ })
+ })
+ })
+
+ Describe("Plugin scrobbler logic", func() {
+ var pluginLoader *mockPluginLoader
+ var pluginFake *fakeScrobbler
+
+ BeforeEach(func() {
+ pluginFake = &fakeScrobbler{Authorized: true}
+ pluginLoader = &mockPluginLoader{
+ names: []string{"plugin1"},
+ scrobblers: map[string]Scrobbler{"plugin1": pluginFake},
+ }
+ tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader)
+
+ // Bypass buffering for both built-in and plugin scrobblers
+ tracker.builtinScrobblers["fake"] = fake
+ tracker.pluginScrobblers["plugin1"] = pluginFake
+ })
+
+ It("registers and uses plugin scrobbler for NowPlaying", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
+ })
+
+ It("removes plugin scrobbler if not present anymore", func() {
+ _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
+ })
+ Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
+ pluginFake.nowPlayingCalled.Store(false)
+ pluginLoader.SetNames([]string{})
+ _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
+ })
+ Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse())
+ })
+
+ It("calls both builtin and plugin scrobblers for NowPlaying", func() {
+ fake.nowPlayingCalled.Store(false)
+ pluginFake.nowPlayingCalled.Store(false)
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
+ Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
+ })
+
+ It("calls plugin scrobbler for Submit", func() {
+ ts := time.Now()
+ err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pluginFake.ScrobbleCalled.Load()).To(BeTrue())
+ })
+ })
+
+ Describe("Plugin Scrobbler Management", func() {
+ var pluginScr *fakeScrobbler
+ var mockPlugin *mockPluginLoader
+ var pTracker *playTracker
+ var mockedBS *mockBufferedScrobbler
+
+ BeforeEach(func() {
+ ctx = GinkgoT().Context()
+ ctx = request.WithUser(ctx, model.User{ID: "u-1"})
+ ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
+ ds = &tests.MockDataStore{}
+
+ // Setup plugin scrobbler
+ pluginScr = &fakeScrobbler{Authorized: true}
+ mockPlugin = &mockPluginLoader{
+ names: []string{"plugin1"},
+ scrobblers: map[string]Scrobbler{"plugin1": pluginScr},
+ }
+
+ // Create a tracker with the mock plugin loader
+ pTracker = newPlayTracker(ds, events.GetBroker(), mockPlugin)
+
+ // Create a mock buffered scrobbler and explicitly cast it to Scrobbler
+ mockedBS = &mockBufferedScrobbler{
+ wrapped: pluginScr,
+ }
+ // Make sure the instance is added with its concrete type preserved
+ pTracker.pluginScrobblers["plugin1"] = mockedBS
+ })
+
+ It("calls Stop on scrobblers when removing them", func() {
+ // Change the plugin names to simulate a plugin being removed
+ mockPlugin.SetNames([]string{})
+
+ // Call refreshPluginScrobblers which should detect the removed plugin
+ pTracker.refreshPluginScrobblers()
+
+ // Verify the Stop method was called
+ Expect(mockedBS.stopCalled).To(BeTrue())
+
+ // Verify the scrobbler was removed from the map
+ Expect(pTracker.pluginScrobblers).NotTo(HaveKey("plugin1"))
+ })
+ })
+
+ Describe("Plugin reload (config update) behavior", func() {
+ var mockPlugin *mockPluginLoader
+ var pTracker *playTracker
+ var originalScrobbler *fakeScrobbler
+ var reloadedScrobbler *fakeScrobbler
+
+ BeforeEach(func() {
+ ctx = GinkgoT().Context()
+ ctx = request.WithUser(ctx, model.User{ID: "u-1"})
+ ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
+ ds = &tests.MockDataStore{}
+
+ // Setup initial plugin scrobbler
+ originalScrobbler = &fakeScrobbler{Authorized: true}
+ reloadedScrobbler = &fakeScrobbler{Authorized: true}
+
+ mockPlugin = &mockPluginLoader{
+ names: []string{"plugin1"},
+ scrobblers: map[string]Scrobbler{"plugin1": originalScrobbler},
+ }
+
+ // Create tracker - this will create buffered scrobblers with loaders
+ pTracker = newPlayTracker(ds, events.GetBroker(), mockPlugin)
+
+ // Trigger initial plugin registration
+ pTracker.refreshPluginScrobblers()
+ })
+
+ AfterEach(func() {
+ pTracker.stopBackgroundWorkers()
+ })
+
+ It("uses the new plugin instance after reload (simulating config update)", func() {
+ // First call should use the original scrobbler
+ scrobblers := pTracker.getActiveScrobblers()
+ pluginScr := scrobblers["plugin1"]
+ Expect(pluginScr).ToNot(BeNil())
+
+ err := pluginScr.NowPlaying(ctx, "u-1", &track, 0)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(originalScrobbler.GetNowPlayingCalled()).To(BeTrue())
+ Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeFalse())
+
+ // Simulate plugin reload (config update): replace the scrobbler in the loader
+ // This is what happens when UpdatePluginConfig is called - the plugin manager
+ // unloads the old plugin and loads a new instance
+ mockPlugin.mu.Lock()
+ mockPlugin.scrobblers["plugin1"] = reloadedScrobbler
+ mockPlugin.mu.Unlock()
+
+ // Reset call tracking
+ originalScrobbler.nowPlayingCalled.Store(false)
+
+ // Get scrobblers again - should still return the same buffered scrobbler
+ // but subsequent calls should use the new plugin instance via the loader
+ scrobblers = pTracker.getActiveScrobblers()
+ pluginScr = scrobblers["plugin1"]
+
+ err = pluginScr.NowPlaying(ctx, "u-1", &track, 0)
+ Expect(err).ToNot(HaveOccurred())
+
+ // The new scrobbler should be called, not the old one
+ Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeTrue())
+ Expect(originalScrobbler.GetNowPlayingCalled()).To(BeFalse())
+ })
+
+ It("handles plugin becoming unavailable temporarily", func() {
+ // First verify plugin works
+ scrobblers := pTracker.getActiveScrobblers()
+ pluginScr := scrobblers["plugin1"]
+
+ err := pluginScr.NowPlaying(ctx, "u-1", &track, 0)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(originalScrobbler.GetNowPlayingCalled()).To(BeTrue())
+
+ // Simulate plugin becoming unavailable (e.g., during reload)
+ mockPlugin.mu.Lock()
+ delete(mockPlugin.scrobblers, "plugin1")
+ mockPlugin.mu.Unlock()
+
+ originalScrobbler.nowPlayingCalled.Store(false)
+
+ // NowPlaying should return error when plugin unavailable
+ err = pluginScr.NowPlaying(ctx, "u-1", &track, 0)
+ Expect(err).To(HaveOccurred())
+ Expect(originalScrobbler.GetNowPlayingCalled()).To(BeFalse())
+
+ // Simulate plugin becoming available again
+ mockPlugin.mu.Lock()
+ mockPlugin.scrobblers["plugin1"] = reloadedScrobbler
+ mockPlugin.mu.Unlock()
+
+ // Should work again with new instance
+ err = pluginScr.NowPlaying(ctx, "u-1", &track, 0)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeTrue())
+ })
+
+ It("IsAuthorized uses the current plugin instance", func() {
+ scrobblers := pTracker.getActiveScrobblers()
+ pluginScr := scrobblers["plugin1"]
+
+ // Original is authorized
+ Expect(pluginScr.IsAuthorized(ctx, "u-1")).To(BeTrue())
+
+ // Replace with unauthorized scrobbler
+ unauthorizedScrobbler := &fakeScrobbler{Authorized: false}
+ mockPlugin.mu.Lock()
+ mockPlugin.scrobblers["plugin1"] = unauthorizedScrobbler
+ mockPlugin.mu.Unlock()
+
+ // Should reflect the new scrobbler's authorization status
+ Expect(pluginScr.IsAuthorized(ctx, "u-1")).To(BeFalse())
+ })
+ })
})
+var _ = DescribeTable("remainingTTL",
+ func(durationSec float32, positionMs int64, rate float64, expected time.Duration) {
+ Expect(remainingTTL(durationSec, positionMs, rate)).To(Equal(expected))
+ },
+ Entry("full track at 1x", float32(300), int64(0), 1.0, 305*time.Second),
+ Entry("halfway through at 1x", float32(300), int64(150000), 1.0, 155*time.Second),
+ Entry("near end at 1x", float32(300), int64(298000), 1.0, 7*time.Second),
+ Entry("at end of track", float32(300), int64(300000), 1.0, 5*time.Second),
+ Entry("past end of track", float32(300), int64(310000), 1.0, 5*time.Second),
+ Entry("2x speed halves remaining time", float32(300), int64(0), 2.0, 155*time.Second),
+ Entry("2x speed halfway", float32(300), int64(150000), 2.0, 80*time.Second),
+ Entry("0.5x speed doubles remaining time", float32(300), int64(0), 0.5, 605*time.Second),
+ Entry("zero rate defaults to 1x", float32(300), int64(0), 0.0, 305*time.Second),
+ Entry("negative rate defaults to 1x", float32(300), int64(0), -1.0, 305*time.Second),
+ Entry("short track", float32(3.5), int64(0), 1.0, 8*time.Second),
+ Entry("zero duration", float32(0), int64(0), 1.0, 5*time.Second),
+)
+
type fakeScrobbler struct {
- Authorized bool
- NowPlayingCalled bool
- ScrobbleCalled bool
- UserID string
- Track *model.MediaFile
- LastScrobble Scrobble
- Error error
+ Authorized bool
+ nowPlayingCalled atomic.Bool
+ ScrobbleCalled atomic.Bool
+ PlaybackReportCalled atomic.Bool
+ userID atomic.Pointer[string]
+ username atomic.Pointer[string]
+ track atomic.Pointer[model.MediaFile]
+ position atomic.Int32
+ LastScrobble atomic.Pointer[Scrobble]
+ LastPlaybackReport atomic.Pointer[PlaybackSession]
+ Error error
+}
+
+func (f *fakeScrobbler) GetNowPlayingCalled() bool {
+ return f.nowPlayingCalled.Load()
+}
+
+func (f *fakeScrobbler) GetUserID() string {
+ if p := f.userID.Load(); p != nil {
+ return *p
+ }
+ return ""
+}
+
+func (f *fakeScrobbler) GetTrack() *model.MediaFile {
+ return f.track.Load()
}
func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return f.Error == nil && f.Authorized
}
-func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
- f.NowPlayingCalled = true
+func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
+ f.nowPlayingCalled.Store(true)
if f.Error != nil {
return f.Error
}
- f.UserID = userId
- f.Track = track
+ f.userID.Store(&userId)
+ // Capture username from context (this is what plugin scrobblers do)
+ username, _ := request.UsernameFrom(ctx)
+ if username == "" {
+ if u, ok := request.UserFrom(ctx); ok {
+ username = u.UserName
+ }
+ }
+ if username != "" {
+ f.username.Store(&username)
+ }
+ f.track.Store(track)
+ f.position.Store(int32(position))
return nil
}
func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
- f.ScrobbleCalled = true
+ f.userID.Store(&userId)
+ f.LastScrobble.Store(&s)
+ f.ScrobbleCalled.Store(true)
if f.Error != nil {
return f.Error
}
- f.UserID = userId
- f.LastScrobble = s
+ return nil
+}
+
+func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
+ f.PlaybackReportCalled.Store(true)
+ if f.Error != nil {
+ return f.Error
+ }
+ f.userID.Store(new(info.UserId))
+ f.LastPlaybackReport.Store(&info)
return nil
}
@@ -243,3 +1147,55 @@ func _p(id, name string, sortName ...string) model.Participant {
}
return p
}
+
+type fakeEventBroker struct {
+ http.Handler
+ events []events.Event
+ mu sync.Mutex
+}
+
+func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.events = append(f.events, event)
+}
+
+func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.events = append(f.events, event)
+}
+
+func (f *fakeEventBroker) getEvents() []events.Event {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.events
+}
+
+var _ events.Broker = (*fakeEventBroker)(nil)
+
+// mockBufferedScrobbler used to test that Stop is called
+type mockBufferedScrobbler struct {
+ wrapped Scrobbler
+ stopCalled bool
+}
+
+func (m *mockBufferedScrobbler) Stop() {
+ m.stopCalled = true
+}
+
+func (m *mockBufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
+ return m.wrapped.IsAuthorized(ctx, userId)
+}
+
+func (m *mockBufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
+ return m.wrapped.NowPlaying(ctx, userId, track, position)
+}
+
+func (m *mockBufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
+ return m.wrapped.Scrobble(ctx, userId, s)
+}
+
+func (m *mockBufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
+ return m.wrapped.PlaybackReport(ctx, info)
+}
diff --git a/core/scrobbler/playbackreport_worker.go b/core/scrobbler/playbackreport_worker.go
new file mode 100644
index 000000000..78ca6e0f7
--- /dev/null
+++ b/core/scrobbler/playbackreport_worker.go
@@ -0,0 +1,64 @@
+package scrobbler
+
+import (
+ "context"
+
+ "github.com/navidrome/navidrome/log"
+)
+
+func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) {
+ p.prMu.Lock()
+ defer p.prMu.Unlock()
+ ctx = context.WithoutCancel(ctx)
+ p.prQueue = append(p.prQueue, playbackReportEntry{
+ ctx: ctx,
+ info: info,
+ })
+ p.sendPlaybackReportSignal()
+}
+
+func (p *playTracker) sendPlaybackReportSignal() {
+ select {
+ case p.prSignal <- struct{}{}:
+ default:
+ }
+}
+
+func (p *playTracker) playbackReportWorker() {
+ defer close(p.prWorkerDone)
+ for {
+ select {
+ case <-p.shutdown:
+ return
+ case <-p.prSignal:
+ }
+
+ p.prMu.Lock()
+ if len(p.prQueue) == 0 {
+ p.prMu.Unlock()
+ continue
+ }
+ entries := p.prQueue
+ p.prQueue = nil
+ p.prMu.Unlock()
+
+ allScrobblers := p.getActiveScrobblers()
+ for _, entry := range entries {
+ p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers)
+ }
+ }
+}
+
+func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) {
+ for name, s := range allScrobblers {
+ if !s.IsAuthorized(ctx, info.UserId) {
+ continue
+ }
+ log.Debug(ctx, "Sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, "positionMs", info.PositionMs)
+ err := s.PlaybackReport(ctx, info)
+ if err != nil {
+ log.Error(ctx, "Error sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, err)
+ continue
+ }
+ }
+}
diff --git a/core/share.go b/core/share.go
index add88322d..5a611c7f0 100644
--- a/core/share.go
+++ b/core/share.go
@@ -7,12 +7,13 @@ import (
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
- gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
+ "github.com/navidrome/navidrome/utils/nanoid"
"github.com/navidrome/navidrome/utils/slice"
+ "github.com/navidrome/navidrome/utils/str"
)
type Share interface {
@@ -40,7 +41,7 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error
if !expiresAt.IsZero() && expiresAt.Before(time.Now()) {
return nil, model.ErrExpired
}
- share.LastVisitedAt = P(time.Now())
+ share.LastVisitedAt = new(time.Now())
share.VisitCount++
err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count")
@@ -72,7 +73,7 @@ type shareRepositoryWrapper struct {
func (r *shareRepositoryWrapper) newId() (string, error) {
for {
- id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10)
+ id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10)
if err != nil {
return "", err
}
@@ -86,7 +87,7 @@ func (r *shareRepositoryWrapper) newId() (string, error) {
}
}
-func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
+func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
s := entity.(*model.Share)
id, err := r.newId()
if err != nil {
@@ -94,10 +95,10 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
}
s.ID = id
if V(s.ExpiresAt).IsZero() {
- s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration))
+ s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration))
}
- firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
+ firstId, _, _ := strings.Cut(s.ResourceIDs, ",")
v, err := model.GetEntityByID(r.ctx, r.ds, firstId)
if err != nil {
return "", err
@@ -119,15 +120,14 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
log.Error(r.ctx, "Invalid Resource ID", "id", firstId)
return "", model.ErrNotFound
}
- if len(s.Contents) > 30 {
- s.Contents = s.Contents[:26] + "..."
- }
+
+ s.Contents = str.TruncateRunes(s.Contents, 30, "...")
id, err = r.Persistable.Save(s)
return id, err
}
-func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
+func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error {
cols := []string{"description", "downloadable"}
// TODO Better handling of Share expiration
@@ -149,7 +149,7 @@ func (r *shareRepositoryWrapper) contentsLabelFromArtist(shareID string, ids str
func (r *shareRepositoryWrapper) contentsLabelFromAlbums(shareID string, ids string) string {
idList := strings.Split(ids, ",")
- all, err := r.ds.Album(r.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": idList}})
+ all, err := r.ds.Album(r.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.id": idList}})
if err != nil {
log.Error(r.ctx, "Error retrieving album names for share", "share", shareID, err)
return ""
diff --git a/core/share_test.go b/core/share_test.go
index 21069bb59..475d40ec9 100644
--- a/core/share_test.go
+++ b/core/share_test.go
@@ -38,6 +38,38 @@ var _ = Describe("Share", func() {
Expect(id).ToNot(BeEmpty())
Expect(entity.ID).To(Equal(id))
})
+
+ It("does not truncate ASCII labels shorter than 30 characters", func() {
+ _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"})
+ entity := &model.Share{Description: "test", ResourceIDs: "456"}
+ _, err := repo.Save(entity)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(entity.Contents).To(Equal("Example Media File"))
+ })
+
+ It("truncates ASCII labels longer than 30 characters", func() {
+ _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"})
+ entity := &model.Share{Description: "test", ResourceIDs: "789"}
+ _, err := repo.Save(entity)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(entity.Contents).To(Equal("Example Media File But The ..."))
+ })
+
+ It("does not truncate CJK labels shorter than 30 runes", func() {
+ _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"})
+ entity := &model.Share{Description: "test", ResourceIDs: "456"}
+ _, err := repo.Save(entity)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(entity.Contents).To(Equal("青春コンプレックス"))
+ })
+
+ It("truncates CJK labels longer than 30 runes", func() {
+ _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"})
+ entity := &model.Share{Description: "test", ResourceIDs: "789"}
+ _, err := repo.Save(entity)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で..."))
+ })
})
Describe("Update", func() {
diff --git a/core/sonic/sonic.go b/core/sonic/sonic.go
new file mode 100644
index 000000000..19eb69c65
--- /dev/null
+++ b/core/sonic/sonic.go
@@ -0,0 +1,130 @@
+package sonic
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/navidrome/navidrome/core/agents"
+ "github.com/navidrome/navidrome/core/matcher"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+)
+
+const capabilitySonicSimilarity = "SonicSimilarity"
+
+type SimilarResult struct {
+ Song agents.Song
+ Similarity float64
+}
+
+type SimilarMatch struct {
+ MediaFile model.MediaFile
+ Similarity float64
+}
+
+type Provider interface {
+ GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]SimilarResult, error)
+ FindSonicPath(ctx context.Context, startMF, endMF *model.MediaFile, count int) ([]SimilarResult, error)
+}
+
+type PluginLoader interface {
+ PluginNames(capability string) []string
+ LoadSonicSimilarity(name string) (Provider, bool)
+}
+
+type Sonic struct {
+ ds model.DataStore
+ pluginLoader PluginLoader
+ matcher *matcher.Matcher
+}
+
+func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher) *Sonic {
+ return &Sonic{
+ ds: ds,
+ pluginLoader: pluginLoader,
+ matcher: matcher,
+ }
+}
+
+func (s *Sonic) HasProvider() bool {
+ return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0
+}
+
+func (s *Sonic) loadProvider() (Provider, error) {
+ names := s.pluginLoader.PluginNames(capabilitySonicSimilarity)
+ if len(names) == 0 {
+ return nil, model.ErrNotFound
+ }
+ provider, ok := s.pluginLoader.LoadSonicSimilarity(names[0])
+ if !ok {
+ return nil, model.ErrNotFound
+ }
+ return provider, nil
+}
+
+func (s *Sonic) resolveMatches(ctx context.Context, results []SimilarResult) ([]SimilarMatch, error) {
+ songs := make([]agents.Song, len(results))
+ for i, r := range results {
+ songs[i] = r.Song
+ }
+
+ matchMap, err := s.matcher.MatchSongsIndexed(ctx, songs)
+ if err != nil {
+ return nil, fmt.Errorf("matching songs to library: %w", err)
+ }
+
+ var matches []SimilarMatch
+ for i, r := range results {
+ if mf, ok := matchMap[i]; ok {
+ matches = append(matches, SimilarMatch{
+ MediaFile: mf,
+ Similarity: r.Similarity,
+ })
+ }
+ }
+ return matches, nil
+}
+
+func (s *Sonic) GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) {
+ provider, err := s.loadProvider()
+ if err != nil {
+ return nil, err
+ }
+
+ mf, err := s.ds.MediaFile(ctx).Get(id)
+ if err != nil {
+ return nil, fmt.Errorf("getting media file %s: %w", id, err)
+ }
+
+ results, err := provider.GetSonicSimilarTracks(ctx, mf, count)
+ if err != nil {
+ log.Error(ctx, "Plugin GetSonicSimilarTracks failed", "id", id, err)
+ return nil, err
+ }
+
+ return s.resolveMatches(ctx, results)
+}
+
+func (s *Sonic) FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) {
+ provider, err := s.loadProvider()
+ if err != nil {
+ return nil, err
+ }
+
+ startMF, err := s.ds.MediaFile(ctx).Get(startID)
+ if err != nil {
+ return nil, fmt.Errorf("getting start media file %s: %w", startID, err)
+ }
+ endMF, err := s.ds.MediaFile(ctx).Get(endID)
+ if err != nil {
+ return nil, fmt.Errorf("getting end media file %s: %w", endID, err)
+ }
+
+ results, err := provider.FindSonicPath(ctx, startMF, endMF, count)
+ if err != nil {
+ log.Error(ctx, "Plugin FindSonicPath failed", "startId", startID, "endId", endID, err)
+ return nil, err
+ }
+
+ return s.resolveMatches(ctx, results)
+}
diff --git a/core/sonic/sonic_suite_test.go b/core/sonic/sonic_suite_test.go
new file mode 100644
index 000000000..7058e5de9
--- /dev/null
+++ b/core/sonic/sonic_suite_test.go
@@ -0,0 +1,17 @@
+package sonic_test
+
+import (
+ "testing"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestSonic(t *testing.T) {
+ tests.Init(t, false)
+ log.SetLevel(log.LevelFatal)
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Sonic Suite")
+}
diff --git a/core/sonic/sonic_test.go b/core/sonic/sonic_test.go
new file mode 100644
index 000000000..81739b726
--- /dev/null
+++ b/core/sonic/sonic_test.go
@@ -0,0 +1,146 @@
+package sonic_test
+
+import (
+ "context"
+ "errors"
+
+ "github.com/navidrome/navidrome/core/agents"
+ "github.com/navidrome/navidrome/core/matcher"
+ "github.com/navidrome/navidrome/core/sonic"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+type mockPluginLoader struct {
+ names []string
+ provider sonic.Provider
+ loadOk bool
+}
+
+func (m *mockPluginLoader) PluginNames(capability string) []string {
+ if capability == "SonicSimilarity" {
+ return m.names
+ }
+ return nil
+}
+
+func (m *mockPluginLoader) LoadSonicSimilarity(name string) (sonic.Provider, bool) {
+ return m.provider, m.loadOk
+}
+
+type mockProvider struct {
+ similarResults []sonic.SimilarResult
+ similarErr error
+ pathResults []sonic.SimilarResult
+ pathErr error
+}
+
+func (m *mockProvider) GetSonicSimilarTracks(_ context.Context, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) {
+ return m.similarResults, m.similarErr
+}
+
+func (m *mockProvider) FindSonicPath(_ context.Context, _, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) {
+ return m.pathResults, m.pathErr
+}
+
+var _ = Describe("Sonic", func() {
+ var (
+ ctx context.Context
+ ds *tests.MockDataStore
+ loader *mockPluginLoader
+ service *sonic.Sonic
+ )
+
+ BeforeEach(func() {
+ ctx = GinkgoT().Context()
+ ds = &tests.MockDataStore{}
+ loader = &mockPluginLoader{}
+ })
+
+ Describe("HasProvider", func() {
+ It("returns false when no plugins available", func() {
+ loader.names = nil
+ service = sonic.New(ds, loader, nil)
+ Expect(service.HasProvider()).To(BeFalse())
+ })
+
+ It("returns true when a plugin is available", func() {
+ loader.names = []string{"test-plugin"}
+ service = sonic.New(ds, loader, nil)
+ Expect(service.HasProvider()).To(BeTrue())
+ })
+ })
+
+ Describe("GetSonicSimilarTracks", func() {
+ It("returns error when no plugin available", func() {
+ loader.names = nil
+ service = sonic.New(ds, loader, nil)
+ _, err := service.GetSonicSimilarTracks(ctx, "song-1", 10)
+ Expect(err).To(MatchError(model.ErrNotFound))
+ })
+
+ It("returns error when media file not found", func() {
+ loader.names = []string{"test-plugin"}
+ loader.provider = &mockProvider{}
+ loader.loadOk = true
+ ds.MockedMediaFile = &tests.MockMediaFileRepo{}
+ service = sonic.New(ds, loader, matcher.New(ds))
+ _, err := service.GetSonicSimilarTracks(ctx, "nonexistent", 10)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("returns matched results from plugin", func() {
+ mf1 := model.MediaFile{ID: "song-1", Title: "Test Song", Artist: "Test Artist"}
+ mf2 := model.MediaFile{ID: "song-2", Title: "Similar Song", Artist: "Test Artist"}
+
+ mockRepo := tests.CreateMockMediaFileRepo()
+ mockRepo.SetData(model.MediaFiles{mf1, mf2})
+ ds.MockedMediaFile = mockRepo
+
+ provider := &mockProvider{
+ similarResults: []sonic.SimilarResult{
+ {Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85},
+ },
+ }
+ loader.names = []string{"test-plugin"}
+ loader.provider = provider
+ loader.loadOk = true
+
+ service = sonic.New(ds, loader, matcher.New(ds))
+ matches, err := service.GetSonicSimilarTracks(ctx, "song-1", 10)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(matches).To(HaveLen(1))
+ Expect(matches[0].MediaFile.ID).To(Equal("song-2"))
+ Expect(matches[0].Similarity).To(Equal(0.85))
+ })
+ })
+
+ Describe("FindSonicPath", func() {
+ It("returns error when no plugin available", func() {
+ loader.names = nil
+ service = sonic.New(ds, loader, nil)
+ _, err := service.FindSonicPath(ctx, "song-1", "song-2", 25)
+ Expect(err).To(MatchError(model.ErrNotFound))
+ })
+
+ It("returns error when plugin call fails", func() {
+ mf1 := model.MediaFile{ID: "song-1", Title: "Start", Artist: "Artist"}
+ mf2 := model.MediaFile{ID: "song-2", Title: "End", Artist: "Artist"}
+
+ mockRepo := tests.CreateMockMediaFileRepo()
+ mockRepo.SetData(model.MediaFiles{mf1, mf2})
+ ds.MockedMediaFile = mockRepo
+
+ provider := &mockProvider{pathErr: errors.New("plugin error")}
+ loader.names = []string{"test-plugin"}
+ loader.provider = provider
+ loader.loadOk = true
+
+ service = sonic.New(ds, loader, matcher.New(ds))
+ _, err := service.FindSonicPath(ctx, "song-1", "song-2", 25)
+ Expect(err).To(HaveOccurred())
+ })
+ })
+})
diff --git a/core/storage/local/local.go b/core/storage/local/local.go
index 5c335ddb9..5384581e0 100644
--- a/core/storage/local/local.go
+++ b/core/storage/local/local.go
@@ -11,6 +11,7 @@ import (
"github.com/djherbis/times"
"github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
@@ -28,7 +29,13 @@ type localStorage struct {
func newLocalStorage(u url.URL) storage.Storage {
newExtractor, ok := extractors[conf.Server.Scanner.Extractor]
if !ok || newExtractor == nil {
- log.Fatal("Extractor not found", "path", conf.Server.Scanner.Extractor)
+ if conf.Server.Scanner.Extractor != consts.DefaultScannerExtractor {
+ log.Warn("Extractor not found, using default", "extractor", conf.Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
+ }
+ newExtractor = extractors[consts.DefaultScannerExtractor]
+ if newExtractor == nil {
+ log.Fatal("Default extractor not registered", "extractor", consts.DefaultScannerExtractor)
+ }
}
isWindowsPath := filepath.VolumeName(u.Host) != ""
if u.Scheme == storage.LocalSchemaID && isWindowsPath {
@@ -44,7 +51,7 @@ func newLocalStorage(u url.URL) storage.Storage {
func (s *localStorage) FS() (storage.MusicFS, error) {
path := s.u.Path
- if _, err := os.Stat(path); err != nil {
+ if _, err := os.Stat(path); err != nil { //nolint:gosec
return nil, fmt.Errorf("%w: %s", err, path)
}
return &localFS{FS: os.DirFS(path), extractor: s.extractor}, nil
diff --git a/core/storage/local/local_suite_test.go b/core/storage/local/local_suite_test.go
index 98dfcbd4b..5934cde5d 100644
--- a/core/storage/local/local_suite_test.go
+++ b/core/storage/local/local_suite_test.go
@@ -3,11 +3,15 @@ package local
import (
"testing"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestLocal(t *testing.T) {
+ tests.Init(t, false)
+ log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
- RunSpecs(t, "Local Storage Test Suite")
+ RunSpecs(t, "Local Storage Suite")
}
diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go
new file mode 100644
index 000000000..aef89cdd5
--- /dev/null
+++ b/core/storage/local/local_test.go
@@ -0,0 +1,455 @@
+package local
+
+import (
+ "io/fs"
+ "net/url"
+ "os"
+ "path/filepath"
+ "runtime"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/core/storage"
+ "github.com/navidrome/navidrome/model/metadata"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("LocalStorage", func() {
+ var tempDir string
+ var testExtractor *mockTestExtractor
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+
+ // Create a temporary directory for testing
+ var err error
+ tempDir, err = os.MkdirTemp("", "navidrome-local-storage-test-")
+ Expect(err).ToNot(HaveOccurred())
+
+ DeferCleanup(func() {
+ os.RemoveAll(tempDir)
+ })
+
+ // Create and register a test extractor
+ testExtractor = &mockTestExtractor{
+ results: make(map[string]metadata.Info),
+ }
+ RegisterExtractor("test", func(fs.FS, string) Extractor {
+ return testExtractor
+ })
+ conf.Server.Scanner.Extractor = "test"
+ })
+
+ Describe("newLocalStorage", func() {
+ BeforeEach(func() {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
+ })
+
+ Context("with valid path", func() {
+ It("should create a localStorage instance with correct path", func() {
+ u, err := url.Parse("file://" + tempDir)
+ Expect(err).ToNot(HaveOccurred())
+
+ storage := newLocalStorage(*u)
+ localStorage := storage.(*localStorage)
+
+ Expect(localStorage.u.Scheme).To(Equal("file"))
+ // Check that the path is set correctly (could be resolved to real path on macOS)
+ Expect(localStorage.u.Path).To(ContainSubstring("navidrome-local-storage-test"))
+ Expect(localStorage.resolvedPath).To(ContainSubstring("navidrome-local-storage-test"))
+ Expect(localStorage.extractor).ToNot(BeNil())
+ })
+
+ It("should handle URL-decoded paths correctly", func() {
+ // Create a directory with spaces to test URL decoding
+ spacedDir := filepath.Join(tempDir, "test folder")
+ err := os.MkdirAll(spacedDir, 0755)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Use proper URL construction instead of manual escaping
+ u := &url.URL{
+ Scheme: "file",
+ Path: spacedDir,
+ }
+
+ storage := newLocalStorage(*u)
+ localStorage, ok := storage.(*localStorage)
+ Expect(ok).To(BeTrue())
+
+ Expect(localStorage.u.Path).To(Equal(spacedDir))
+ })
+
+ It("should resolve symlinks when possible", func() {
+ // Create a real directory and a symlink to it
+ realDir := filepath.Join(tempDir, "real")
+ linkDir := filepath.Join(tempDir, "link")
+
+ err := os.MkdirAll(realDir, 0755)
+ Expect(err).ToNot(HaveOccurred())
+
+ err = os.Symlink(realDir, linkDir)
+ Expect(err).ToNot(HaveOccurred())
+
+ u, err := url.Parse("file://" + linkDir)
+ Expect(err).ToNot(HaveOccurred())
+
+ storage := newLocalStorage(*u)
+ localStorage, ok := storage.(*localStorage)
+ Expect(ok).To(BeTrue())
+
+ Expect(localStorage.u.Path).To(Equal(linkDir))
+ // Check that the resolved path contains the real directory name
+ Expect(localStorage.resolvedPath).To(ContainSubstring("real"))
+ })
+
+ It("should use u.Path as resolvedPath when symlink resolution fails", func() {
+ // Use a non-existent path to trigger symlink resolution failure
+ nonExistentPath := filepath.Join(tempDir, "non-existent")
+
+ u, err := url.Parse("file://" + nonExistentPath)
+ Expect(err).ToNot(HaveOccurred())
+
+ storage := newLocalStorage(*u)
+ localStorage, ok := storage.(*localStorage)
+ Expect(ok).To(BeTrue())
+
+ Expect(localStorage.u.Path).To(Equal(nonExistentPath))
+ Expect(localStorage.resolvedPath).To(Equal(nonExistentPath))
+ })
+ })
+
+ Context("with Windows path", func() {
+ BeforeEach(func() {
+ if runtime.GOOS != "windows" {
+ Skip("Windows-specific test")
+ }
+ })
+
+ It("should handle Windows drive letters correctly", func() {
+ u, err := url.Parse("file://C:/music")
+ Expect(err).ToNot(HaveOccurred())
+
+ storage := newLocalStorage(*u)
+ localStorage, ok := storage.(*localStorage)
+ Expect(ok).To(BeTrue())
+
+ Expect(localStorage.u.Path).To(Equal("C:/music"))
+ })
+ })
+
+ Context("when the configured extractor is not registered", func() {
+ var defaultExtractor *mockTestExtractor
+
+ BeforeEach(func() {
+ defaultExtractor = &mockTestExtractor{results: make(map[string]metadata.Info)}
+ RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) Extractor {
+ return defaultExtractor
+ })
+ DeferCleanup(func() {
+ lock.Lock()
+ delete(extractors, consts.DefaultScannerExtractor)
+ lock.Unlock()
+ })
+ })
+
+ It("falls back to the default extractor instead of crashing", func() {
+ conf.Server.Scanner.Extractor = "nonexistent-extractor"
+
+ u, err := url.Parse("file://" + tempDir)
+ Expect(err).ToNot(HaveOccurred())
+
+ storage := newLocalStorage(*u)
+ ls, ok := storage.(*localStorage)
+ Expect(ok).To(BeTrue())
+ Expect(ls.extractor).To(BeIdenticalTo(defaultExtractor))
+ })
+ })
+ })
+
+ Describe("localStorage.FS", func() {
+ BeforeEach(func() {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
+ })
+
+ Context("with existing directory", func() {
+ It("should return a localFS instance", func() {
+ u, err := url.Parse("file://" + tempDir)
+ Expect(err).ToNot(HaveOccurred())
+
+ storage := newLocalStorage(*u)
+ musicFS, err := storage.FS()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(musicFS).ToNot(BeNil())
+
+ _, ok := musicFS.(*localFS)
+ Expect(ok).To(BeTrue())
+ })
+ })
+
+ Context("with non-existent directory", func() {
+ It("should return an error", func() {
+ nonExistentPath := filepath.Join(tempDir, "non-existent")
+ u, err := url.Parse("file://" + nonExistentPath)
+ Expect(err).ToNot(HaveOccurred())
+
+ storage := newLocalStorage(*u)
+ _, err = storage.FS()
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring(nonExistentPath))
+ })
+ })
+ })
+
+ Describe("localFS.ReadTags", func() {
+ var testFile string
+
+ BeforeEach(func() {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
+ // Create a test file
+ testFile = filepath.Join(tempDir, "test.mp3")
+ err := os.WriteFile(testFile, []byte("test data"), 0600)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Reset extractor state
+ testExtractor.results = make(map[string]metadata.Info)
+ testExtractor.err = nil
+ })
+
+ Context("when extractor returns complete metadata", func() {
+ It("should return the metadata as-is", func() {
+ expectedInfo := metadata.Info{
+ Tags: map[string][]string{
+ "title": {"Test Song"},
+ "artist": {"Test Artist"},
+ },
+ AudioProperties: metadata.AudioProperties{
+ Duration: 180,
+ BitRate: 320,
+ },
+ FileInfo: &testFileInfo{name: "test.mp3"},
+ }
+
+ testExtractor.results["test.mp3"] = expectedInfo
+
+ u, err := url.Parse("file://" + tempDir)
+ Expect(err).ToNot(HaveOccurred())
+ storage := newLocalStorage(*u)
+ musicFS, err := storage.FS()
+ Expect(err).ToNot(HaveOccurred())
+
+ results, err := musicFS.ReadTags("test.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveKey("test.mp3"))
+ Expect(results["test.mp3"]).To(Equal(expectedInfo))
+ })
+ })
+
+ Context("when extractor returns metadata without FileInfo", func() {
+ It("should populate FileInfo from filesystem", func() {
+ incompleteInfo := metadata.Info{
+ Tags: map[string][]string{
+ "title": {"Test Song"},
+ },
+ FileInfo: nil, // Missing FileInfo
+ }
+
+ testExtractor.results["test.mp3"] = incompleteInfo
+
+ u, err := url.Parse("file://" + tempDir)
+ Expect(err).ToNot(HaveOccurred())
+ storage := newLocalStorage(*u)
+ musicFS, err := storage.FS()
+ Expect(err).ToNot(HaveOccurred())
+
+ results, err := musicFS.ReadTags("test.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveKey("test.mp3"))
+
+ result := results["test.mp3"]
+ Expect(result.FileInfo).ToNot(BeNil())
+ Expect(result.FileInfo.Name()).To(Equal("test.mp3"))
+
+ // Should be wrapped in localFileInfo
+ _, ok := result.FileInfo.(localFileInfo)
+ Expect(ok).To(BeTrue())
+ })
+ })
+
+ Context("when filesystem stat fails", func() {
+ It("should return an error", func() {
+ incompleteInfo := metadata.Info{
+ Tags: map[string][]string{"title": {"Test Song"}},
+ FileInfo: nil,
+ }
+
+ testExtractor.results["non-existent.mp3"] = incompleteInfo
+
+ u, err := url.Parse("file://" + tempDir)
+ Expect(err).ToNot(HaveOccurred())
+ storage := newLocalStorage(*u)
+ musicFS, err := storage.FS()
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = musicFS.ReadTags("non-existent.mp3")
+ Expect(err).To(HaveOccurred())
+ })
+ })
+
+ Context("when extractor fails", func() {
+ It("should return the extractor error", func() {
+ testExtractor.err = &extractorError{message: "extractor failed"}
+
+ u, err := url.Parse("file://" + tempDir)
+ Expect(err).ToNot(HaveOccurred())
+ storage := newLocalStorage(*u)
+ musicFS, err := storage.FS()
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = musicFS.ReadTags("test.mp3")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("extractor failed"))
+ })
+ })
+
+ Context("with multiple files", func() {
+ It("should process all files correctly", func() {
+ // Create another test file
+ testFile2 := filepath.Join(tempDir, "test2.mp3")
+ err := os.WriteFile(testFile2, []byte("test data 2"), 0600)
+ Expect(err).ToNot(HaveOccurred())
+
+ info1 := metadata.Info{
+ Tags: map[string][]string{"title": {"Song 1"}},
+ FileInfo: &testFileInfo{name: "test.mp3"},
+ }
+ info2 := metadata.Info{
+ Tags: map[string][]string{"title": {"Song 2"}},
+ FileInfo: nil, // This one needs FileInfo populated
+ }
+
+ testExtractor.results["test.mp3"] = info1
+ testExtractor.results["test2.mp3"] = info2
+
+ u, err := url.Parse("file://" + tempDir)
+ Expect(err).ToNot(HaveOccurred())
+ storage := newLocalStorage(*u)
+ musicFS, err := storage.FS()
+ Expect(err).ToNot(HaveOccurred())
+
+ results, err := musicFS.ReadTags("test.mp3", "test2.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+
+ Expect(results["test.mp3"].FileInfo).To(Equal(&testFileInfo{name: "test.mp3"}))
+ Expect(results["test2.mp3"].FileInfo).ToNot(BeNil())
+ Expect(results["test2.mp3"].FileInfo.Name()).To(Equal("test2.mp3"))
+ })
+ })
+ })
+
+ Describe("localFileInfo", func() {
+ var testFile string
+ var fileInfo fs.FileInfo
+
+ BeforeEach(func() {
+ testFile = filepath.Join(tempDir, "test.mp3")
+ err := os.WriteFile(testFile, []byte("test data"), 0600)
+ Expect(err).ToNot(HaveOccurred())
+
+ fileInfo, err = os.Stat(testFile)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ Describe("BirthTime", func() {
+ It("should return birth time when available", func() {
+ lfi := localFileInfo{FileInfo: fileInfo}
+ birthTime := lfi.BirthTime()
+
+ // Birth time should be a valid time (not zero value)
+ Expect(birthTime).ToNot(BeZero())
+ // Should be around the current time (within last few minutes)
+ Expect(birthTime).To(BeTemporally("~", time.Now(), 5*time.Minute))
+ })
+ })
+
+ It("should delegate all other FileInfo methods", func() {
+ lfi := localFileInfo{FileInfo: fileInfo}
+
+ Expect(lfi.Name()).To(Equal(fileInfo.Name()))
+ Expect(lfi.Size()).To(Equal(fileInfo.Size()))
+ Expect(lfi.Mode()).To(Equal(fileInfo.Mode()))
+ Expect(lfi.ModTime()).To(Equal(fileInfo.ModTime()))
+ Expect(lfi.IsDir()).To(Equal(fileInfo.IsDir()))
+ Expect(lfi.Sys()).To(Equal(fileInfo.Sys()))
+ })
+ })
+
+ Describe("Storage registration", func() {
+ It("should register localStorage for file scheme", func() {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
+ // This tests the init() function indirectly
+ storage, err := storage.For("file://" + tempDir)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(storage).To(BeAssignableToTypeOf(&localStorage{}))
+ })
+ })
+})
+
+// Test extractor for testing
+type mockTestExtractor struct {
+ results map[string]metadata.Info
+ err error
+}
+
+func (m *mockTestExtractor) Parse(files ...string) (map[string]metadata.Info, error) {
+ if m.err != nil {
+ return nil, m.err
+ }
+
+ result := make(map[string]metadata.Info)
+ for _, file := range files {
+ if info, exists := m.results[file]; exists {
+ result[file] = info
+ }
+ }
+ return result, nil
+}
+
+func (m *mockTestExtractor) Version() string {
+ return "test-1.0"
+}
+
+type extractorError struct {
+ message string
+}
+
+func (e *extractorError) Error() string {
+ return e.message
+}
+
+// Test FileInfo that implements metadata.FileInfo
+type testFileInfo struct {
+ name string
+ size int64
+ mode fs.FileMode
+ modTime time.Time
+ isDir bool
+ birthTime time.Time
+}
+
+func (t *testFileInfo) Name() string { return t.name }
+func (t *testFileInfo) Size() int64 { return t.size }
+func (t *testFileInfo) Mode() fs.FileMode { return t.mode }
+func (t *testFileInfo) ModTime() time.Time { return t.modTime }
+func (t *testFileInfo) IsDir() bool { return t.isDir }
+func (t *testFileInfo) Sys() any { return nil }
+func (t *testFileInfo) BirthTime() time.Time {
+ if t.birthTime.IsZero() {
+ return time.Now()
+ }
+ return t.birthTime
+}
diff --git a/core/storage/local/watcher.go b/core/storage/local/watcher.go
index e2418f4cb..1b8a4e0c8 100644
--- a/core/storage/local/watcher.go
+++ b/core/storage/local/watcher.go
@@ -17,8 +17,8 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) {
if !s.watching.CompareAndSwap(false, true) {
return nil, errors.New("watcher already started")
}
- input := make(chan notify.EventInfo, 1)
- output := make(chan string, 1)
+ input := make(chan notify.EventInfo, 500)
+ output := make(chan string, 500)
started := make(chan struct{})
go func() {
diff --git a/core/storage/storage.go b/core/storage/storage.go
index 84bcae0d6..b9fceb1fd 100644
--- a/core/storage/storage.go
+++ b/core/storage/storage.go
@@ -6,6 +6,8 @@ import (
"path/filepath"
"strings"
"sync"
+
+ "github.com/navidrome/navidrome/utils/slice"
)
const LocalSchemaID = "file"
@@ -36,7 +38,14 @@ func For(uri string) (Storage, error) {
if len(parts) < 2 {
uri, _ = filepath.Abs(uri)
uri = filepath.ToSlash(uri)
- uri = LocalSchemaID + "://" + uri
+
+ // Properly escape each path component using URL standards
+ pathParts := strings.Split(uri, "/")
+ escapedParts := slice.Map(pathParts, func(s string) string {
+ return url.PathEscape(s)
+ })
+
+ uri = LocalSchemaID + "://" + strings.Join(escapedParts, "/")
}
u, err := url.Parse(uri)
diff --git a/core/storage/storage_test.go b/core/storage/storage_test.go
index c74c7c6ed..32fbac413 100644
--- a/core/storage/storage_test.go
+++ b/core/storage/storage_test.go
@@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"
+ "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -54,6 +55,7 @@ var _ = Describe("Storage", func() {
Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp"))
})
It("should return a file implementation for a relative folder", func() {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage)")
s, err := For("tmp")
Expect(err).ToNot(HaveOccurred())
cwd, _ := os.Getwd()
@@ -65,6 +67,21 @@ var _ = Describe("Storage", func() {
_, err := For("webdav:///tmp")
Expect(err).To(HaveOccurred())
})
+ DescribeTable("should handle paths with special characters correctly",
+ func(inputPath string) {
+ s, err := For(inputPath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(s).To(BeAssignableToTypeOf(&fakeLocalStorage{}))
+ Expect(s.(*fakeLocalStorage).u.Scheme).To(Equal("file"))
+ // The path should be exactly the same as the input - after URL parsing it gets decoded back
+ Expect(s.(*fakeLocalStorage).u.Path).To(Equal(inputPath))
+ },
+ Entry("hash symbols", "/tmp/test#folder/file.mp3"),
+ Entry("spaces", "/tmp/test folder/file with spaces.mp3"),
+ Entry("question marks", "/tmp/test?query/file.mp3"),
+ Entry("ampersands", "/tmp/test&/file.mp3"),
+ Entry("multiple special chars", "/tmp/Song #1 & More?.mp3"),
+ )
})
})
diff --git a/core/storage/storagetest/fake_storage.go b/core/storage/storagetest/fake_storage.go
index 009b37d2d..1b0d1a6c1 100644
--- a/core/storage/storagetest/fake_storage.go
+++ b/core/storage/storagetest/fake_storage.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io/fs"
+ "maps"
"net/url"
"path"
"testing/fstest"
@@ -135,9 +136,7 @@ func (ffs *FakeFS) UpdateTags(filePath string, newTags map[string]any, when ...t
if err != nil {
panic(err)
}
- for k, v := range newTags {
- tags[k] = v
- }
+ maps.Copy(tags, newTags)
data, _ := json.Marshal(tags)
f.Data = data
ffs.Touch(filePath, when...)
@@ -180,9 +179,7 @@ func Track(num int, title string, tags ...map[string]any) map[string]any {
ts["title"] = title
ts["track"] = num
for _, t := range tags {
- for k, v := range t {
- ts[k] = v
- }
+ maps.Copy(ts, t)
}
return ts
}
@@ -200,9 +197,7 @@ func MP3(tags ...map[string]any) *fstest.MapFile {
func File(tags ...map[string]any) *fstest.MapFile {
ts := map[string]any{}
for _, t := range tags {
- for k, v := range t {
- ts[k] = v
- }
+ maps.Copy(ts, t)
}
modTime := time.Now()
if mt, ok := ts[fakeFileInfoModTime]; !ok {
@@ -289,6 +284,9 @@ func (ffs *FakeFS) parseFile(filePath string) (*metadata.Info, error) {
p.AudioProperties.BitDepth = getInt("bitdepth")
p.AudioProperties.SampleRate = getInt("samplerate")
p.AudioProperties.Channels = getInt("channels")
+ if codec, ok := data["codec"].(string); ok {
+ p.AudioProperties.Codec = codec
+ }
for k, v := range data {
p.Tags[k] = []string{fmt.Sprintf("%v", v)}
}
diff --git a/core/stream/aliases.go b/core/stream/aliases.go
new file mode 100644
index 000000000..af42ac076
--- /dev/null
+++ b/core/stream/aliases.go
@@ -0,0 +1,92 @@
+package stream
+
+import (
+ "slices"
+ "strings"
+)
+
+// containerAliasGroups maps each container alias to a canonical group name.
+var containerAliasGroups = func() map[string]string {
+ groups := [][]string{
+ {"aac", "adts", "m4a", "mp4", "m4b", "m4p"},
+ {"mpeg", "mp3", "mp2"},
+ {"ogg", "oga", "opus"},
+ {"aif", "aiff"},
+ {"asf", "wma"},
+ {"mpc", "mpp"},
+ {"wv"},
+ }
+ m := make(map[string]string)
+ for _, g := range groups {
+ canonical := g[0]
+ for _, name := range g {
+ m[name] = canonical
+ }
+ }
+ return m
+}()
+
+// codecAliasGroups maps each codec alias to a canonical group name.
+// Codecs within the same group are considered equivalent.
+var codecAliasGroups = func() map[string]string {
+ groups := [][]string{
+ {"aac", "adts"},
+ {"ac3", "ac-3"},
+ {"eac3", "e-ac3", "e-ac-3", "eac-3"},
+ {"mpc7", "musepack7"},
+ {"mpc8", "musepack8"},
+ {"wma1", "wmav1"},
+ {"wma2", "wmav2"},
+ {"wmalossless", "wma9lossless"},
+ {"wmapro", "wma9pro"},
+ {"shn", "shorten"},
+ {"mp4als", "als"},
+ }
+ m := make(map[string]string)
+ for _, g := range groups {
+ for _, name := range g {
+ m[name] = g[0] // canonical = first entry
+ }
+ }
+ return m
+}()
+
+// matchesWithAliases checks if a value matches any entry in candidates,
+// consulting the alias map for equivalent names.
+func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool {
+ value = strings.ToLower(value)
+ canonical := aliases[value]
+ for _, c := range candidates {
+ c = strings.ToLower(c)
+ if c == value {
+ return true
+ }
+ if canonical != "" && aliases[c] == canonical {
+ return true
+ }
+ }
+ return false
+}
+
+// matchesContainer checks if a file suffix matches any of the container names,
+// including common aliases.
+func matchesContainer(suffix string, containers []string) bool {
+ return matchesWithAliases(suffix, containers, containerAliasGroups)
+}
+
+// matchesCodec checks if a codec matches any of the codec names,
+// including common aliases.
+func matchesCodec(codec string, codecs []string) bool {
+ return matchesWithAliases(codec, codecs, codecAliasGroups)
+}
+
+// IsAACCodec returns true if the given codec or container name resolves to AAC.
+func IsAACCodec(name string) bool {
+ return matchesCodec(name, []string{"aac"}) || matchesContainer(name, []string{"aac"})
+}
+
+func containsIgnoreCase(slice []string, s string) bool {
+ return slices.ContainsFunc(slice, func(item string) bool {
+ return strings.EqualFold(item, s)
+ })
+}
diff --git a/core/stream/aliases_test.go b/core/stream/aliases_test.go
new file mode 100644
index 000000000..72f061810
--- /dev/null
+++ b/core/stream/aliases_test.go
@@ -0,0 +1,30 @@
+package stream
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Aliases", func() {
+ Describe("IsAACCodec", func() {
+ It("returns true for AAC and its aliases", func() {
+ Expect(IsAACCodec("aac")).To(BeTrue())
+ Expect(IsAACCodec("AAC")).To(BeTrue())
+ Expect(IsAACCodec("adts")).To(BeTrue())
+ Expect(IsAACCodec("m4a")).To(BeTrue())
+ Expect(IsAACCodec("mp4")).To(BeTrue())
+ Expect(IsAACCodec("m4b")).To(BeTrue())
+ })
+
+ It("returns false for non-AAC formats", func() {
+ Expect(IsAACCodec("mp3")).To(BeFalse())
+ Expect(IsAACCodec("opus")).To(BeFalse())
+ Expect(IsAACCodec("flac")).To(BeFalse())
+ Expect(IsAACCodec("ogg")).To(BeFalse())
+ })
+
+ It("returns false for empty string", func() {
+ Expect(IsAACCodec("")).To(BeFalse())
+ })
+ })
+})
diff --git a/core/stream/codec.go b/core/stream/codec.go
new file mode 100644
index 000000000..28bff75c4
--- /dev/null
+++ b/core/stream/codec.go
@@ -0,0 +1,90 @@
+package stream
+
+import "strings"
+
+// normalizeProbeCodec maps ffprobe codec_name values to the simplified internal
+// codec names used throughout Navidrome (matching inferCodecFromSuffix output).
+// Most ffprobe names match directly; this handles the exceptions.
+func normalizeProbeCodec(codec string) string {
+ c := strings.ToLower(codec)
+ // DSD variants: dsd_lsbf_planar, dsd_msbf_planar, dsd_lsbf, dsd_msbf
+ if strings.HasPrefix(c, "dsd") {
+ return "dsd"
+ }
+ // PCM variants: pcm_s16le, pcm_s24le, pcm_s32be, pcm_f32le, etc.
+ if strings.HasPrefix(c, "pcm_") {
+ return "pcm"
+ }
+ return c
+}
+
+// isLosslessFormat returns true if the format is a known lossless audio codec/format.
+// Detection is based on codec name only, not bit depth — some lossy codecs (e.g. ADPCM)
+// report non-zero bits_per_sample in ffprobe, so bit depth alone is not a reliable signal.
+//
+// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats
+// ffmpeg can produce as output (a smaller set).
+func isLosslessFormat(format string) bool {
+ switch strings.ToLower(format) {
+ case "flac", "alac", "wav", "aiff", "ape", "wv", "wavpack", "tta", "tak", "shn", "dsd", "pcm":
+ return true
+ }
+ return false
+}
+
+// normalizeSourceSampleRate adjusts the source sample rate for codecs that store
+// it differently than PCM. Currently handles DSD (÷8):
+// DSD64=2822400→352800, DSD128=5644800→705600, etc.
+// For other codecs, returns the rate unchanged.
+func normalizeSourceSampleRate(sampleRate int, codec string) int {
+ if strings.EqualFold(codec, "dsd") && sampleRate > 0 {
+ return sampleRate / 8
+ }
+ return sampleRate
+}
+
+// normalizeSourceBitDepth adjusts the source bit depth for codecs that use
+// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is
+// what ffmpeg produces). For other codecs, returns the depth unchanged.
+func normalizeSourceBitDepth(bitDepth int, codec string) int {
+ if strings.EqualFold(codec, "dsd") && bitDepth == 1 {
+ return 24
+ }
+ return bitDepth
+}
+
+// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs
+// that always resample regardless of input (e.g., Opus always outputs 48000Hz).
+// Returns 0 if the codec has no fixed output rate.
+func codecFixedOutputSampleRate(codec string) int {
+ switch strings.ToLower(codec) {
+ case "opus":
+ return 48000
+ }
+ return 0
+}
+
+// codecMaxSampleRate returns the hard maximum output sample rate for a codec.
+// Returns 0 if the codec has no hard limit.
+func codecMaxSampleRate(codec string) int {
+ switch strings.ToLower(codec) {
+ case "mp3":
+ return 48000
+ case "aac":
+ return 96000
+ }
+ return 0
+}
+
+// codecMaxChannels returns the hard maximum number of audio channels a codec
+// supports. Returns 0 if the codec has no hard limit (or is unknown), in which
+// case the source/profile constraints applied upstream are authoritative.
+func codecMaxChannels(codec string) int {
+ switch strings.ToLower(codec) {
+ case "mp3":
+ return 2
+ case "opus":
+ return 8
+ }
+ return 0
+}
diff --git a/core/stream/codec_test.go b/core/stream/codec_test.go
new file mode 100644
index 000000000..97e15bdb5
--- /dev/null
+++ b/core/stream/codec_test.go
@@ -0,0 +1,91 @@
+package stream
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Codec", func() {
+ Describe("isLosslessFormat", func() {
+ It("returns true for known lossless codecs", func() {
+ Expect(isLosslessFormat("flac")).To(BeTrue())
+ Expect(isLosslessFormat("alac")).To(BeTrue())
+ Expect(isLosslessFormat("pcm")).To(BeTrue())
+ Expect(isLosslessFormat("wav")).To(BeTrue())
+ Expect(isLosslessFormat("dsd")).To(BeTrue())
+ Expect(isLosslessFormat("ape")).To(BeTrue())
+ Expect(isLosslessFormat("wv")).To(BeTrue())
+ Expect(isLosslessFormat("wavpack")).To(BeTrue()) // ffprobe codec_name for WavPack
+ })
+
+ It("returns false for lossy codecs", func() {
+ Expect(isLosslessFormat("mp3")).To(BeFalse())
+ Expect(isLosslessFormat("aac")).To(BeFalse())
+ Expect(isLosslessFormat("opus")).To(BeFalse())
+ Expect(isLosslessFormat("vorbis")).To(BeFalse())
+ })
+
+ It("returns false for unknown codecs", func() {
+ Expect(isLosslessFormat("unknown_codec")).To(BeFalse())
+ })
+
+ It("is case-insensitive", func() {
+ Expect(isLosslessFormat("FLAC")).To(BeTrue())
+ Expect(isLosslessFormat("Alac")).To(BeTrue())
+ })
+ })
+
+ Describe("normalizeProbeCodec", func() {
+ It("passes through common codec names unchanged", func() {
+ Expect(normalizeProbeCodec("mp3")).To(Equal("mp3"))
+ Expect(normalizeProbeCodec("aac")).To(Equal("aac"))
+ Expect(normalizeProbeCodec("flac")).To(Equal("flac"))
+ Expect(normalizeProbeCodec("opus")).To(Equal("opus"))
+ Expect(normalizeProbeCodec("vorbis")).To(Equal("vorbis"))
+ Expect(normalizeProbeCodec("alac")).To(Equal("alac"))
+ Expect(normalizeProbeCodec("wmav2")).To(Equal("wmav2"))
+ })
+
+ It("normalizes DSD variants to dsd", func() {
+ Expect(normalizeProbeCodec("dsd_lsbf_planar")).To(Equal("dsd"))
+ Expect(normalizeProbeCodec("dsd_msbf_planar")).To(Equal("dsd"))
+ Expect(normalizeProbeCodec("dsd_lsbf")).To(Equal("dsd"))
+ Expect(normalizeProbeCodec("dsd_msbf")).To(Equal("dsd"))
+ })
+
+ It("normalizes PCM variants to pcm", func() {
+ Expect(normalizeProbeCodec("pcm_s16le")).To(Equal("pcm"))
+ Expect(normalizeProbeCodec("pcm_s24le")).To(Equal("pcm"))
+ Expect(normalizeProbeCodec("pcm_s32be")).To(Equal("pcm"))
+ Expect(normalizeProbeCodec("pcm_f32le")).To(Equal("pcm"))
+ })
+
+ It("lowercases input", func() {
+ Expect(normalizeProbeCodec("MP3")).To(Equal("mp3"))
+ Expect(normalizeProbeCodec("AAC")).To(Equal("aac"))
+ Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd"))
+ })
+ })
+
+ Describe("codecMaxChannels", func() {
+ It("returns 2 for mp3", func() {
+ Expect(codecMaxChannels("mp3")).To(Equal(2))
+ })
+
+ It("returns 8 for opus", func() {
+ Expect(codecMaxChannels("opus")).To(Equal(8))
+ })
+
+ It("is case-insensitive", func() {
+ Expect(codecMaxChannels("MP3")).To(Equal(2))
+ Expect(codecMaxChannels("Opus")).To(Equal(8))
+ })
+
+ It("returns 0 for codecs with no hard limit", func() {
+ Expect(codecMaxChannels("aac")).To(Equal(0))
+ Expect(codecMaxChannels("flac")).To(Equal(0))
+ Expect(codecMaxChannels("vorbis")).To(Equal(0))
+ Expect(codecMaxChannels("")).To(Equal(0))
+ })
+ })
+})
diff --git a/core/stream/decider.go b/core/stream/decider.go
new file mode 100644
index 000000000..d6e48497c
--- /dev/null
+++ b/core/stream/decider.go
@@ -0,0 +1,457 @@
+package stream
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/core/ffmpeg"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+)
+
+const fallbackBitrate = 256 // kbps
+
+// TranscodeDecider is the core service interface for making transcoding decisions
+type TranscodeDecider interface {
+ MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error)
+ CreateTranscodeParams(decision *TranscodeDecision) (string, error)
+ ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error)
+ ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request
+}
+
+func NewTranscodeDecider(ds model.DataStore, ff ffmpeg.FFmpeg) TranscodeDecider {
+ return &deciderService{
+ ds: ds,
+ ff: ff,
+ }
+}
+
+type deciderService struct {
+ ds model.DataStore
+ ff ffmpeg.FFmpeg
+}
+
+func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error) {
+ decision := &TranscodeDecision{
+ MediaID: mf.ID,
+ SourceUpdatedAt: mf.UpdatedAt,
+ }
+
+ var probe *ffmpeg.AudioProbeResult
+ if !opts.SkipProbe {
+ if !s.ff.IsProbeAvailable() {
+ log.Debug(ctx, "ffprobe not available, using tag metadata for transcode decision", "mediaID", mf.ID)
+ } else {
+ var err error
+ probe, err = s.ensureProbed(ctx, mf)
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ // Build source stream details (uses probe data if available)
+ decision.SourceStream = buildSourceStream(mf, probe)
+ src := &decision.SourceStream
+
+ log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container,
+ "codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels,
+ "sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name)
+
+ // Check global bitrate constraint first.
+ if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
+ log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play",
+ "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
+ decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported")
+ // Skip direct play profiles entirely — global constraint fails
+ } else {
+ // Try direct play profiles, collecting reasons for each failure
+ for _, profile := range clientInfo.DirectPlayProfiles {
+ if reason := s.checkDirectPlayProfile(src, &profile, clientInfo); reason == "" {
+ decision.CanDirectPlay = true
+ decision.TranscodeReasons = nil // Clear any previously collected reasons
+ break
+ } else {
+ decision.TranscodeReasons = append(decision.TranscodeReasons, reason)
+ }
+ }
+ }
+
+ // If direct play is possible, we're done
+ if decision.CanDirectPlay {
+ log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", src.Container, "codec", src.Codec)
+ return decision, nil
+ }
+
+ // Try transcoding profiles (in order of preference)
+ for _, profile := range clientInfo.TranscodingProfiles {
+ if ts, transcodeFormat := s.computeTranscodedStream(ctx, src, &profile, clientInfo); ts != nil {
+ decision.CanTranscode = true
+ decision.TargetFormat = transcodeFormat
+ decision.TargetBitrate = ts.Bitrate
+ decision.TargetChannels = ts.Channels
+ decision.TargetSampleRate = ts.SampleRate
+ decision.TargetBitDepth = ts.BitDepth
+ decision.TranscodeStream = ts
+ break
+ }
+ }
+
+ if decision.CanTranscode {
+ log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID,
+ "targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate,
+ "targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons)
+ }
+
+ // If neither direct play nor transcode is possible
+ if !decision.CanDirectPlay && !decision.CanTranscode {
+ decision.ErrorReason = "no compatible playback profile found"
+ log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID,
+ "container", src.Container, "codec", src.Codec, "reasons", decision.TranscodeReasons)
+ }
+
+ return decision, nil
+}
+
+func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Details {
+ sd := Details{
+ Container: mf.Suffix,
+ Duration: mf.Duration,
+ Size: mf.Size,
+ }
+
+ // Use pre-parsed probe result, or fall back to parsing stored probe data
+ if probe == nil {
+ probe, _ = parseProbeData(mf.ProbeData)
+ }
+
+ // Use probe data if available for authoritative values
+ if probe != nil {
+ sd.Codec = normalizeProbeCodec(probe.Codec)
+ sd.Profile = probe.Profile
+ sd.Bitrate = probe.BitRate
+ sd.SampleRate = probe.SampleRate
+ sd.BitDepth = probe.BitDepth
+ sd.Channels = probe.Channels
+ } else {
+ sd.Codec = mf.AudioCodec()
+ sd.Bitrate = mf.BitRate
+ sd.SampleRate = mf.SampleRate
+ sd.BitDepth = mf.BitDepth
+ sd.Channels = mf.Channels
+ }
+ sd.IsLossless = isLosslessFormat(sd.Codec)
+
+ return sd
+}
+
+// applyServerOverride replaces the client-provided profiles with synthetic ones
+// matching the server-forced transcoding format and bitrate.
+func applyServerOverride(ctx context.Context, original *ClientInfo, trc *model.Transcoding) *ClientInfo {
+ maxBitRate := trc.DefaultBitRate
+ if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
+ maxBitRate = player.MaxBitRate
+ }
+
+ log.Debug(ctx, "Applying server-side transcoding override",
+ "targetFormat", trc.TargetFormat, "maxBitRate", maxBitRate,
+ "client", original.Name)
+
+ return &ClientInfo{
+ Name: original.Name,
+ Platform: original.Platform,
+ MaxAudioBitrate: maxBitRate,
+ MaxTranscodingAudioBitrate: maxBitRate,
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{trc.TargetFormat}, AudioCodecs: []string{trc.TargetFormat}, Protocols: []string{ProtocolHTTP}},
+ },
+ TranscodingProfiles: []Profile{
+ {Container: trc.TargetFormat, AudioCodec: trc.TargetFormat, Protocol: ProtocolHTTP},
+ },
+ }
+}
+
+func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) {
+ if data == "" {
+ return nil, nil
+ }
+ var result ffmpeg.AudioProbeResult
+ if err := json.Unmarshal([]byte(data), &result); err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// matchesPCMWAVBridge bridges Navidrome's internal "pcm" codec name with the
+// "wav" codec name that browsers use to advertise audio/wav support. The match
+// is scoped to WAV-container sources so AIFF files (which also normalize to
+// codec "pcm" but use a different container) cannot false-match a codec-only
+// ["wav"] profile.
+func matchesPCMWAVBridge(src *Details, profile *DirectPlayProfile) bool {
+ return strings.EqualFold(src.Codec, "pcm") &&
+ strings.EqualFold(src.Container, "wav") &&
+ containsIgnoreCase(profile.AudioCodecs, "wav")
+}
+
+// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
+// or a typed reason string if it doesn't match.
+func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
+ // Check protocol (only http for now)
+ if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) {
+ return "protocol not supported"
+ }
+
+ // Check container
+ if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) {
+ return fmt.Sprintf("container '%s' not supported by profile %s", src.Container, profile)
+ }
+
+ // Check codec
+ if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) && !matchesPCMWAVBridge(src, profile) {
+ return fmt.Sprintf("audio codec '%s' not supported by profile %s", src.Codec, profile)
+ }
+
+ // Check channels
+ if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
+ return fmt.Sprintf("audio channels %d not supported by profile %s (max %d)", src.Channels, profile, profile.MaxAudioChannels)
+ }
+
+ // Check codec-specific limitations
+ for _, codecProfile := range clientInfo.CodecProfiles {
+ if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(src.Codec, []string{codecProfile.Name}) {
+ if reason := checkLimitations(src, codecProfile.Limitations); reason != "" {
+ return reason
+ }
+ }
+ }
+
+ return ""
+}
+
+// computeTranscodedStream attempts to build a valid transcoded stream for the given profile.
+// Returns the stream details and the internal transcoding format (which may differ from the
+// response container when a codec fallback occurs, e.g., "mp4"→"aac").
+// Returns nil, "" if the profile cannot produce a valid output.
+func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Details, profile *Profile, clientInfo *ClientInfo) (*Details, string) {
+ // Check protocol (only http for now)
+ if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) {
+ log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol)
+ return nil, ""
+ }
+
+ responseContainer, targetFormat := resolveTargetFormat(profile)
+ if targetFormat == "" {
+ return nil, ""
+ }
+
+ // Verify we have a transcoding command available (DB custom or built-in default)
+ if LookupTranscodeCommand(ctx, s.ds, targetFormat) == "" {
+ log.Trace(ctx, "Skipping transcoding profile: no transcoding command available", "targetFormat", targetFormat)
+ return nil, ""
+ }
+
+ targetIsLossless := isLosslessFormat(targetFormat)
+
+ // Reject lossy to lossless conversion
+ if !src.IsLossless && targetIsLossless {
+ log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat)
+ return nil, ""
+ }
+
+ ts := &Details{
+ Container: responseContainer,
+ Codec: strings.ToLower(profile.AudioCodec),
+ SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec),
+ Channels: src.Channels,
+ BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec),
+ IsLossless: targetIsLossless,
+ }
+ if ts.Codec == "" {
+ ts.Codec = targetFormat
+ }
+
+ // Apply codec-intrinsic sample rate adjustments before codec profile limitations
+ if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 {
+ ts.SampleRate = fixedRate
+ }
+ if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate {
+ ts.SampleRate = maxRate
+ }
+ if maxCh := codecMaxChannels(ts.Codec); maxCh > 0 && ts.Channels > maxCh {
+ ts.Channels = maxCh
+ }
+
+ // Determine target bitrate (all in kbps)
+ if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok {
+ return nil, ""
+ }
+
+ // Apply MaxAudioChannels from the transcoding profile. Compare against the
+ // already-clamped ts.Channels (not src.Channels) so the codec hard limit
+ // applied above is never raised by a looser profile setting.
+ if profile.MaxAudioChannels > 0 && ts.Channels > profile.MaxAudioChannels {
+ ts.Channels = profile.MaxAudioChannels
+ }
+
+ // Apply codec profile limitations to the TARGET codec
+ if ok := s.applyCodecLimitations(ctx, src.Bitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
+ return nil, ""
+ }
+
+ return ts, targetFormat
+}
+
+// lookupDefaultBitrate returns the default bitrate for the given format.
+// It checks the DB first (for user-customized values), then falls back to
+// the built-in defaults, and finally to fallbackBitrate.
+func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string) int {
+ if t, err := ds.Transcoding(ctx).FindByFormat(format); err == nil && t.DefaultBitRate > 0 {
+ return t.DefaultBitRate
+ }
+ for _, dt := range consts.DefaultTranscodings {
+ if dt.TargetFormat == format && dt.DefaultBitRate > 0 {
+ return dt.DefaultBitRate
+ }
+ }
+ return fallbackBitrate
+}
+
+// LookupTranscodeCommand returns the ffmpeg command for the given format.
+// It checks the DB first (for user-customized commands), then falls back to
+// the built-in default command. Returns "" if the format is unknown.
+func LookupTranscodeCommand(ctx context.Context, ds model.DataStore, format string) string {
+ t, err := ds.Transcoding(ctx).FindByFormat(format)
+ if err == nil && t.Command != "" {
+ return t.Command
+ }
+ // Fall back to built-in defaults
+ for _, dt := range consts.DefaultTranscodings {
+ if dt.TargetFormat == format {
+ return dt.Command
+ }
+ }
+ return ""
+}
+
+// resolveTargetFormat determines the response container and internal target format
+// from the profile's Container and AudioCodec fields. When an AudioCodec is specified
+// it is preferred as targetFormat (e.g. container "mp4" with audioCodec "aac" → targetFormat "aac").
+func resolveTargetFormat(profile *Profile) (responseContainer, targetFormat string) {
+ responseContainer = strings.ToLower(profile.Container)
+ targetFormat = responseContainer
+
+ // Prefer the audioCodec as targetFormat when provided (handles container-to-codec
+ // mapping like "mp4" → "aac", "ogg" → "opus").
+ if profile.AudioCodec != "" {
+ targetFormat = strings.ToLower(profile.AudioCodec)
+ }
+
+ // If neither container nor audioCodec is set, we can't resolve a format.
+ if targetFormat == "" {
+ return "", ""
+ }
+
+ // When no container was specified, use the targetFormat as container too.
+ if responseContainer == "" {
+ responseContainer = targetFormat
+ }
+
+ return responseContainer, targetFormat
+}
+
+// computeBitrate determines the target bitrate for the transcoded stream.
+// Returns false if the profile should be rejected.
+func (s *deciderService) computeBitrate(ctx context.Context, src *Details, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool {
+ if src.IsLossless {
+ if !targetIsLossless {
+ if clientInfo.MaxTranscodingAudioBitrate > 0 {
+ ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
+ } else if clientInfo.MaxAudioBitrate > 0 {
+ ts.Bitrate = clientInfo.MaxAudioBitrate
+ } else {
+ ts.Bitrate = lookupDefaultBitrate(ctx, s.ds, targetFormat)
+ }
+ } else {
+ if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
+ log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit",
+ "targetFormat", targetFormat, "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
+ return false
+ }
+ }
+ } else {
+ ts.Bitrate = src.Bitrate
+ }
+
+ // Apply maxAudioBitrate as final cap
+ if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate {
+ ts.Bitrate = clientInfo.MaxAudioBitrate
+ }
+ return true
+}
+
+// applyCodecLimitations applies codec profile limitations to the transcoded stream.
+// Returns false if the profile should be rejected.
+func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool {
+ targetCodec := ts.Codec
+ for _, codecProfile := range clientInfo.CodecProfiles {
+ if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) {
+ continue
+ }
+ if !matchesCodec(targetCodec, []string{codecProfile.Name}) {
+ continue
+ }
+ for _, lim := range codecProfile.Limitations {
+ result := applyLimitation(sourceBitrate, &lim, ts)
+ if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted {
+ log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target",
+ "targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name)
+ return false
+ }
+ if result == adjustCannotFit {
+ log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied",
+ "targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name,
+ "comparison", lim.Comparison, "values", lim.Values)
+ return false
+ }
+ }
+ }
+ return true
+}
+
+// ensureProbed runs ffprobe if probe data is missing, persists it, and returns
+// the parsed result. Returns (nil, nil) when probing is skipped or data already exists
+// (in which case the caller should parse mf.ProbeData).
+func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile) (*ffmpeg.AudioProbeResult, error) {
+ if mf.ProbeData != "" {
+ return nil, nil
+ }
+ if !conf.Server.DevEnableMediaFileProbe {
+ return nil, nil
+ }
+
+ result, err := s.ff.ProbeAudioStream(ctx, mf.AbsolutePath())
+ if err != nil {
+ return nil, fmt.Errorf("probing media file %s: %w", mf.ID, err)
+ }
+
+ data, err := json.Marshal(result)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling probe result for %s: %w", mf.ID, err)
+ }
+ mf.ProbeData = string(data)
+
+ if err := s.ds.MediaFile(ctx).UpdateProbeData(mf.ID, mf.ProbeData); err != nil {
+ log.Error(ctx, "Failed to persist probe data", "mediaID", mf.ID, err)
+ // Don't fail the decision — we have the data in memory
+ }
+
+ log.Debug(ctx, "Probed media file", "mediaID", mf.ID, "codec", result.Codec,
+ "profile", result.Profile, "bitRate", result.BitRate,
+ "sampleRate", result.SampleRate, "bitDepth", result.BitDepth, "channels", result.Channels)
+ return result, nil
+}
diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go
new file mode 100644
index 000000000..f74953258
--- /dev/null
+++ b/core/stream/decider_test.go
@@ -0,0 +1,1180 @@
+package stream
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core/auth"
+ "github.com/navidrome/navidrome/core/ffmpeg"
+ "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"
+)
+
+// withProbe pre-populates ProbeData on a MediaFile from its own fields,
+// so ensureProbed short-circuits and tests don't need mock ffprobe results.
+func withProbe(mf *model.MediaFile) *model.MediaFile {
+ probe := ffmpeg.AudioProbeResult{
+ Codec: mf.AudioCodec(),
+ BitRate: mf.BitRate,
+ SampleRate: mf.SampleRate,
+ BitDepth: mf.BitDepth,
+ Channels: mf.Channels,
+ }
+ data, _ := json.Marshal(probe)
+ mf.ProbeData = string(data)
+ return mf
+}
+
+var _ = Describe("Decider", func() {
+ var (
+ ds *tests.MockDataStore
+ ff *tests.MockFFmpeg
+ svc TranscodeDecider
+ ctx context.Context
+ )
+
+ BeforeEach(func() {
+ ctx = GinkgoT().Context()
+ ds = &tests.MockDataStore{
+ MockedProperty: &tests.MockedPropertyRepo{},
+ MockedTranscoding: &tests.MockTranscodingRepo{},
+ }
+ ff = tests.NewMockFFmpeg("")
+ auth.Init(ds)
+ svc = NewTranscodeDecider(ds, ff)
+ })
+
+ Describe("MakeDecision", func() {
+ Context("Direct Play", func() {
+ It("allows direct play when profile matches", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ Expect(decision.CanTranscode).To(BeFalse())
+ Expect(decision.TranscodeReasons).To(BeEmpty())
+ })
+
+ It("rejects direct play when container doesn't match", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ Expect(decision.TranscodeReasons).To(ContainElement(And(
+ ContainSubstring("container 'flac' not supported"),
+ ContainSubstring("[mp3]"),
+ )))
+ })
+
+ It("rejects direct play when codec doesn't match", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "ALAC", BitRate: 1000, Channels: 2})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ Expect(decision.TranscodeReasons).To(ContainElement(And(
+ ContainSubstring("audio codec 'alac' not supported"),
+ ContainSubstring("[m4a/aac]"),
+ )))
+ })
+
+ It("rejects direct play when channels exceed limit", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ Expect(decision.TranscodeReasons).To(ContainElement(And(
+ ContainSubstring("audio channels 6 not supported"),
+ ContainSubstring("[flac]"),
+ ContainSubstring("(max 2)"),
+ )))
+ })
+
+ It("accepts WAV source against a wav codec profile (pcm->wav bridge)", func() {
+ // ffprobe normalizes PCM variants (pcm_s16le etc) to codec "pcm", but
+ // browsers advertise WAV support as audioCodecs:["wav"] via audio/wav MIME.
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "wav", Codec: "pcm", BitRate: 1411, Channels: 2})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"wav"}, AudioCodecs: []string{"wav"}, Protocols: []string{ProtocolHTTP}},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+
+ It("does not accept AIFF (pcm in non-wav container) against a wav codec profile", func() {
+ // AIFF files also normalize to codec="pcm" but use container="aiff".
+ // Without the container guard they would falsely match a codec-only
+ // ["wav"] profile and be direct-played as if they were WAV.
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "aiff", Codec: "pcm", BitRate: 1411, Channels: 2})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {AudioCodecs: []string{"wav"}, Protocols: []string{ProtocolHTTP}},
+ },
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ Expect(decision.TranscodeReasons).To(ContainElement(ContainSubstring("audio codec 'pcm'")))
+ })
+
+ It("handles container aliases (aac -> m4a)", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"aac"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+
+ It("handles container aliases (mp4 -> m4a)", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+
+ It("handles container aliases (opus -> ogg)", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "opus", Codec: "Opus", BitRate: 165, Channels: 2, SampleRate: 48000})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP}},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+
+ It("handles codec aliases (adts -> aac)", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"m4a"}, AudioCodecs: []string{"adts"}, Protocols: []string{ProtocolHTTP}},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+
+ It("allows when protocol list is empty (any protocol)", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"flac"}, AudioCodecs: []string{"flac"}},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+
+ It("allows when both container and codec lists are empty (wildcard)", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{}, AudioCodecs: []string{}},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+ })
+
+ Context("MaxAudioBitrate constraint", func() {
+ It("revokes direct play when bitrate exceeds maxAudioBitrate", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2})
+ ci := &ClientInfo{
+ MaxAudioBitrate: 500, // kbps
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
+ },
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported"))
+ })
+ })
+
+ Context("Transcoding", func() {
+ It("selects transcoding when direct play isn't possible", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 256, // kbps
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
+ },
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetFormat).To(Equal("mp3"))
+ Expect(decision.TargetBitrate).To(Equal(256)) // kbps
+ Expect(decision.TranscodeReasons).To(ContainElement(And(
+ ContainSubstring("container 'flac' not supported"),
+ ContainSubstring("[mp3]"),
+ )))
+ })
+
+ It("rejects lossy to lossless transcoding", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2})
+ ci := &ClientInfo{
+ TranscodingProfiles: []Profile{
+ {Container: "flac", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeFalse())
+ })
+
+ It("uses default bitrate when client doesn't specify", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16})
+ ci := &ClientInfo{
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetBitrate).To(Equal(160)) // mp3 default from mock transcoding repo
+ })
+
+ It("preserves lossy bitrate when under max", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 256, // kbps
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetBitrate).To(Equal(192)) // source bitrate in kbps
+ })
+
+ It("rejects format with no transcoding command available", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2})
+ ci := &ClientInfo{
+ TranscodingProfiles: []Profile{
+ {Container: "wav", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeFalse())
+ })
+
+ It("applies maxAudioBitrate as final cap on transcoded stream", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2})
+ ci := &ClientInfo{
+ MaxAudioBitrate: 96, // kbps
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetBitrate).To(Equal(96)) // capped by maxAudioBitrate
+ })
+
+ It("selects first valid transcoding profile in order", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 320,
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
+ },
+ TranscodingProfiles: []Profile{
+ {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetFormat).To(Equal("opus"))
+ })
+ })
+
+ Context("Lossless to lossless transcoding", func() {
+ It("allows lossless to lossless when samplerate needs downsampling", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1})
+ ci := &ClientInfo{
+ MaxAudioBitrate: 1000,
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
+ },
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetFormat).To(Equal("mp3"))
+ })
+
+ It("sets IsLossless=true on transcoded stream when target is lossless", func() {
+ // Transcoding to mp3 (lossy) should result in IsLossless=false.
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 320,
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TranscodeStream.IsLossless).To(BeFalse()) // mp3 is lossy
+ })
+ })
+
+ Context("No compatible profile", func() {
+ It("returns error when nothing matches", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6})
+ ci := &ClientInfo{}
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ Expect(decision.CanTranscode).To(BeFalse())
+ Expect(decision.ErrorReason).To(Equal("no compatible playback profile found"))
+ })
+ })
+
+ Context("Codec limitations on direct play", func() {
+ It("rejects direct play when codec limitation fails (required)", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
+ },
+ CodecProfiles: []CodecProfile{
+ {
+ Type: CodecProfileTypeAudio,
+ Name: "mp3",
+ Limitations: []Limitation{
+ {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: true},
+ },
+ },
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported"))
+ })
+
+ It("allows direct play when optional limitation fails", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
+ },
+ CodecProfiles: []CodecProfile{
+ {
+ Type: CodecProfileTypeAudio,
+ Name: "mp3",
+ Limitations: []Limitation{
+ {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: false},
+ },
+ },
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+
+ It("handles Equals comparison with multiple values", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
+ },
+ CodecProfiles: []CodecProfile{
+ {
+ Type: CodecProfileTypeAudio,
+ Name: "flac",
+ Limitations: []Limitation{
+ {Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true},
+ },
+ },
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+
+ It("rejects when Equals comparison doesn't match any value", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
+ },
+ CodecProfiles: []CodecProfile{
+ {
+ Type: CodecProfileTypeAudio,
+ Name: "flac",
+ Limitations: []Limitation{
+ {Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true},
+ },
+ },
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ })
+
+ It("rejects direct play when audioProfile limitation fails (required)", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
+ },
+ CodecProfiles: []CodecProfile{
+ {
+ Type: CodecProfileTypeAudio,
+ Name: "aac",
+ Limitations: []Limitation{
+ {Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: true},
+ },
+ },
+ },
+ }
+ // Source profile is empty (not yet populated from scanner), so Equals("LC") fails
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ Expect(decision.TranscodeReasons).To(ContainElement("audio profile not supported"))
+ })
+
+ It("allows direct play when audioProfile limitation is optional", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
+ },
+ CodecProfiles: []CodecProfile{
+ {
+ Type: CodecProfileTypeAudio,
+ Name: "aac",
+ Limitations: []Limitation{
+ {Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: false},
+ },
+ },
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+
+ It("rejects direct play due to samplerate limitation", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
+ },
+ CodecProfiles: []CodecProfile{
+ {
+ Type: CodecProfileTypeAudio,
+ Name: "flac",
+ Limitations: []Limitation{
+ {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true},
+ },
+ },
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ Expect(decision.TranscodeReasons).To(ContainElement("audio samplerate not supported"))
+ })
+ })
+
+ Context("Codec limitations on transcoded output", func() {
+ It("applies bitrate limitation to transcoded stream", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 192, Channels: 2, SampleRate: 44100})
+ ci := &ClientInfo{
+ MaxAudioBitrate: 96, // force transcode
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ CodecProfiles: []CodecProfile{
+ {
+ Type: CodecProfileTypeAudio,
+ Name: "mp3",
+ Limitations: []Limitation{
+ {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"96"}, Required: true},
+ },
+ },
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TranscodeStream.Bitrate).To(Equal(96))
+ })
+
+ It("applies channel limitation to transcoded stream", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 320,
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ CodecProfiles: []CodecProfile{
+ {
+ Type: CodecProfileTypeAudio,
+ Name: "mp3",
+ Limitations: []Limitation{
+ {Name: LimitationAudioChannels, Comparison: ComparisonLessThanEqual, Values: []string{"2"}, Required: true},
+ },
+ },
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TranscodeStream.Channels).To(Equal(2))
+ })
+
+ It("applies samplerate limitation to transcoded stream", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 320,
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ CodecProfiles: []CodecProfile{
+ {
+ Type: CodecProfileTypeAudio,
+ Name: "mp3",
+ Limitations: []Limitation{
+ {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true},
+ },
+ },
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
+ })
+
+ It("applies bitdepth limitation to transcoded stream", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
+ ci := &ClientInfo{
+ TranscodingProfiles: []Profile{
+ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
+ },
+ CodecProfiles: []CodecProfile{
+ {
+ Type: CodecProfileTypeAudio,
+ Name: "flac",
+ Limitations: []Limitation{
+ {Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true},
+ },
+ },
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TranscodeStream.BitDepth).To(Equal(16))
+ Expect(decision.TargetBitDepth).To(Equal(16))
+ })
+
+ It("preserves source bit depth when no limitation applies", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 24})
+ ci := &ClientInfo{
+ TranscodingProfiles: []Profile{
+ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
+ Expect(decision.TargetBitDepth).To(Equal(24))
+ })
+
+ It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 320,
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ CodecProfiles: []CodecProfile{
+ {
+ Type: CodecProfileTypeAudio,
+ Name: "mp3",
+ Limitations: []Limitation{
+ {Name: LimitationAudioSamplerate, Comparison: ComparisonGreaterThanEqual, Values: []string{"96000"}, Required: true},
+ },
+ },
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeFalse())
+ })
+ })
+
+ Context("DSD sample rate conversion", func() {
+ It("converts DSD sample rate to PCM-equivalent in decision", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 320,
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetFormat).To(Equal("mp3"))
+ // DSD64 2822400 / 8 = 352800, capped by MP3 max of 48000
+ Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
+ Expect(decision.TargetSampleRate).To(Equal(48000))
+ // DSD 1-bit → 24-bit PCM
+ Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
+ Expect(decision.TargetBitDepth).To(Equal(24))
+ })
+
+ It("converts DSD sample rate for FLAC target without codec limit", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
+ ci := &ClientInfo{
+ TranscodingProfiles: []Profile{
+ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetFormat).To(Equal("flac"))
+ // DSD64 2822400 / 8 = 352800, FLAC has no hard max
+ Expect(decision.TranscodeStream.SampleRate).To(Equal(352800))
+ Expect(decision.TargetSampleRate).To(Equal(352800))
+ // DSD 1-bit → 24-bit PCM
+ Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
+ Expect(decision.TargetBitDepth).To(Equal(24))
+ })
+
+ It("applies codec profile limit to DSD-converted FLAC sample rate", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
+ ci := &ClientInfo{
+ TranscodingProfiles: []Profile{
+ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
+ },
+ CodecProfiles: []CodecProfile{
+ {
+ Type: CodecProfileTypeAudio,
+ Name: "flac",
+ Limitations: []Limitation{
+ {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true},
+ },
+ },
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ // DSD64 2822400 / 8 = 352800, capped by codec profile limit of 48000
+ Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
+ Expect(decision.TargetSampleRate).To(Equal(48000))
+ // DSD 1-bit → 24-bit PCM
+ Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
+ Expect(decision.TargetBitDepth).To(Equal(24))
+ })
+
+ It("applies audioBitdepth limitation to DSD-converted bit depth", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
+ ci := &ClientInfo{
+ TranscodingProfiles: []Profile{
+ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
+ },
+ CodecProfiles: []CodecProfile{
+ {
+ Type: CodecProfileTypeAudio,
+ Name: "flac",
+ Limitations: []Limitation{
+ {Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true},
+ },
+ },
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ // DSD 1-bit → 24-bit PCM, then capped by codec profile limit to 16-bit
+ Expect(decision.TranscodeStream.BitDepth).To(Equal(16))
+ Expect(decision.TargetBitDepth).To(Equal(16))
+ })
+ })
+
+ Context("Codec channel limits", func() {
+ It("clamps 6-channel FLAC to 2 channels when transcoding to MP3", func() {
+ // Regression test for #5336: ffmpeg's mp3 encoder rejects >2 channels.
+ // The decider must clamp to the codec's hard limit even when no
+ // transcoding profile MaxAudioChannels is configured.
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 320,
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetFormat).To(Equal("mp3"))
+ Expect(decision.TranscodeStream.Channels).To(Equal(2))
+ Expect(decision.TargetChannels).To(Equal(2))
+ })
+
+ It("honors a stricter profile MaxAudioChannels over the codec clamp", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 320,
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 1},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TranscodeStream.Channels).To(Equal(1))
+ Expect(decision.TargetChannels).To(Equal(1))
+ })
+
+ It("applies the codec clamp when the profile limit is looser", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 320,
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 4},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TranscodeStream.Channels).To(Equal(2))
+ Expect(decision.TargetChannels).To(Equal(2))
+ })
+
+ It("passes channels through unchanged for codecs with no hard limit", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 320,
+ TranscodingProfiles: []Profile{
+ {Container: "m4a", AudioCodec: "aac", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetFormat).To(Equal("aac"))
+ Expect(decision.TranscodeStream.Channels).To(Equal(6))
+ Expect(decision.TargetChannels).To(Equal(6))
+ })
+ })
+
+ Context("Probe-based lossless detection", func() {
+ It("uses probe codec name for lossless detection", func() {
+ // WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv"
+ mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}
+ probe := ffmpeg.AudioProbeResult{
+ Codec: "wavpack", BitRate: 1000, SampleRate: 44100, BitDepth: 16, Channels: 2,
+ }
+ data, _ := json.Marshal(probe)
+ mf.ProbeData = string(data)
+
+ ci := &ClientInfo{
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ MaxTranscodingAudioBitrate: 256,
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.SourceStream.IsLossless).To(BeTrue())
+ Expect(decision.SourceStream.Codec).To(Equal("wavpack"))
+ // Lossless source transcoding to MP3 should use MaxTranscodingAudioBitrate
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TranscodeStream.Bitrate).To(Equal(256))
+ })
+
+ It("detects lossy from probe codec name", func() {
+ mf := &model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2, SampleRate: 48000}
+ probe := ffmpeg.AudioProbeResult{
+ Codec: "vorbis", BitRate: 192, SampleRate: 48000, BitDepth: 0, Channels: 2,
+ }
+ data, _ := json.Marshal(probe)
+ mf.ProbeData = string(data)
+
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"ogg"}, AudioCodecs: []string{"vorbis"}, Protocols: []string{ProtocolHTTP}},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.SourceStream.IsLossless).To(BeFalse())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+ })
+
+ Context("Opus fixed sample rate", func() {
+ It("sets Opus output to 48000Hz regardless of input", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 128,
+ TranscodingProfiles: []Profile{
+ {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetFormat).To(Equal("opus"))
+ // Opus always outputs 48000Hz
+ Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
+ Expect(decision.TargetSampleRate).To(Equal(48000))
+ })
+
+ It("sets Opus output to 48000Hz even for 96kHz input", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 128,
+ TranscodingProfiles: []Profile{
+ {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
+ })
+ })
+
+ Context("Container vs format separation", func() {
+ It("preserves mp4 container when falling back to aac format", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 256,
+ TranscodingProfiles: []Profile{
+ {Container: "mp4", AudioCodec: "aac", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ // TargetFormat is the internal format used for transcoding ("aac")
+ Expect(decision.TargetFormat).To(Equal("aac"))
+ // Container in the response preserves what the client asked ("mp4")
+ Expect(decision.TranscodeStream.Container).To(Equal("mp4"))
+ Expect(decision.TranscodeStream.Codec).To(Equal("aac"))
+ })
+
+ It("uses container as format when container matches transcoding config", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 256,
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetFormat).To(Equal("mp3"))
+ Expect(decision.TranscodeStream.Container).To(Equal("mp3"))
+ })
+ })
+
+ Context("MP3 max sample rate", func() {
+ It("caps sample rate at 48000 for MP3", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 320,
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
+ })
+
+ It("preserves sample rate at 44100 for MP3", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 320,
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TranscodeStream.SampleRate).To(Equal(44100))
+ })
+ })
+
+ Context("AAC max sample rate", func() {
+ It("caps sample rate at 96000 for AAC", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
+ ci := &ClientInfo{
+ MaxTranscodingAudioBitrate: 320,
+ TranscodingProfiles: []Profile{
+ {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ // DSD64 2822400 / 8 = 352800, capped by AAC max of 96000
+ Expect(decision.TranscodeStream.SampleRate).To(Equal(96000))
+ })
+ })
+
+ Context("Typed transcode reasons from multiple profiles", func() {
+ It("collects reasons from each failed direct play profile", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", Codec: "Vorbis", BitRate: 128, Channels: 2, SampleRate: 48000})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
+ {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
+ {Containers: []string{"m4a", "mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
+ },
+ TranscodingProfiles: []Profile{
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ Expect(decision.TranscodeReasons).To(HaveLen(3))
+ Expect(decision.TranscodeReasons[0]).To(ContainSubstring("container 'ogg' not supported"))
+ Expect(decision.TranscodeReasons[0]).To(ContainSubstring("[flac]"))
+ Expect(decision.TranscodeReasons[1]).To(ContainSubstring("container 'ogg' not supported"))
+ Expect(decision.TranscodeReasons[1]).To(ContainSubstring("[mp3/mp3]"))
+ Expect(decision.TranscodeReasons[2]).To(ContainSubstring("container 'ogg' not supported"))
+ Expect(decision.TranscodeReasons[2]).To(ContainSubstring("[m4a,mp4/aac]"))
+ })
+ })
+
+ Context("Source stream details", func() {
+ It("populates source stream correctly with kbps bitrate", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000})
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.SourceStream.Container).To(Equal("flac"))
+ Expect(decision.SourceStream.Codec).To(Equal("flac"))
+ Expect(decision.SourceStream.Bitrate).To(Equal(1000)) // kbps
+ Expect(decision.SourceStream.SampleRate).To(Equal(96000))
+ Expect(decision.SourceStream.BitDepth).To(Equal(24))
+ Expect(decision.SourceStream.Channels).To(Equal(2))
+ })
+ })
+
+ Context("Server-side context is ignored by MakeDecision", func() {
+ It("ignores transcoding override in context", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
+ ci := &ClientInfo{
+ Name: "TestClient",
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
+ },
+ }
+ overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
+ decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+
+ It("ignores player MaxBitRate in context", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+ ci := &ClientInfo{
+ Name: "TestClient",
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
+ },
+ }
+ playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
+ decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+ })
+
+ Context("Format-aware default bitrate", func() {
+ It("uses opus default bitrate from DB", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16})
+ ci := &ClientInfo{
+ TranscodingProfiles: []Profile{
+ {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetBitrate).To(Equal(96)) // opus default from mock
+ })
+
+ It("uses aac default bitrate from DB", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+ ci := &ClientInfo{
+ TranscodingProfiles: []Profile{
+ {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP},
+ },
+ }
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetBitrate).To(Equal(256)) // aac default from mock
+ })
+
+ It("falls back to 256 for unknown format", func() {
+ bitrate := lookupDefaultBitrate(ctx, ds, "xyz")
+ Expect(bitrate).To(Equal(fallbackBitrate))
+ })
+ })
+
+ })
+
+ Describe("ensureProbed", func() {
+ var mockMFRepo *tests.MockMediaFileRepo
+
+ BeforeEach(func() {
+ mockMFRepo = tests.CreateMockMediaFileRepo()
+ ds.MockedMediaFile = mockMFRepo
+ })
+
+ It("calls ffprobe and populates ProbeData when empty", func() {
+ mf := &model.MediaFile{ID: "probe-1", Suffix: "mp3", BitRate: 320, Channels: 2}
+ mockMFRepo.SetData(model.MediaFiles{*mf})
+
+ ff.ProbeAudioResult = &ffmpeg.AudioProbeResult{
+ Codec: "mp3", BitRate: 320, SampleRate: 44100, Channels: 2,
+ }
+
+ svc := NewTranscodeDecider(ds, ff).(*deciderService)
+ probe, err := svc.ensureProbed(ctx, mf)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.ProbeData).ToNot(BeEmpty())
+ Expect(probe).ToNot(BeNil())
+ Expect(probe.Codec).To(Equal("mp3"))
+ Expect(probe.BitRate).To(Equal(320))
+ Expect(probe.SampleRate).To(Equal(44100))
+ Expect(probe.Channels).To(Equal(2))
+
+ // Verify persisted to DB
+ stored := mockMFRepo.Data["probe-1"]
+ Expect(stored.ProbeData).To(Equal(mf.ProbeData))
+ })
+
+ It("skips ffprobe when ProbeData is already set", func() {
+ mf := withProbe(&model.MediaFile{ID: "probe-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2})
+
+ // Set error on mock — if ffprobe were called, this would fail
+ ff.Error = fmt.Errorf("should not be called")
+
+ svc := NewTranscodeDecider(ds, ff).(*deciderService)
+ probe, err := svc.ensureProbed(ctx, mf)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(probe).To(BeNil())
+ })
+
+ It("returns error when ffprobe fails", func() {
+ mf := &model.MediaFile{ID: "probe-3", Suffix: "mp3"}
+ ff.Error = fmt.Errorf("ffprobe not found")
+
+ svc := NewTranscodeDecider(ds, ff).(*deciderService)
+ _, err := svc.ensureProbed(ctx, mf)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("probing media file"))
+ Expect(mf.ProbeData).To(BeEmpty())
+ })
+
+ It("skips ffprobe when DevEnableMediaFileProbe is false", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.DevEnableMediaFileProbe = false
+
+ mf := &model.MediaFile{ID: "probe-4", Suffix: "mp3"}
+ // Set a result — if ffprobe were called, ProbeData would be populated
+ ff.ProbeAudioResult = &ffmpeg.AudioProbeResult{Codec: "mp3"}
+
+ svc := NewTranscodeDecider(ds, ff).(*deciderService)
+ probe, err := svc.ensureProbed(ctx, mf)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(probe).To(BeNil())
+ Expect(mf.ProbeData).To(BeEmpty())
+ })
+ })
+
+})
diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go
new file mode 100644
index 000000000..9dd6179a0
--- /dev/null
+++ b/core/stream/legacy_client.go
@@ -0,0 +1,113 @@
+package stream
+
+import (
+ "context"
+ "strings"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+)
+
+// buildLegacyClientInfo translates legacy Subsonic stream/download parameters
+// into a ClientInfo for use with MakeDecision.
+func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo {
+ ci := &ClientInfo{Name: "legacy"}
+
+ // Determine target format for transcoding
+ var targetFormat string
+ switch {
+ case reqFormat != "":
+ targetFormat = reqFormat
+ case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "":
+ targetFormat = conf.Server.DefaultDownsamplingFormat
+ }
+
+ if targetFormat != "" {
+ // Add a direct play profile for the source format when no explicit
+ // format was requested (bitrate-only downsampling) or when the
+ // requested format matches the source. When the client explicitly
+ // requests a different format, direct play must not match the
+ // source — otherwise the source is returned untranscoded.
+ if reqFormat == "" || strings.EqualFold(reqFormat, mf.Suffix) {
+ ci.DirectPlayProfiles = []DirectPlayProfile{
+ {Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}},
+ }
+ }
+ ci.TranscodingProfiles = []Profile{
+ {Container: targetFormat, AudioCodec: targetFormat, Protocol: ProtocolHTTP},
+ }
+ if reqBitRate > 0 {
+ ci.MaxAudioBitrate = reqBitRate
+ ci.MaxTranscodingAudioBitrate = reqBitRate
+ }
+ } else {
+ // No transcoding requested — direct play everything
+ ci.DirectPlayProfiles = []DirectPlayProfile{
+ {Protocols: []string{ProtocolHTTP}},
+ }
+ }
+
+ return ci
+}
+
+// ResolveRequest uses MakeDecision to resolve legacy Subsonic stream parameters
+// into a fully specified Request.
+func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request {
+ var req Request
+ req.Offset = offset
+
+ if reqFormat == "raw" {
+ req.Format = "raw"
+ return req
+ }
+
+ clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
+
+ // Apply server-side player transcoding override before making the decision
+ if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
+ clientInfo = applyServerOverride(ctx, clientInfo, &trc)
+ } else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
+ if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
+ modified := *clientInfo
+ modified.MaxAudioBitrate = player.MaxBitRate
+ clientInfo = &modified
+ log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
+ }
+ }
+
+ decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true})
+ if err != nil {
+ log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err)
+ req.Format = "raw"
+ return req
+ }
+
+ if decision.CanDirectPlay {
+ req.Format = "raw"
+ return req
+ }
+
+ if decision.CanTranscode {
+ req.Format = decision.TargetFormat
+ req.BitRate = decision.TargetBitrate
+ req.SampleRate = decision.TargetSampleRate
+ req.BitDepth = decision.TargetBitDepth
+ req.Channels = decision.TargetChannels
+ return req
+ }
+
+ // No compatible profile for the requested format — retry with DefaultDownsamplingFormat
+ // TODO: validate DefaultDownsamplingFormat at startup to warn about unsupported values
+ fallbackFormat := conf.Server.DefaultDownsamplingFormat
+ if reqFormat != "" && fallbackFormat != "" && !strings.EqualFold(reqFormat, fallbackFormat) {
+ log.Warn(ctx, "Requested format not available, falling back to default downsampling format",
+ "requestedFormat", reqFormat, "fallbackFormat", fallbackFormat, "id", mf.ID)
+ return s.ResolveRequest(ctx, mf, fallbackFormat, reqBitRate, offset)
+ }
+
+ // Ultimate fallback — raw
+ req.Format = "raw"
+ return req
+}
diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go
new file mode 100644
index 000000000..ce7b38650
--- /dev/null
+++ b/core/stream/legacy_client_test.go
@@ -0,0 +1,344 @@
+package stream
+
+import (
+ "context"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "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("buildLegacyClientInfo", func() {
+ var mf *model.MediaFile
+
+ BeforeEach(func() {
+ mf = &model.MediaFile{Suffix: "flac", BitRate: 960}
+ })
+
+ It("sets transcoding profile for explicit format without bitrate", func() {
+ ci := buildLegacyClientInfo(mf, "mp3", 0)
+
+ Expect(ci.Name).To(Equal("legacy"))
+ Expect(ci.TranscodingProfiles).To(HaveLen(1))
+ Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
+ Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
+ Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP))
+ Expect(ci.MaxAudioBitrate).To(BeZero())
+ Expect(ci.MaxTranscodingAudioBitrate).To(BeZero())
+ Expect(ci.DirectPlayProfiles).To(BeEmpty())
+ })
+
+ It("does not add direct play profile when explicit format differs from source (no bitrate)", func() {
+ ci := buildLegacyClientInfo(mf, "opus", 0)
+
+ Expect(ci.TranscodingProfiles).To(HaveLen(1))
+ Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
+ Expect(ci.DirectPlayProfiles).To(BeEmpty())
+ })
+
+ It("adds direct play profile when explicit format matches source format", func() {
+ ci := buildLegacyClientInfo(mf, "flac", 0)
+
+ Expect(ci.TranscodingProfiles).To(HaveLen(1))
+ Expect(ci.TranscodingProfiles[0].Container).To(Equal("flac"))
+ Expect(ci.DirectPlayProfiles).To(HaveLen(1))
+ Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
+ Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()}))
+ })
+
+ It("sets transcoding profile and bitrate for explicit format with bitrate", func() {
+ ci := buildLegacyClientInfo(mf, "mp3", 192)
+
+ Expect(ci.TranscodingProfiles).To(HaveLen(1))
+ Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
+ Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
+ Expect(ci.MaxAudioBitrate).To(Equal(192))
+ Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192))
+ Expect(ci.DirectPlayProfiles).To(BeEmpty())
+ })
+
+ It("returns direct play profile when no format and no bitrate", func() {
+ ci := buildLegacyClientInfo(mf, "", 0)
+
+ Expect(ci.DirectPlayProfiles).To(HaveLen(1))
+ Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
+ Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty())
+ Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
+ Expect(ci.TranscodingProfiles).To(BeEmpty())
+ Expect(ci.MaxAudioBitrate).To(BeZero())
+ })
+
+ It("uses default downsampling format for bitrate-only downsampling", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.DefaultDownsamplingFormat = "opus"
+
+ ci := buildLegacyClientInfo(mf, "", 128)
+
+ Expect(ci.TranscodingProfiles).To(HaveLen(1))
+ Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
+ Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
+ Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP))
+ Expect(ci.MaxAudioBitrate).To(Equal(128))
+ Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128))
+ Expect(ci.DirectPlayProfiles).To(HaveLen(1))
+ Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
+ Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()}))
+ })
+
+ It("returns direct play when bitrate >= source bitrate", func() {
+ ci := buildLegacyClientInfo(mf, "", 960)
+
+ Expect(ci.DirectPlayProfiles).To(HaveLen(1))
+ Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
+ Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty())
+ Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
+ Expect(ci.TranscodingProfiles).To(BeEmpty())
+ Expect(ci.MaxAudioBitrate).To(BeZero())
+ })
+})
+
+var _ = Describe("ResolveRequest", func() {
+ var (
+ svc TranscodeDecider
+ ctx context.Context
+ )
+
+ BeforeEach(func() {
+ ctx = GinkgoT().Context()
+ ds := &tests.MockDataStore{
+ MockedProperty: &tests.MockedPropertyRepo{},
+ MockedTranscoding: &tests.MockTranscodingRepo{},
+ }
+ ff := tests.NewMockFFmpeg("")
+ auth.Init(ds)
+ svc = NewTranscodeDecider(ds, ff)
+ })
+
+ It("returns raw when format is 'raw'", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(ctx, mf, "raw", 0, 0)
+
+ Expect(req.Format).To(Equal("raw"))
+ })
+
+ It("returns raw (direct play) when no format or bitrate specified", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(ctx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("raw"))
+ })
+
+ It("transcodes to requested format", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(ctx, mf, "opus", 0, 0)
+
+ Expect(req.Format).To(Equal("opus"))
+ })
+
+ It("transcodes to requested format with bitrate limit", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(ctx, mf, "mp3", 128, 0)
+
+ Expect(req.Format).To(Equal("mp3"))
+ Expect(req.BitRate).To(Equal(128))
+ })
+
+ It("returns raw when requested format matches source and no bitrate reduction", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(ctx, mf, "mp3", 320, 0)
+
+ Expect(req.Format).To(Equal("raw"))
+ })
+
+ It("downsamples when only bitrate is specified below source", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.DefaultDownsamplingFormat = "opus"
+
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(ctx, mf, "", 128, 0)
+
+ Expect(req.Format).To(Equal("opus"))
+ Expect(req.BitRate).To(Equal(128))
+ })
+
+ It("passes offset through", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(ctx, mf, "opus", 128, 30)
+
+ Expect(req.Format).To(Equal("opus"))
+ Expect(req.Offset).To(Equal(30))
+ })
+
+ Context("Server-side player transcoding override", func() {
+ It("forces transcoding when override targets a different format", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
+ overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
+ overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("mp3"))
+ Expect(req.BitRate).To(Equal(192))
+ })
+
+ It("allows direct play when source matches forced format and bitrate is within cap", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
+ overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("raw"))
+ })
+
+ It("transcodes when source bitrate exceeds the forced cap", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
+ overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("mp3"))
+ Expect(req.BitRate).To(Equal(192))
+ })
+
+ It("uses player MaxBitRate over transcoding DefaultBitRate", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
+ overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
+ overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("mp3"))
+ Expect(req.BitRate).To(Equal(320))
+ })
+
+ It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
+ overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0})
+ overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("mp3"))
+ // With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock)
+ Expect(req.BitRate).To(Equal(160))
+ })
+
+ It("does not apply override when no transcoding is in context", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(ctx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("raw"))
+ })
+ })
+
+ Context("Player MaxBitRate cap", func() {
+ It("applies player MaxBitRate cap when client has no limit", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+ playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(playerCtx, mf, "mp3", 0, 0)
+
+ Expect(req.Format).To(Equal("mp3"))
+ Expect(req.BitRate).To(Equal(320))
+ })
+
+ It("uses client limit when it is more restrictive than player MaxBitRate", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+ playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(playerCtx, mf, "mp3", 256, 0)
+
+ Expect(req.Format).To(Equal("mp3"))
+ Expect(req.BitRate).To(Equal(256))
+ })
+
+ It("does not cap when player MaxBitRate is 0", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
+ playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(playerCtx, mf, "", 0, 0)
+
+ Expect(req.Format).To(Equal("raw"))
+ })
+ })
+
+ Context("fallback for unknown format", func() {
+ It("falls back to DefaultDownsamplingFormat", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.DefaultDownsamplingFormat = "opus"
+
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0)
+
+ Expect(req.Format).To(Equal("opus"))
+ })
+
+ It("falls back to raw when DefaultDownsamplingFormat is empty", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.DefaultDownsamplingFormat = ""
+
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0)
+
+ Expect(req.Format).To(Equal("raw"))
+ })
+
+ It("falls back to raw when DefaultDownsamplingFormat is also invalid", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.DefaultDownsamplingFormat = "xyz"
+
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0)
+
+ Expect(req.Format).To(Equal("raw"))
+ })
+
+ It("preserves bitrate when falling back to DefaultDownsamplingFormat", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.DefaultDownsamplingFormat = "opus"
+
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
+
+ decider := svc.(*deciderService)
+ req := decider.ResolveRequest(ctx, mf, "xyz", 128, 0)
+
+ Expect(req.Format).To(Equal("opus"))
+ Expect(req.BitRate).To(Equal(128))
+ })
+ })
+})
diff --git a/core/stream/limitations.go b/core/stream/limitations.go
new file mode 100644
index 000000000..ab70d6c07
--- /dev/null
+++ b/core/stream/limitations.go
@@ -0,0 +1,171 @@
+package stream
+
+import (
+ "strconv"
+ "strings"
+)
+
+// adjustResult represents the outcome of applying a limitation to a transcoded stream value
+type adjustResult int
+
+const (
+ adjustNone adjustResult = iota // Value already satisfies the limitation
+ adjustAdjusted // Value was changed to fit the limitation
+ adjustCannotFit // Cannot satisfy the limitation (reject this profile)
+)
+
+// checkLimitations checks codec profile limitations against source stream details.
+// Returns "" if all limitations pass, or a typed reason string for the first failure.
+func checkLimitations(src *Details, limitations []Limitation) string {
+ for _, lim := range limitations {
+ var ok bool
+ var reason string
+
+ switch lim.Name {
+ case LimitationAudioChannels:
+ ok = checkIntLimitation(src.Channels, lim.Comparison, lim.Values)
+ reason = "audio channels not supported"
+ case LimitationAudioSamplerate:
+ ok = checkIntLimitation(src.SampleRate, lim.Comparison, lim.Values)
+ reason = "audio samplerate not supported"
+ case LimitationAudioBitrate:
+ ok = checkIntLimitation(src.Bitrate, lim.Comparison, lim.Values)
+ reason = "audio bitrate not supported"
+ case LimitationAudioBitdepth:
+ ok = checkIntLimitation(src.BitDepth, lim.Comparison, lim.Values)
+ reason = "audio bitdepth not supported"
+ case LimitationAudioProfile:
+ ok = checkStringLimitation(src.Profile, lim.Comparison, lim.Values)
+ reason = "audio profile not supported"
+ default:
+ continue
+ }
+
+ if !ok && lim.Required {
+ return reason
+ }
+ }
+ return ""
+}
+
+// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation.
+// Returns the adjustment result.
+func applyLimitation(sourceBitrate int, lim *Limitation, ts *Details) adjustResult {
+ switch lim.Name {
+ case LimitationAudioChannels:
+ return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v })
+ case LimitationAudioBitrate:
+ current := ts.Bitrate
+ if current == 0 {
+ current = sourceBitrate
+ }
+ return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v })
+ case LimitationAudioSamplerate:
+ return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v })
+ case LimitationAudioBitdepth:
+ if ts.BitDepth > 0 {
+ return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v })
+ }
+ case LimitationAudioProfile:
+ // TODO: implement when audio profile data is available
+ }
+ return adjustNone
+}
+
+// applyIntLimitation applies a limitation comparison to a value.
+// If the value needs adjusting, calls the setter and returns the result.
+func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult {
+ if len(values) == 0 {
+ return adjustNone
+ }
+
+ switch comparison {
+ case ComparisonLessThanEqual:
+ limit, ok := parseInt(values[0])
+ if !ok {
+ return adjustNone
+ }
+ if current <= limit {
+ return adjustNone
+ }
+ setter(limit)
+ return adjustAdjusted
+ case ComparisonGreaterThanEqual:
+ limit, ok := parseInt(values[0])
+ if !ok {
+ return adjustNone
+ }
+ if current >= limit {
+ return adjustNone
+ }
+ // Cannot upscale
+ return adjustCannotFit
+ case ComparisonEquals:
+ // Check if current value matches any allowed value
+ for _, v := range values {
+ if limit, ok := parseInt(v); ok && current == limit {
+ return adjustNone
+ }
+ }
+ // Find the closest allowed value below current (don't upscale)
+ var closest int
+ found := false
+ for _, v := range values {
+ if limit, ok := parseInt(v); ok && limit < current {
+ if !found || limit > closest {
+ closest = limit
+ found = true
+ }
+ }
+ }
+ if found {
+ setter(closest)
+ return adjustAdjusted
+ }
+ return adjustCannotFit
+ case ComparisonNotEquals:
+ for _, v := range values {
+ if limit, ok := parseInt(v); ok && current == limit {
+ return adjustCannotFit
+ }
+ }
+ return adjustNone
+ }
+
+ return adjustNone
+}
+
+func checkIntLimitation(value int, comparison string, values []string) bool {
+ return applyIntLimitation(comparison, values, value, func(int) {}) == adjustNone
+}
+
+// checkStringLimitation checks a string value against a limitation.
+// Only Equals and NotEquals comparisons are meaningful for strings.
+// LessThanEqual/GreaterThanEqual are not applicable and always pass.
+func checkStringLimitation(value string, comparison string, values []string) bool {
+ switch comparison {
+ case ComparisonEquals:
+ for _, v := range values {
+ if strings.EqualFold(value, v) {
+ return true
+ }
+ }
+ return false
+ case ComparisonNotEquals:
+ for _, v := range values {
+ if strings.EqualFold(value, v) {
+ return false
+ }
+ }
+ return true
+ }
+ return true
+}
+
+func parseInt(s string) (int, bool) {
+ v, err := strconv.Atoi(s)
+ if err != nil || v < 0 {
+ return 0, false
+ }
+ return v, true
+}
diff --git a/core/stream/limiter.go b/core/stream/limiter.go
new file mode 100644
index 000000000..622fe21cc
--- /dev/null
+++ b/core/stream/limiter.go
@@ -0,0 +1,135 @@
+package stream
+
+import (
+ "context"
+ "errors"
+ "io"
+ "sync"
+ "sync/atomic"
+)
+
+// ErrTooManyTranscodes is returned by TranscodeLimiter.Acquire when the
+// configured concurrency cap has been reached. Callers should translate this
+// into an HTTP 429 response so well-behaved clients back off and retry.
+var ErrTooManyTranscodes = errors.New("too many concurrent transcodes")
+
+// RetryAfterSeconds is the value returned in the HTTP Retry-After header when
+// a request is rejected with ErrTooManyTranscodes. Most transcodes finish well
+// within this window, so retrying after this delay typically succeeds.
+const RetryAfterSeconds = 5
+
+// TranscodeLimiter gates the number of concurrent ffmpeg transcodes. It enforces
+// both a global cap (to protect the host from process exhaustion) and an optional
+// per-user cap (to keep one client from starving the others). Acquire never
+// blocks: it either reserves a slot or returns ErrTooManyTranscodes immediately.
+type TranscodeLimiter interface {
+ // Acquire reserves a slot for the given user. On success it returns a release
+ // function that must be called exactly once when the transcode is done.
+ // Calling release more than once is safe and idempotent.
+ Acquire(ctx context.Context, user string) (release func(), err error)
+
+ // Enabled reports whether the limiter actually enforces any cap. Callers
+ // can use it to decide whether to bind ffmpeg's lifetime to the request
+ // context so disconnects free slots quickly, rather than letting the
+ // process drain to completion in the background.
+ Enabled() bool
+}
+
+// NewTranscodeLimiter returns a limiter enforcing the given caps. Each cap is
+// independent: a value of zero or less disables that cap. When both caps are
+// disabled the limiter is a no-op.
+func NewTranscodeLimiter(maxConcurrent, maxPerUser int) TranscodeLimiter {
+ if maxConcurrent <= 0 && maxPerUser <= 0 {
+ return noopLimiter{}
+ }
+ l := &transcodeLimiter{maxPerUser: maxPerUser}
+ if maxConcurrent > 0 {
+ l.global = make(chan struct{}, maxConcurrent)
+ }
+ if maxPerUser > 0 {
+ l.perUser = make(map[string]int)
+ }
+ return l
+}
+
+// releasingReadCloser wraps an io.ReadCloser so that closing it also releases
+// the limiter slot exactly once. release must be the function returned by
+// TranscodeLimiter.Acquire; its own idempotency makes double-Close safe too.
+type releasingReadCloser struct {
+ io.ReadCloser
+ release func()
+}
+
+func (r *releasingReadCloser) Close() error {
+ err := r.ReadCloser.Close()
+ r.release()
+ return err
+}
+
+type noopLimiter struct{}
+
+func (noopLimiter) Acquire(context.Context, string) (func(), error) {
+ return func() {}, nil
+}
+
+func (noopLimiter) Enabled() bool { return false }
+
+type transcodeLimiter struct {
+ maxPerUser int
+ global chan struct{}
+
+ mu sync.Mutex
+ perUser map[string]int
+}
+
+func (*transcodeLimiter) Enabled() bool { return true }
+
+func (l *transcodeLimiter) Acquire(_ context.Context, user string) (func(), error) {
+ // Reserve a per-user slot first so a noisy user can't burn through
+ // global slots only to be rejected later. An empty user key means
+ // "anonymous" (e.g. public share viewers); we skip the per-user cap
+ // entirely so unrelated anonymous clients do not share a bucket.
+ perUserActive := l.maxPerUser > 0 && user != ""
+ if perUserActive {
+ l.mu.Lock()
+ if l.perUser[user] >= l.maxPerUser {
+ l.mu.Unlock()
+ return nil, ErrTooManyTranscodes
+ }
+ l.perUser[user]++
+ l.mu.Unlock()
+ }
+
+ if l.global != nil {
+ select {
+ case l.global <- struct{}{}:
+ default:
+ if perUserActive {
+ l.releasePerUser(user)
+ }
+ return nil, ErrTooManyTranscodes
+ }
+ }
+
+ var released atomic.Bool
+ return func() {
+ if !released.CompareAndSwap(false, true) {
+ return
+ }
+ if l.global != nil {
+ <-l.global
+ }
+ if perUserActive {
+ l.releasePerUser(user)
+ }
+ }, nil
+}
+
+func (l *transcodeLimiter) releasePerUser(user string) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.perUser[user]--
+ if l.perUser[user] <= 0 {
+ delete(l.perUser, user)
+ }
+}
diff --git a/core/stream/limiter_test.go b/core/stream/limiter_test.go
new file mode 100644
index 000000000..d278d47c8
--- /dev/null
+++ b/core/stream/limiter_test.go
@@ -0,0 +1,186 @@
+package stream_test
+
+import (
+ "context"
+ "errors"
+ "sync"
+
+ "github.com/navidrome/navidrome/core/stream"
+ "github.com/navidrome/navidrome/log"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("TranscodeLimiter", func() {
+ ctx := log.NewContext(context.TODO())
+
+ Describe("Disabled (both caps <= 0)", func() {
+ It("never blocks and never returns ErrTooManyTranscodes", func() {
+ lim := stream.NewTranscodeLimiter(0, 0)
+ for range 100 {
+ rel, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(rel).ToNot(BeNil())
+ }
+ })
+ })
+
+ Describe("Per-user cap only (no global cap)", func() {
+ It("still enforces the per-user limit when MaxConcurrent is disabled", func() {
+ lim := stream.NewTranscodeLimiter(0, 2)
+
+ rel1, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+ rel2, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = lim.Acquire(ctx, "alice")
+ Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
+
+ // Other users have their own buckets.
+ rel3, err := lim.Acquire(ctx, "bob")
+ Expect(err).ToNot(HaveOccurred())
+
+ rel1()
+ _, err = lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+
+ rel2()
+ rel3()
+ })
+ })
+
+ Describe("Global cap", func() {
+ It("rejects requests beyond MaxConcurrent with ErrTooManyTranscodes", func() {
+ lim := stream.NewTranscodeLimiter(2, 0)
+
+ rel1, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+ rel2, err := lim.Acquire(ctx, "bob")
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = lim.Acquire(ctx, "carol")
+ Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
+
+ rel1()
+ _, err = lim.Acquire(ctx, "carol")
+ Expect(err).ToNot(HaveOccurred())
+
+ rel2()
+ })
+
+ It("releases a slot only once even if release is called multiple times", func() {
+ lim := stream.NewTranscodeLimiter(1, 0)
+
+ rel, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+
+ rel()
+ rel()
+ rel()
+
+ // After releases, exactly one slot should be available.
+ _, err = lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+ _, err = lim.Acquire(ctx, "alice")
+ Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
+ })
+ })
+
+ Describe("Per-user cap", func() {
+ It("rejects a user beyond MaxConcurrentPerUser even if global slots remain", func() {
+ lim := stream.NewTranscodeLimiter(10, 2)
+
+ rel1, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+ rel2, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = lim.Acquire(ctx, "alice")
+ Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
+
+ // A different user is unaffected.
+ rel3, err := lim.Acquire(ctx, "bob")
+ Expect(err).ToNot(HaveOccurred())
+
+ rel1()
+ _, err = lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+
+ rel2()
+ rel3()
+ })
+
+ It("skips the per-user cap for anonymous users (empty key)", func() {
+ // Anonymous requests (e.g. public share viewers) deliberately
+ // bypass the per-user cap so unrelated anonymous clients are not
+ // collapsed into a single shared bucket. The global cap remains
+ // the only ceiling on anonymous traffic.
+ lim := stream.NewTranscodeLimiter(10, 1)
+
+ rels := make([]func(), 0, 5)
+ for range 5 {
+ rel, err := lim.Acquire(ctx, "")
+ Expect(err).ToNot(HaveOccurred())
+ rels = append(rels, rel)
+ }
+ for _, rel := range rels {
+ rel()
+ }
+ })
+
+ It("still applies the global cap to anonymous users", func() {
+ lim := stream.NewTranscodeLimiter(2, 1)
+
+ rel1, err := lim.Acquire(ctx, "")
+ Expect(err).ToNot(HaveOccurred())
+ rel2, err := lim.Acquire(ctx, "")
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = lim.Acquire(ctx, "")
+ Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
+
+ rel1()
+ rel2()
+ })
+ })
+
+ Describe("Concurrent safety", func() {
+ It("survives parallel Acquire/release with consistent counts", func() {
+ lim := stream.NewTranscodeLimiter(5, 0)
+
+ var wg sync.WaitGroup
+ var acquired int64
+ var rejected int64
+ var mu sync.Mutex
+
+ for i := range 50 {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ rel, err := lim.Acquire(ctx, "alice")
+ mu.Lock()
+ if err == nil {
+ acquired++
+ mu.Unlock()
+ rel()
+ } else {
+ rejected++
+ mu.Unlock()
+ }
+ _ = i
+ }(i)
+ }
+ wg.Wait()
+
+ Expect(acquired + rejected).To(Equal(int64(50)))
+ // After all releases, all 5 slots should be free again.
+ for range 5 {
+ _, err := lim.Acquire(ctx, "alice")
+ Expect(err).ToNot(HaveOccurred())
+ }
+ _, err := lim.Acquire(ctx, "alice")
+ Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
+ })
+ })
+})
diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go
new file mode 100644
index 000000000..b09d9bab8
--- /dev/null
+++ b/core/stream/media_streamer.go
@@ -0,0 +1,302 @@
+package stream
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "mime"
+ "net/http"
+ "os"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/core/ffmpeg"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/utils/cache"
+ "github.com/navidrome/navidrome/utils/req"
+)
+
+type MediaStreamer interface {
+ NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error)
+}
+
+type TranscodingCache cache.FileCache
+
+func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer {
+ return &mediaStreamer{
+ ds: ds,
+ transcoder: t,
+ cache: cache,
+ limiter: NewTranscodeLimiter(conf.Server.Transcoding.MaxConcurrent, conf.Server.Transcoding.MaxConcurrentPerUser),
+ }
+}
+
+type mediaStreamer struct {
+ ds model.DataStore
+ transcoder ffmpeg.FFmpeg
+ cache cache.FileCache
+ limiter TranscodeLimiter
+}
+
+type streamJob struct {
+ ms *mediaStreamer
+ mf *model.MediaFile
+ filePath string
+ format string
+ bitRate int
+ sampleRate int
+ bitDepth int
+ channels int
+ offset int
+}
+
+func (j *streamJob) Key() string {
+ return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset)
+}
+
+// NewStream creates a Stream for the given MediaFile and Request. It handles both raw streaming (no transcoding)
+// and transcoded streaming based on the requested format and bitrate. It also logs detailed information about
+// the streaming request and whether the transcoding result was served from cache or not.
+func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) {
+ var format string
+ var bitRate int
+ var cached bool
+ defer func() {
+ log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
+ "bitRate", bitRate, "sampleRate", req.SampleRate, "bitDepth", req.BitDepth, "channels", req.Channels,
+ "user", userName(ctx), "transcoding", format != "raw",
+ "originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
+ }()
+
+ format = req.Format
+ bitRate = req.BitRate
+ if format == "" || format == "raw" {
+ format = "raw"
+ bitRate = 0
+ }
+ s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
+ filePath := mf.AbsolutePath()
+
+ if format == "raw" {
+ log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
+ "requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
+ "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
+ "selectedBitrate", bitRate, "selectedFormat", format)
+ f, err := os.Open(filePath)
+ if err != nil {
+ return nil, err
+ }
+ s.ReadCloser = f
+ s.Seeker = f
+ s.format = mf.Suffix
+ return s, nil
+ }
+
+ job := &streamJob{
+ ms: ms,
+ mf: mf,
+ filePath: filePath,
+ format: format,
+ bitRate: bitRate,
+ sampleRate: req.SampleRate,
+ bitDepth: req.BitDepth,
+ channels: req.Channels,
+ offset: req.Offset,
+ }
+ r, err := ms.cache.Get(ctx, job)
+ if err != nil {
+ // Rate-limit rejections are already logged at warn level by the
+ // producer; treating them as cache failures here would both
+ // double-log and mask actual cache problems.
+ if !errors.Is(err, ErrTooManyTranscodes) {
+ log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
+ }
+ return nil, err
+ }
+ cached = r.Cached
+
+ s.ReadCloser = r
+ s.Seeker = r.Seeker
+
+ log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
+ "requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
+ "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
+ "selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
+
+ return s, nil
+}
+
+type Stream struct {
+ ctx context.Context
+ mf *model.MediaFile
+ bitRate int
+ format string
+ io.ReadCloser
+ io.Seeker
+}
+
+func (s *Stream) Seekable() bool { return s.Seeker != nil }
+func (s *Stream) Duration() float32 { return s.mf.Duration }
+func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
+func (s *Stream) Name() string { return s.mf.Title + "." + s.format }
+func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
+func (s *Stream) EstimatedContentLength() int {
+ return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
+}
+
+// Serve writes the stream to the HTTP response. For seekable streams it uses http.ServeContent
+// (supporting range requests). For non-seekable streams it writes directly and logs any errors.
+// Returns the number of bytes written and an error only when io.Copy fails with 0 bytes written
+// (meaning the HTTP 200 status has not been flushed yet and the caller can still send an error response).
+// Empty output (0 bytes, no error) is logged but not treated as an error.
+func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request) (int64, error) {
+ if s.Seekable() {
+ http.ServeContent(w, r, s.Name(), s.ModTime(), s)
+ return -1, nil
+ }
+
+ w.Header().Set("Accept-Ranges", "none")
+ w.Header().Set("Content-Type", s.ContentType())
+
+ if req.Params(r).BoolOr("estimateContentLength", false) {
+ length := strconv.Itoa(s.EstimatedContentLength())
+ log.Trace(ctx, "Estimated content-length", "contentLength", length)
+ w.Header().Set("Content-Length", length)
+ }
+
+ if r.Method == http.MethodHead {
+ go func() { _, _ = io.Copy(io.Discard, s) }()
+ return 0, nil
+ }
+
+ id := s.mf.ID
+ c, err := io.Copy(w, s)
+ if err != nil {
+ log.Error(ctx, "Error sending transcoded file", "id", id, err)
+ if c == 0 {
+ w.Header().Del("Content-Length")
+ return 0, fmt.Errorf("sending transcoded file: %w", err)
+ }
+ return c, nil
+ }
+ if c == 0 {
+ log.Error(ctx, "Transcoding returned empty output, ffmpeg may have failed. "+
+ "Check that ffmpeg supports the requested codec. Enable Trace logging for ffmpeg stderr details",
+ "id", id, "format", s.ContentType())
+ } else {
+ log.Trace(ctx, "Success sending transcoded file", "id", id, "size", c)
+ }
+ return c, nil
+}
+
+// NewStream creates a non-seekable Stream from the given components.
+func NewStream(mf *model.MediaFile, format string, bitRate int, r io.ReadCloser) *Stream {
+ return &Stream{
+ ctx: context.Background(),
+ mf: mf,
+ format: format,
+ bitRate: bitRate,
+ ReadCloser: r,
+ }
+}
+
+var (
+ onceTranscodingCache sync.Once
+ instanceTranscodingCache TranscodingCache
+)
+
+func GetTranscodingCache() TranscodingCache {
+ onceTranscodingCache.Do(func() {
+ instanceTranscodingCache = NewTranscodingCache()
+ })
+ return instanceTranscodingCache
+}
+
+func NewTranscodingCache() TranscodingCache {
+ return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
+ consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
+ func(ctx context.Context, arg cache.Item) (io.Reader, error) {
+ job := arg.(*streamJob)
+ command := LookupTranscodeCommand(ctx, job.ms.ds, job.format)
+ if command == "" {
+ log.Error(ctx, "No transcoding command available", "format", job.format)
+ return nil, os.ErrInvalid
+ }
+
+ release, err := job.ms.limiter.Acquire(ctx, limiterKey(ctx))
+ if err != nil {
+ log.Warn(ctx, "Refusing transcode: concurrent transcode limit reached",
+ "id", job.mf.ID, "user", userName(ctx),
+ "maxConcurrent", conf.Server.Transcoding.MaxConcurrent,
+ "maxPerUser", conf.Server.Transcoding.MaxConcurrentPerUser)
+ return nil, err
+ }
+
+ // Choose the context that drives the ffmpeg process.
+ //
+ // When the limiter is enabled, force the request context so a
+ // client disconnect cancels ffmpeg and frees the slot promptly.
+ // Otherwise a client could open many transcodes, disconnect
+ // immediately, and still leave the configured cap's worth of
+ // ffmpeg processes draining in the background — which is exactly
+ // the DoS the limiter is meant to prevent.
+ //
+ // When the limiter is disabled, preserve the legacy behavior
+ // governed by Transcoding.EnableCancellation so unchanged configs
+ // keep their previous observable behavior.
+ var transcodingCtx context.Context
+ if job.ms.limiter.Enabled() || conf.Server.Transcoding.EnableCancellation {
+ transcodingCtx = ctx
+ } else {
+ transcodingCtx = request.AddValues(context.Background(), ctx)
+ }
+
+ out, err := job.ms.transcoder.Transcode(transcodingCtx, ffmpeg.TranscodeOptions{
+ Command: command,
+ Format: job.format,
+ FilePath: job.filePath,
+ BitRate: job.bitRate,
+ SampleRate: job.sampleRate,
+ BitDepth: job.bitDepth,
+ Channels: job.channels,
+ Offset: job.offset,
+ })
+ if err != nil {
+ release()
+ log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
+ return nil, os.ErrInvalid
+ }
+ // Tie the slot to the ffmpeg process: copyAndClose calls Close
+ // on this reader after io.Copy returns, which is exactly when
+ // ffmpeg has exited (either EOF or context cancellation).
+ return &releasingReadCloser{ReadCloser: out, release: release}, nil
+ })
+}
+
+// userName extracts the username from the context for logging purposes.
+func userName(ctx context.Context) string {
+ if user, ok := request.UserFrom(ctx); !ok {
+ return "UNKNOWN"
+ } else {
+ return user.UserName
+ }
+}
+
+// limiterKey returns the per-user bucket key used by the transcode limiter.
+// For anonymous requests (e.g. public shares) it returns the empty string,
+// which signals the limiter to skip the per-user cap entirely — otherwise
+// every anonymous viewer of a public share would collide on the same key
+// and starve each other within MaxConcurrentPerUser slots. The global cap
+// still applies and remains the protection against runaway anonymous load.
+func limiterKey(ctx context.Context) string {
+ if user, ok := request.UserFrom(ctx); ok {
+ return user.UserName
+ }
+ return ""
+}
diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go
new file mode 100644
index 000000000..f5ca16d3f
--- /dev/null
+++ b/core/stream/media_streamer_test.go
@@ -0,0 +1,142 @@
+package stream_test
+
+import (
+ "context"
+ "errors"
+ "io"
+ "os"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core/stream"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("MediaStreamer", func() {
+ var streamer stream.MediaStreamer
+ var ds model.DataStore
+ ffmpeg := tests.NewMockFFmpeg("fake data")
+ ctx := log.NewContext(context.TODO())
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ cacheDir, _ := os.MkdirTemp("", "file_caches")
+ conf.Server.CacheFolder = conf.NewDir(cacheDir)
+ conf.Server.TranscodingCacheSize = "100MB"
+ ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
+ ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
+ {ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
+ })
+ testCache := stream.NewTranscodingCache()
+ Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
+ streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache)
+ })
+ AfterEach(func() {
+ _ = os.RemoveAll(conf.Server.CacheFolder.String())
+ })
+
+ Context("NewStream", func() {
+ var mf *model.MediaFile
+ BeforeEach(func() {
+ var err error
+ mf, err = ds.MediaFile(ctx).Get("123")
+ Expect(err).ToNot(HaveOccurred())
+ })
+ It("returns a seekable stream if format is 'raw'", func() {
+ s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "raw"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(s.Seekable()).To(BeTrue())
+ })
+ It("returns a seekable stream if no format is specified (direct play)", func() {
+ s, err := streamer.NewStream(ctx, mf, stream.Request{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(s.Seekable()).To(BeTrue())
+ })
+ It("returns a NON seekable stream if transcode is required", func() {
+ s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 64})
+ Expect(err).To(BeNil())
+ Expect(s.Seekable()).To(BeFalse())
+ Expect(s.Duration()).To(Equal(float32(257.0)))
+ })
+ It("rejects transcode requests beyond MaxConcurrent with ErrTooManyTranscodes", func() {
+ // Use an ffmpeg whose Read blocks indefinitely so the cache's
+ // background copy can't drain the source and release the slot —
+ // keeping the single transcode slot pinned for this test.
+ pr, pw := io.Pipe()
+ DeferCleanup(func() { _ = pw.Close() })
+ blockingFFmpeg := tests.NewMockFFmpeg("")
+ blockingFFmpeg.Reader = pr
+
+ conf.Server.Transcoding.MaxConcurrent = 1
+ conf.Server.Transcoding.MaxConcurrentPerUser = 0
+ tightCache := stream.NewTranscodingCache()
+ Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
+ tightStreamer := stream.NewMediaStreamer(ds, blockingFFmpeg, tightCache)
+
+ userCtx := request.WithUsername(ctx, "alice")
+ s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
+ Expect(err).ToNot(HaveOccurred())
+ defer s1.Close()
+
+ // Different cache key so it doesn't dedupe with the first request.
+ _, err = tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96})
+ Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
+ })
+
+ It("releases the slot once the stream is closed", func() {
+ conf.Server.Transcoding.MaxConcurrent = 1
+ conf.Server.Transcoding.MaxConcurrentPerUser = 0
+ tightCache := stream.NewTranscodingCache()
+ Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
+ tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache)
+
+ userCtx := request.WithUsername(ctx, "alice")
+ s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
+ Expect(err).ToNot(HaveOccurred())
+ _, _ = io.ReadAll(s1)
+ _ = s1.Close()
+ Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
+
+ // Slot should now be free for a different transcode.
+ s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96})
+ Expect(err).ToNot(HaveOccurred())
+ defer s2.Close()
+ })
+
+ It("does not consume a slot for raw streams", func() {
+ conf.Server.Transcoding.MaxConcurrent = 1
+ conf.Server.Transcoding.MaxConcurrentPerUser = 0
+ tightCache := stream.NewTranscodingCache()
+ Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
+ tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache)
+
+ userCtx := request.WithUsername(ctx, "alice")
+ // First, saturate the single transcode slot.
+ s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
+ Expect(err).ToNot(HaveOccurred())
+ defer s1.Close()
+
+ // Raw stream must still succeed.
+ s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "raw"})
+ Expect(err).ToNot(HaveOccurred())
+ defer s2.Close()
+ })
+
+ It("returns a seekable stream if the file is complete in the cache", func() {
+ s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32})
+ Expect(err).To(BeNil())
+ _, _ = io.ReadAll(s)
+ _ = s.Close()
+ Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
+
+ s, err = streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32})
+ Expect(err).To(BeNil())
+ Expect(s.Seekable()).To(BeTrue())
+ })
+ })
+})
diff --git a/core/stream/stream_suite_test.go b/core/stream/stream_suite_test.go
new file mode 100644
index 000000000..36e9e7f43
--- /dev/null
+++ b/core/stream/stream_suite_test.go
@@ -0,0 +1,17 @@
+package stream
+
+import (
+ "testing"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestStream(t *testing.T) {
+ tests.Init(t, false)
+ log.SetLevel(log.LevelFatal)
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Stream Suite")
+}
diff --git a/core/stream/token.go b/core/stream/token.go
new file mode 100644
index 000000000..24a154b54
--- /dev/null
+++ b/core/stream/token.go
@@ -0,0 +1,148 @@
+package stream
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v3/jwt"
+ "github.com/navidrome/navidrome/core/auth"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+)
+
+const tokenTTL = 48 * time.Hour
+
+// params contains the parameters extracted from a transcode token.
+// TargetBitrate is in kilobits per second (kbps).
+type params struct {
+ MediaID string
+ DirectPlay bool
+ TargetFormat string
+ TargetBitrate int
+ TargetChannels int
+ TargetSampleRate int
+ TargetBitDepth int
+ SourceUpdatedAt time.Time
+}
+
+// toClaimsMap converts a Decision into a JWT claims map for token encoding.
+// Only non-zero transcode fields are included.
+func (d *TranscodeDecision) toClaimsMap() map[string]any {
+ m := map[string]any{
+ "mid": d.MediaID,
+ "ua": d.SourceUpdatedAt.Truncate(time.Second).Unix(),
+ jwt.ExpirationKey: time.Now().Add(tokenTTL).UTC().Unix(),
+ }
+ if d.CanDirectPlay {
+ m["dp"] = true
+ }
+ if d.CanTranscode && d.TargetFormat != "" {
+ m["f"] = d.TargetFormat
+ if d.TargetBitrate != 0 {
+ m["b"] = d.TargetBitrate
+ }
+ if d.TargetChannels != 0 {
+ m["ch"] = d.TargetChannels
+ }
+ if d.TargetSampleRate != 0 {
+ m["sr"] = d.TargetSampleRate
+ }
+ if d.TargetBitDepth != 0 {
+ m["bd"] = d.TargetBitDepth
+ }
+ }
+ return m
+}
+
+// paramsFromToken extracts and validates Params from a parsed JWT token.
+// Returns an error if required claims (media ID, source timestamp) are missing.
+func paramsFromToken(token jwt.Token) (*params, error) {
+ var p params
+ var mid string
+ if err := token.Get("mid", &mid); err == nil {
+ p.MediaID = mid
+ }
+ if p.MediaID == "" {
+ return nil, fmt.Errorf("%w: missing media ID", ErrTokenInvalid)
+ }
+
+ var dp bool
+ if err := token.Get("dp", &dp); err == nil {
+ p.DirectPlay = dp
+ }
+
+ ua := getIntClaim(token, "ua")
+ if ua != 0 {
+ p.SourceUpdatedAt = time.Unix(int64(ua), 0)
+ }
+ if p.SourceUpdatedAt.IsZero() {
+ return nil, fmt.Errorf("%w: missing source timestamp", ErrTokenInvalid)
+ }
+
+ var f string
+ if err := token.Get("f", &f); err == nil {
+ p.TargetFormat = f
+ }
+ p.TargetBitrate = getIntClaim(token, "b")
+ p.TargetChannels = getIntClaim(token, "ch")
+ p.TargetSampleRate = getIntClaim(token, "sr")
+ p.TargetBitDepth = getIntClaim(token, "bd")
+ return &p, nil
+}
+
+// getIntClaim extracts an int claim from a JWT token, handling the case where
+// the value may be stored as int64 or float64 (common in JSON-based JWT libraries).
+func getIntClaim(token jwt.Token, key string) int {
+ var v int
+ if err := token.Get(key, &v); err == nil {
+ return v
+ }
+ var v64 int64
+ if err := token.Get(key, &v64); err == nil {
+ return int(v64)
+ }
+ var f float64
+ if err := token.Get(key, &f); err == nil {
+ return int(f)
+ }
+ return 0
+}
+
+func (s *deciderService) CreateTranscodeParams(decision *TranscodeDecision) (string, error) {
+ return auth.EncodeToken(decision.toClaimsMap())
+}
+
+func (s *deciderService) parseTranscodeParams(tokenStr string) (*params, error) {
+ token, err := auth.DecodeAndVerifyToken(tokenStr)
+ if err != nil {
+ return nil, err
+ }
+ return paramsFromToken(token)
+}
+
+func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) {
+ p, err := s.parseTranscodeParams(token)
+ if err != nil {
+ return Request{}, errors.Join(ErrTokenInvalid, err)
+ }
+ if p.MediaID != mf.ID {
+ return Request{}, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mf.ID)
+ }
+ if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) {
+ log.Info(ctx, "Transcode token is stale", "mediaID", mf.ID,
+ "tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt)
+ return Request{}, ErrTokenStale
+ }
+
+ req := Request{Offset: offset}
+ if !p.DirectPlay && p.TargetFormat != "" {
+ req.Format = p.TargetFormat
+ req.BitRate = p.TargetBitrate
+ req.SampleRate = p.TargetSampleRate
+ req.BitDepth = p.TargetBitDepth
+ req.Channels = p.TargetChannels
+ }
+ return req, nil
+}
diff --git a/core/stream/token_test.go b/core/stream/token_test.go
new file mode 100644
index 000000000..7409a7532
--- /dev/null
+++ b/core/stream/token_test.go
@@ -0,0 +1,256 @@
+package stream
+
+import (
+ "context"
+ "time"
+
+ "github.com/go-chi/jwtauth/v5"
+ "github.com/navidrome/navidrome/core/auth"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Token", func() {
+ var (
+ ds *tests.MockDataStore
+ ff *tests.MockFFmpeg
+ svc TranscodeDecider
+ ctx context.Context
+ )
+
+ BeforeEach(func() {
+ ctx = GinkgoT().Context()
+ ds = &tests.MockDataStore{
+ MockedProperty: &tests.MockedPropertyRepo{},
+ MockedTranscoding: &tests.MockTranscodingRepo{},
+ }
+ ff = tests.NewMockFFmpeg("")
+ auth.Init(ds)
+ svc = NewTranscodeDecider(ds, ff)
+ })
+
+ Describe("Token round-trip", func() {
+ var (
+ sourceTime time.Time
+ impl *deciderService
+ )
+
+ BeforeEach(func() {
+ sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC)
+ impl = svc.(*deciderService)
+ })
+
+ It("creates and parses a direct play token", func() {
+ decision := &TranscodeDecision{
+ MediaID: "media-123",
+ CanDirectPlay: true,
+ SourceUpdatedAt: sourceTime,
+ }
+ token, err := svc.CreateTranscodeParams(decision)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(token).ToNot(BeEmpty())
+
+ params, err := impl.parseTranscodeParams(token)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(params.MediaID).To(Equal("media-123"))
+ Expect(params.DirectPlay).To(BeTrue())
+ Expect(params.TargetFormat).To(BeEmpty())
+ Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix()))
+ })
+
+ It("creates and parses a transcode token with kbps bitrate", func() {
+ decision := &TranscodeDecision{
+ MediaID: "media-456",
+ CanDirectPlay: false,
+ CanTranscode: true,
+ TargetFormat: "mp3",
+ TargetBitrate: 256, // kbps
+ TargetChannels: 2,
+ SourceUpdatedAt: sourceTime,
+ }
+ token, err := svc.CreateTranscodeParams(decision)
+ Expect(err).ToNot(HaveOccurred())
+
+ params, err := impl.parseTranscodeParams(token)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(params.MediaID).To(Equal("media-456"))
+ Expect(params.DirectPlay).To(BeFalse())
+ Expect(params.TargetFormat).To(Equal("mp3"))
+ Expect(params.TargetBitrate).To(Equal(256)) // kbps
+ Expect(params.TargetChannels).To(Equal(2))
+ Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix()))
+ })
+
+ It("creates and parses a transcode token with sample rate", func() {
+ decision := &TranscodeDecision{
+ MediaID: "media-789",
+ CanDirectPlay: false,
+ CanTranscode: true,
+ TargetFormat: "flac",
+ TargetBitrate: 0,
+ TargetChannels: 2,
+ TargetSampleRate: 48000,
+ SourceUpdatedAt: sourceTime,
+ }
+ token, err := svc.CreateTranscodeParams(decision)
+ Expect(err).ToNot(HaveOccurred())
+
+ params, err := impl.parseTranscodeParams(token)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(params.MediaID).To(Equal("media-789"))
+ Expect(params.DirectPlay).To(BeFalse())
+ Expect(params.TargetFormat).To(Equal("flac"))
+ Expect(params.TargetSampleRate).To(Equal(48000))
+ Expect(params.TargetChannels).To(Equal(2))
+ })
+
+ It("creates and parses a transcode token with bit depth", func() {
+ decision := &TranscodeDecision{
+ MediaID: "media-bd",
+ CanDirectPlay: false,
+ CanTranscode: true,
+ TargetFormat: "flac",
+ TargetBitrate: 0,
+ TargetChannels: 2,
+ TargetBitDepth: 24,
+ SourceUpdatedAt: sourceTime,
+ }
+ token, err := svc.CreateTranscodeParams(decision)
+ Expect(err).ToNot(HaveOccurred())
+
+ params, err := impl.parseTranscodeParams(token)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(params.MediaID).To(Equal("media-bd"))
+ Expect(params.TargetBitDepth).To(Equal(24))
+ })
+
+ It("omits bit depth from token when 0", func() {
+ decision := &TranscodeDecision{
+ MediaID: "media-nobd",
+ CanDirectPlay: false,
+ CanTranscode: true,
+ TargetFormat: "mp3",
+ TargetBitrate: 256,
+ TargetBitDepth: 0,
+ SourceUpdatedAt: sourceTime,
+ }
+ token, err := svc.CreateTranscodeParams(decision)
+ Expect(err).ToNot(HaveOccurred())
+
+ params, err := impl.parseTranscodeParams(token)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(params.TargetBitDepth).To(Equal(0))
+ })
+
+ It("omits sample rate from token when 0", func() {
+ decision := &TranscodeDecision{
+ MediaID: "media-100",
+ CanDirectPlay: false,
+ CanTranscode: true,
+ TargetFormat: "mp3",
+ TargetBitrate: 256,
+ TargetSampleRate: 0,
+ SourceUpdatedAt: sourceTime,
+ }
+ token, err := svc.CreateTranscodeParams(decision)
+ Expect(err).ToNot(HaveOccurred())
+
+ params, err := impl.parseTranscodeParams(token)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(params.TargetSampleRate).To(Equal(0))
+ })
+
+ It("truncates SourceUpdatedAt to seconds", func() {
+ timeWithNanos := time.Date(2025, 6, 15, 10, 30, 0, 123456789, time.UTC)
+ decision := &TranscodeDecision{
+ MediaID: "media-trunc",
+ CanDirectPlay: true,
+ SourceUpdatedAt: timeWithNanos,
+ }
+ token, err := svc.CreateTranscodeParams(decision)
+ Expect(err).ToNot(HaveOccurred())
+
+ params, err := impl.parseTranscodeParams(token)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(params.SourceUpdatedAt.Unix()).To(Equal(timeWithNanos.Truncate(time.Second).Unix()))
+ })
+
+ It("rejects an invalid token", func() {
+ _, err := impl.parseTranscodeParams("invalid-token")
+ Expect(err).To(HaveOccurred())
+ })
+ })
+
+ Describe("ResolveRequestFromToken", func() {
+ var sourceTime time.Time
+
+ BeforeEach(func() {
+ sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC)
+ })
+
+ createTokenForMedia := func(mediaID string, updatedAt time.Time) string {
+ decision := &TranscodeDecision{
+ MediaID: mediaID,
+ CanDirectPlay: true,
+ SourceUpdatedAt: updatedAt,
+ }
+ token, err := svc.CreateTranscodeParams(decision)
+ Expect(err).ToNot(HaveOccurred())
+ return token
+ }
+
+ It("returns stream request for valid token", func() {
+ mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime}
+ token := createTokenForMedia("song-1", sourceTime)
+
+ req, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(req.Format).To(BeEmpty()) // direct play has no target format
+ })
+
+ It("returns ErrTokenInvalid for invalid token", func() {
+ mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime}
+ _, err := svc.ResolveRequestFromToken(ctx, "bad-token", mf, 0)
+ Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error())))
+ })
+
+ It("returns ErrTokenInvalid when mediaID does not match token", func() {
+ mf := &model.MediaFile{ID: "song-2", UpdatedAt: sourceTime}
+ token := createTokenForMedia("song-1", sourceTime)
+
+ _, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
+ Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error())))
+ })
+
+ It("returns ErrTokenStale when media file has changed", func() {
+ newTime := sourceTime.Add(1 * time.Hour)
+ mf := &model.MediaFile{ID: "song-1", UpdatedAt: newTime}
+ token := createTokenForMedia("song-1", sourceTime)
+
+ _, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
+ Expect(err).To(MatchError(ErrTokenStale))
+ })
+ })
+
+ Describe("paramsFromToken", func() {
+ It("returns error when media ID is missing", func() {
+ tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
+ token, _, err := tokenAuth.Encode(map[string]any{"ua": int64(1700000000)})
+ Expect(err).NotTo(HaveOccurred())
+
+ _, err = paramsFromToken(token)
+ Expect(err).To(MatchError(ContainSubstring("missing media ID")))
+ })
+
+ It("returns error when source timestamp is missing", func() {
+ tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
+ token, _, err := tokenAuth.Encode(map[string]any{"mid": "song-5"})
+ Expect(err).NotTo(HaveOccurred())
+
+ _, err = paramsFromToken(token)
+ Expect(err).To(MatchError(ContainSubstring("missing source timestamp")))
+ })
+ })
+})
diff --git a/core/stream/types.go b/core/stream/types.go
new file mode 100644
index 000000000..bd8ce292c
--- /dev/null
+++ b/core/stream/types.go
@@ -0,0 +1,145 @@
+package stream
+
+import (
+ "errors"
+ "strings"
+ "time"
+)
+
+var (
+ ErrTokenInvalid = errors.New("invalid or expired transcode token")
+ ErrTokenStale = errors.New("transcode token is stale: media file has changed")
+)
+
+// TranscodeOptions controls optional behavior of MakeTranscodeDecision.
+type TranscodeOptions struct {
+ // SkipProbe prevents MakeTranscodeDecision from running ffprobe on the media file.
+ // When true, source stream details are derived from tag metadata only.
+ SkipProbe bool
+}
+
+// Request contains the resolved parameters for creating a media stream.
+type Request struct {
+ Format string
+ BitRate int // kbps
+ SampleRate int
+ BitDepth int
+ Channels int
+ Offset int // seconds
+}
+
+// ClientInfo represents client playback capabilities.
+// All bitrate values are in kilobits per second (kbps)
+type ClientInfo struct {
+ Name string
+ Platform string
+ MaxAudioBitrate int
+ MaxTranscodingAudioBitrate int
+ DirectPlayProfiles []DirectPlayProfile
+ TranscodingProfiles []Profile
+ CodecProfiles []CodecProfile
+}
+
+// DirectPlayProfile describes a format the client can play directly
+type DirectPlayProfile struct {
+ Containers []string
+ AudioCodecs []string
+ Protocols []string
+ MaxAudioChannels int
+}
+
+func (p DirectPlayProfile) String() string {
+ containers := strings.Join(p.Containers, ",")
+ if containers == "" {
+ containers = "*"
+ }
+ codecs := strings.Join(p.AudioCodecs, ",")
+ if codecs == "" {
+ return "[" + containers + "]"
+ }
+ return "[" + containers + "/" + codecs + "]"
+}
+
+// Profile describes a transcoding target the client supports
+type Profile struct {
+ Container string
+ AudioCodec string
+ Protocol string
+ MaxAudioChannels int
+}
+
+// CodecProfile describes codec-specific limitations
+type CodecProfile struct {
+ Type string
+ Name string
+ Limitations []Limitation
+}
+
+// Limitation describes a specific codec limitation
+type Limitation struct {
+ Name string
+ Comparison string
+ Values []string
+ Required bool
+}
+
+// Protocol values (OpenSubsonic spec enum)
+const (
+ ProtocolHTTP = "http"
+ ProtocolHLS = "hls"
+)
+
+// Comparison operators (OpenSubsonic spec enum)
+const (
+ ComparisonEquals = "Equals"
+ ComparisonNotEquals = "NotEquals"
+ ComparisonLessThanEqual = "LessThanEqual"
+ ComparisonGreaterThanEqual = "GreaterThanEqual"
+)
+
+// Limitation names (OpenSubsonic spec enum)
+const (
+ LimitationAudioChannels = "audioChannels"
+ LimitationAudioBitrate = "audioBitrate"
+ LimitationAudioProfile = "audioProfile"
+ LimitationAudioSamplerate = "audioSamplerate"
+ LimitationAudioBitdepth = "audioBitdepth"
+)
+
+// Codec profile types (OpenSubsonic spec enum)
+const (
+ CodecProfileTypeAudio = "AudioCodec"
+)
+
+// TranscodeDecision represents the internal decision result.
+// All bitrate values are in kilobits per second (kbps).
+type TranscodeDecision struct {
+ MediaID string
+ CanDirectPlay bool
+ CanTranscode bool
+ TranscodeReasons []string
+ ErrorReason string
+ TargetFormat string
+ TargetBitrate int
+ TargetChannels int
+ TargetSampleRate int
+ TargetBitDepth int
+ SourceStream Details
+ SourceUpdatedAt time.Time
+ TranscodeStream *Details
+}
+
+// Details describes audio stream properties.
+// Bitrate is in kilobits per second (kbps).
+type Details struct {
+ Container string
+ Codec string
+ Profile string // Audio profile (e.g., "LC", "HE-AACv2"). Populated from ffprobe data.
+ Bitrate int
+ SampleRate int
+ BitDepth int
+ Channels int
+ Duration float32
+ Size int64
+ IsLossless bool
+}
diff --git a/core/user.go b/core/user.go
new file mode 100644
index 000000000..f13e90167
--- /dev/null
+++ b/core/user.go
@@ -0,0 +1,76 @@
+package core
+
+import (
+ "context"
+
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/model"
+)
+
+// PluginUnloader defines the interface for unloading disabled plugins.
+// This is satisfied by plugins.Manager but defined here to avoid import cycles.
+type PluginUnloader interface {
+ UnloadDisabledPlugins(ctx context.Context)
+}
+
+// User provides business logic for user management with plugin coordination.
+type User interface {
+ NewRepository(ctx context.Context) rest.Repository
+}
+
+type userService struct {
+ ds model.DataStore
+ pluginManager PluginUnloader
+}
+
+// NewUser creates a new User service
+func NewUser(ds model.DataStore, pluginManager PluginUnloader) User {
+ return &userService{
+ ds: ds,
+ pluginManager: pluginManager,
+ }
+}
+
+// NewRepository returns a REST repository wrapper for user operations.
+// The wrapper intercepts Delete operations to coordinate plugin unloading.
+func (s *userService) NewRepository(ctx context.Context) rest.Repository {
+ repo := s.ds.User(ctx)
+ wrapper := &userRepositoryWrapper{
+ ctx: ctx,
+ UserRepository: repo,
+ pluginManager: s.pluginManager,
+ }
+ return wrapper
+}
+
+type userRepositoryWrapper struct {
+ model.UserRepository
+ ctx context.Context
+ pluginManager PluginUnloader
+}
+
+// Save implements rest.Persistable by delegating to the underlying repository.
+func (r *userRepositoryWrapper) Save(entity any) (string, error) {
+ return r.UserRepository.(rest.Persistable).Save(entity)
+}
+
+// Update implements rest.Persistable by delegating to the underlying repository.
+func (r *userRepositoryWrapper) Update(id string, entity any, cols ...string) error {
+ return r.UserRepository.(rest.Persistable).Update(id, entity, cols...)
+}
+
+// Delete implements rest.Persistable and coordinates plugin unloading.
+func (r *userRepositoryWrapper) Delete(id string) error {
+ // The underlying repository Delete handles the database cleanup
+ // including calling cleanupPluginUserReferences
+ err := r.UserRepository.(rest.Persistable).Delete(id)
+ if err != nil {
+ return err
+ }
+
+ // After successful deletion, check if any plugins were auto-disabled
+ // and need to be unloaded from memory
+ r.pluginManager.UnloadDisabledPlugins(r.ctx)
+
+ return nil
+}
diff --git a/core/user_test.go b/core/user_test.go
new file mode 100644
index 000000000..b2d3117f8
--- /dev/null
+++ b/core/user_test.go
@@ -0,0 +1,86 @@
+package core_test
+
+import (
+ "context"
+ "errors"
+
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/core"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("User Service", func() {
+ var service core.User
+ var ds *tests.MockDataStore
+ var userRepo *tests.MockedUserRepo
+ var pluginManager *mockPluginUnloader
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ds = &tests.MockDataStore{}
+ userRepo = tests.CreateMockUserRepo()
+ ds.MockedUser = userRepo
+ pluginManager = &mockPluginUnloader{}
+ service = core.NewUser(ds, pluginManager)
+ ctx = GinkgoT().Context()
+ })
+
+ Describe("NewRepository", func() {
+ It("returns a rest.Persistable", func() {
+ repo := service.NewRepository(ctx)
+ _, ok := repo.(rest.Persistable)
+ Expect(ok).To(BeTrue())
+ })
+ })
+
+ Describe("Delete", func() {
+ var repo rest.Persistable
+
+ BeforeEach(func() {
+ r := service.NewRepository(ctx)
+ repo = r.(rest.Persistable)
+
+ // Add a test user
+ user := &model.User{
+ ID: "user-123",
+ UserName: "testuser",
+ IsAdmin: false,
+ }
+ user.NewPassword = "password"
+ Expect(userRepo.Put(user)).To(Succeed())
+ })
+
+ It("deletes the user successfully", func() {
+ err := repo.Delete("user-123")
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify user is deleted
+ _, err = userRepo.Get("user-123")
+ Expect(err).To(Equal(model.ErrNotFound))
+ })
+
+ It("calls UnloadDisabledPlugins after successful deletion", func() {
+ err := repo.Delete("user-123")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(pluginManager.unloadCalls).To(Equal(1))
+ })
+
+ It("does not call UnloadDisabledPlugins when deletion fails", func() {
+ // Try to delete non-existent user
+ err := repo.Delete("non-existent")
+ Expect(err).To(HaveOccurred())
+ Expect(pluginManager.unloadCalls).To(Equal(0))
+ })
+
+ It("returns error when repository fails", func() {
+ userRepo.Error = errors.New("database error")
+ err := repo.Delete("user-123")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("database error"))
+ Expect(pluginManager.unloadCalls).To(Equal(0))
+ })
+ })
+})
diff --git a/core/wire_providers.go b/core/wire_providers.go
index 482cfbefe..a2fffa34f 100644
--- a/core/wire_providers.go
+++ b/core/wire_providers.go
@@ -5,23 +5,35 @@ import (
"github.com/navidrome/navidrome/core/agents"
"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/stream"
)
var Set = wire.NewSet(
- NewMediaStreamer,
- GetTranscodingCache,
+ stream.NewMediaStreamer,
+ stream.GetTranscodingCache,
NewArchiver,
NewPlayers,
NewShare,
- NewPlaylists,
+ playlists.NewPlaylists,
+ NewLibrary,
+ NewUser,
+ NewMaintenance,
+ NewImageUploadService,
+ wire.Bind(new(playlists.ImageUploadService), new(ImageUploadService)),
+ stream.NewTranscodeDecider,
agents.GetAgents,
external.NewProvider,
+ matcher.New,
wire.Bind(new(external.Agents), new(*agents.Agents)),
ffmpeg.New,
scrobbler.GetPlayTracker,
playback.GetInstance,
metrics.GetInstance,
+ lyrics.NewLyrics,
)
diff --git a/db/backup.go b/db/backup.go
index 8b0f18b1b..806bef8e2 100644
--- a/db/backup.go
+++ b/db/backup.go
@@ -27,7 +27,7 @@ const backupSuffixLayout = "2006.01.02_15.04.05"
func backupPath(t time.Time) string {
return filepath.Join(
- conf.Server.Backup.Path,
+ conf.Server.Backup.Path.MustPath(),
fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)),
)
}
@@ -81,12 +81,12 @@ func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
// Caution: -1 means that sqlite will hold a read lock until the operation finishes
// This will lock out other writes that could happen at the same time
done, err := backupOp.Step(-1)
- if !done {
- return fmt.Errorf("backup not done with step -1")
- }
if err != nil {
return fmt.Errorf("error during backup step: %w", err)
}
+ if !done {
+ return fmt.Errorf("backup not done with step -1")
+ }
err = backupOp.Finish()
if err != nil {
@@ -117,7 +117,11 @@ func Restore(ctx context.Context, path string) error {
}
func Prune(ctx context.Context) (int, error) {
- files, err := os.ReadDir(conf.Server.Backup.Path)
+ backupDir, err := conf.Server.Backup.Path.Path()
+ if err != nil {
+ return 0, fmt.Errorf("backup directory not available: %w", err)
+ }
+ files, err := os.ReadDir(backupDir)
if err != nil {
return 0, fmt.Errorf("unable to read database backup entries: %w", err)
}
diff --git a/db/backup_test.go b/db/backup_test.go
index aec43446d..5e8f877e6 100644
--- a/db/backup_test.go
+++ b/db/backup_test.go
@@ -60,7 +60,7 @@ var _ = Describe("database backups", func() {
tempFolder, err := os.MkdirTemp("", "navidrome_backup")
Expect(err).ToNot(HaveOccurred())
- conf.Server.Backup.Path = tempFolder
+ conf.Server.Backup.Path = conf.NewDir(tempFolder)
DeferCleanup(func() {
_ = os.RemoveAll(tempFolder)
@@ -118,7 +118,7 @@ var _ = Describe("database backups", func() {
BeforeEach(func() {
tempFolder, err := os.MkdirTemp("", "navidrome_backup")
Expect(err).ToNot(HaveOccurred())
- conf.Server.Backup.Path = tempFolder
+ conf.Server.Backup.Path = conf.NewDir(tempFolder)
DeferCleanup(func() {
_ = os.RemoveAll(tempFolder)
diff --git a/db/db.go b/db/db.go
index cb1ebd9e3..168c12122 100644
--- a/db/db.go
+++ b/db/db.go
@@ -38,6 +38,8 @@ func Db() *sql.DB {
if Path == ":memory:" {
Path = "file::memory:?cache=shared&_foreign_keys=on"
conf.Server.DbPath = Path
+ } else {
+ conf.Server.DataFolder.MustPath()
}
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
db, err := sql.Open(Driver, Path)
@@ -45,10 +47,12 @@ func Db() *sql.DB {
if err != nil {
log.Fatal("Error opening database", err)
}
- _, err = db.Exec("PRAGMA optimize=0x10002")
- if err != nil {
- log.Error("Error applying PRAGMA optimize", err)
- return nil
+ if conf.Server.DevOptimizeDB {
+ _, err = db.Exec("PRAGMA optimize=0x10002")
+ if err != nil {
+ log.Error("Error applying PRAGMA optimize", err)
+ return nil
+ }
}
return db
})
@@ -99,7 +103,7 @@ func Init(ctx context.Context) func() {
log.Fatal(ctx, "Failed to apply new migrations", err)
}
- if hasSchemaChanges {
+ if hasSchemaChanges && conf.Server.DevOptimizeDB {
log.Debug(ctx, "Applying PRAGMA optimize after schema changes")
_, err = db.ExecContext(ctx, "PRAGMA optimize")
if err != nil {
@@ -114,6 +118,9 @@ func Init(ctx context.Context) func() {
// Optimize runs PRAGMA optimize on each connection in the pool
func Optimize(ctx context.Context) {
+ if !conf.Server.DevOptimizeDB {
+ return
+ }
numConns := Db().Stats().OpenConnections
if numConns == 0 {
log.Debug(ctx, "No open connections to optimize")
@@ -121,7 +128,7 @@ func Optimize(ctx context.Context) {
}
log.Debug(ctx, "Optimizing open connections", "numConns", numConns)
var conns []*sql.Conn
- for i := 0; i < numConns; i++ {
+ for range numConns {
conn, err := Db().Conn(ctx)
conns = append(conns, conn)
if err != nil {
@@ -142,8 +149,8 @@ func Optimize(ctx context.Context) {
type statusLogger struct{ numPending int }
-func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) }
-func (l *statusLogger) Printf(format string, v ...interface{}) {
+func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) }
+func (l *statusLogger) Printf(format string, v ...any) {
if len(v) < 1 {
return
}
@@ -178,27 +185,27 @@ type logAdapter struct {
silent bool
}
-func (l *logAdapter) Fatal(v ...interface{}) {
+func (l *logAdapter) Fatal(v ...any) {
log.Fatal(l.ctx, fmt.Sprint(v...))
}
-func (l *logAdapter) Fatalf(format string, v ...interface{}) {
+func (l *logAdapter) Fatalf(format string, v ...any) {
log.Fatal(l.ctx, fmt.Sprintf(format, v...))
}
-func (l *logAdapter) Print(v ...interface{}) {
+func (l *logAdapter) Print(v ...any) {
if !l.silent {
log.Info(l.ctx, fmt.Sprint(v...))
}
}
-func (l *logAdapter) Println(v ...interface{}) {
+func (l *logAdapter) Println(v ...any) {
if !l.silent {
log.Info(l.ctx, fmt.Sprintln(v...))
}
}
-func (l *logAdapter) Printf(format string, v ...interface{}) {
+func (l *logAdapter) Printf(format string, v ...any) {
if !l.silent {
log.Info(l.ctx, fmt.Sprintf(format, v...))
}
diff --git a/db/migrations/20241026183640_support_new_scanner.go b/db/migrations/20241026183640_support_new_scanner.go
index 251b27f63..fcbef7e4e 100644
--- a/db/migrations/20241026183640_support_new_scanner.go
+++ b/db/migrations/20241026183640_support_new_scanner.go
@@ -13,7 +13,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/utils/chain"
+ "github.com/navidrome/navidrome/utils/run"
"github.com/pressly/goose/v3"
)
@@ -25,7 +25,7 @@ func upSupportNewScanner(ctx context.Context, tx *sql.Tx) error {
execute := createExecuteFunc(ctx, tx)
addColumn := createAddColumnFunc(ctx, tx)
- return chain.RunSequentially(
+ return run.Sequentially(
upSupportNewScanner_CreateTableFolder(ctx, execute),
upSupportNewScanner_PopulateTableFolder(ctx, tx),
upSupportNewScanner_UpdateTableMediaFile(ctx, execute, addColumn),
@@ -213,7 +213,7 @@ update media_file set path = replace(substr(path, %d), '\', '/');`, libPathLen+2
func upSupportNewScanner_UpdateTableMediaFile(_ context.Context, execute execStmtFunc, addColumn addColumnFunc) execFunc {
return func() error {
- return chain.RunSequentially(
+ return run.Sequentially(
execute(`
alter table media_file
add column folder_id varchar default '' not null;
@@ -288,7 +288,7 @@ create index if not exists album_mbz_release_group_id
func upSupportNewScanner_UpdateTableArtist(_ context.Context, execute execStmtFunc, addColumn addColumnFunc) execFunc {
return func() error {
- return chain.RunSequentially(
+ return run.Sequentially(
execute(`
alter table artist
drop column album_count;
diff --git a/db/migrations/20250611010101_playqueue_current_to_index.go b/db/migrations/20250611010101_playqueue_current_to_index.go
new file mode 100644
index 000000000..d9250eba2
--- /dev/null
+++ b/db/migrations/20250611010101_playqueue_current_to_index.go
@@ -0,0 +1,80 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+ "strings"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upPlayQueueCurrentToIndex, downPlayQueueCurrentToIndex)
+}
+
+func upPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `
+create table playqueue_dg_tmp(
+ id varchar(255) not null,
+ user_id varchar(255) not null
+ references user(id)
+ on update cascade on delete cascade,
+ current integer not null default 0,
+ position real,
+ changed_by varchar(255),
+ items varchar(255),
+ created_at datetime,
+ updated_at datetime
+);`)
+ if err != nil {
+ return err
+ }
+
+ rows, err := tx.QueryContext(ctx, `select id, user_id, current, position, changed_by, items, created_at, updated_at from playqueue`)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ stmt, err := tx.PrepareContext(ctx, `insert into playqueue_dg_tmp(id, user_id, current, position, changed_by, items, created_at, updated_at) values(?,?,?,?,?,?,?,?)`)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+
+ for rows.Next() {
+ var id, userID, currentID, changedBy, items string
+ var position sql.NullFloat64
+ var createdAt, updatedAt sql.NullString
+ if err = rows.Scan(&id, &userID, ¤tID, &position, &changedBy, &items, &createdAt, &updatedAt); err != nil {
+ return err
+ }
+ index := 0
+ if currentID != "" && items != "" {
+ parts := strings.Split(items, ",")
+ for i, p := range parts {
+ if p == currentID {
+ index = i
+ break
+ }
+ }
+ }
+ _, err = stmt.Exec(id, userID, index, position, changedBy, items, createdAt, updatedAt)
+ if err != nil {
+ return err
+ }
+ }
+ if err = rows.Err(); err != nil {
+ return err
+ }
+
+ if _, err = tx.ExecContext(ctx, `drop table playqueue;`); err != nil {
+ return err
+ }
+ _, err = tx.ExecContext(ctx, `alter table playqueue_dg_tmp rename to playqueue;`)
+ return err
+}
+
+func downPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error {
+ return nil
+}
diff --git a/db/migrations/20250701010101_add_folder_hash.go b/db/migrations/20250701010101_add_folder_hash.go
new file mode 100644
index 000000000..e82a0749f
--- /dev/null
+++ b/db/migrations/20250701010101_add_folder_hash.go
@@ -0,0 +1,21 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upAddFolderHash, downAddFolderHash)
+}
+
+func upAddFolderHash(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `alter table folder add column hash varchar default '' not null;`)
+ return err
+}
+
+func downAddFolderHash(ctx context.Context, tx *sql.Tx) error {
+ return nil
+}
diff --git a/db/migrations/20250701010102_add_annotation_user_foreign_key.sql b/db/migrations/20250701010102_add_annotation_user_foreign_key.sql
new file mode 100644
index 000000000..114de2a88
--- /dev/null
+++ b/db/migrations/20250701010102_add_annotation_user_foreign_key.sql
@@ -0,0 +1,46 @@
+-- +goose Up
+-- +goose StatementBegin
+CREATE TABLE IF NOT EXISTS annotation_tmp
+(
+ user_id varchar(255) not null
+ REFERENCES user(id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE,
+ item_id varchar(255) default '' not null,
+ item_type varchar(255) default '' not null,
+ play_count integer default 0,
+ play_date datetime,
+ rating integer default 0,
+ starred bool default FALSE not null,
+ starred_at datetime,
+ unique (user_id, item_id, item_type)
+);
+
+
+INSERT INTO annotation_tmp(
+ user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at
+)
+SELECT user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at
+FROM annotation
+WHERE user_id IN (
+ SELECT id FROM user
+);
+
+DROP TABLE annotation;
+ALTER TABLE annotation_tmp RENAME TO annotation;
+
+CREATE INDEX annotation_play_count
+ on annotation (play_count);
+CREATE INDEX annotation_play_date
+ on annotation (play_date);
+CREATE INDEX annotation_rating
+ on annotation (rating);
+CREATE INDEX annotation_starred
+ on annotation (starred);
+CREATE INDEX annotation_starred_at
+ on annotation (starred_at);
+
+-- +goose StatementEnd
+
+-- +goose Down
+
diff --git a/db/migrations/20250701010103_add_library_stats.go b/db/migrations/20250701010103_add_library_stats.go
new file mode 100644
index 000000000..8025229cc
--- /dev/null
+++ b/db/migrations/20250701010103_add_library_stats.go
@@ -0,0 +1,48 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upAddLibraryStats, downAddLibraryStats)
+}
+
+func upAddLibraryStats(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `
+alter table library add column total_songs integer default 0 not null;
+alter table library add column total_albums integer default 0 not null;
+alter table library add column total_artists integer default 0 not null;
+alter table library add column total_folders integer default 0 not null;
+ alter table library add column total_files integer default 0 not null;
+ alter table library add column total_missing_files integer default 0 not null;
+ alter table library add column total_size integer default 0 not null;
+update library set
+ total_songs = (
+ select count(*) from media_file where library_id = library.id and missing = 0
+ ),
+ total_albums = (select count(*) from album where library_id = library.id and missing = 0),
+ total_artists = (
+ select count(*) from library_artist la
+ join artist a on la.artist_id = a.id
+ where la.library_id = library.id and a.missing = 0
+ ),
+ total_folders = (select count(*) from folder where library_id = library.id and missing = 0 and num_audio_files > 0),
+ total_files = (
+ select ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0)
+ from folder where library_id = library.id and missing = 0
+ ),
+ total_missing_files = (
+ select count(*) from media_file where library_id = library.id and missing = 1
+ ),
+ total_size = (select ifnull(sum(size),0) from album where library_id = library.id and missing = 0);
+`)
+ return err
+}
+
+func downAddLibraryStats(ctx context.Context, tx *sql.Tx) error {
+ return nil
+}
diff --git a/db/migrations/20250701010104_make_replaygain_fields_nullable.go b/db/migrations/20250701010104_make_replaygain_fields_nullable.go
new file mode 100644
index 000000000..163608d32
--- /dev/null
+++ b/db/migrations/20250701010104_make_replaygain_fields_nullable.go
@@ -0,0 +1,49 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upMakeReplaygainFieldsNullable, downMakeReplaygainFieldsNullable)
+}
+
+func upMakeReplaygainFieldsNullable(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `
+ALTER TABLE media_file ADD COLUMN rg_album_gain_new real;
+ALTER TABLE media_file ADD COLUMN rg_album_peak_new real;
+ALTER TABLE media_file ADD COLUMN rg_track_gain_new real;
+ALTER TABLE media_file ADD COLUMN rg_track_peak_new real;
+
+UPDATE media_file SET
+ rg_album_gain_new = rg_album_gain,
+ rg_album_peak_new = rg_album_peak,
+ rg_track_gain_new = rg_track_gain,
+ rg_track_peak_new = rg_track_peak;
+
+ALTER TABLE media_file DROP COLUMN rg_album_gain;
+ALTER TABLE media_file DROP COLUMN rg_album_peak;
+ALTER TABLE media_file DROP COLUMN rg_track_gain;
+ALTER TABLE media_file DROP COLUMN rg_track_peak;
+
+ALTER TABLE media_file RENAME COLUMN rg_album_gain_new TO rg_album_gain;
+ALTER TABLE media_file RENAME COLUMN rg_album_peak_new TO rg_album_peak;
+ALTER TABLE media_file RENAME COLUMN rg_track_gain_new TO rg_track_gain;
+ALTER TABLE media_file RENAME COLUMN rg_track_peak_new TO rg_track_peak;
+ `)
+
+ if err != nil {
+ return err
+ }
+
+ notice(tx, "Fetching replaygain fields properly will require a full scan")
+ return nil
+}
+
+func downMakeReplaygainFieldsNullable(ctx context.Context, tx *sql.Tx) error {
+ // This code is executed when the migration is rolled back.
+ return nil
+}
diff --git a/db/migrations/20250701010105_remove_dangling_items.sql b/db/migrations/20250701010105_remove_dangling_items.sql
new file mode 100644
index 000000000..aede49b6e
--- /dev/null
+++ b/db/migrations/20250701010105_remove_dangling_items.sql
@@ -0,0 +1,7 @@
+-- +goose Up
+-- +goose StatementBegin
+update media_file set missing = 1 where folder_id = '';
+update album set missing = 1 where folder_ids = '[]';
+-- +goose StatementEnd
+
+-- +goose Down
diff --git a/db/migrations/20250701010106_add_participant_stats_to_all_artists.sql b/db/migrations/20250701010106_add_participant_stats_to_all_artists.sql
new file mode 100644
index 000000000..1cd67dc32
--- /dev/null
+++ b/db/migrations/20250701010106_add_participant_stats_to_all_artists.sql
@@ -0,0 +1,65 @@
+-- +goose Up
+-- +goose StatementBegin
+WITH artist_role_counters AS (
+ SELECT jt.atom AS artist_id,
+ substr(
+ replace(jt.path, '$.', ''),
+ 1,
+ CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0
+ THEN instr(replace(jt.path, '$.', ''), '[') - 1
+ ELSE length(replace(jt.path, '$.', ''))
+ END
+ ) AS role,
+ count(DISTINCT mf.album_id) AS album_count,
+ count(mf.id) AS count,
+ sum(mf.size) AS size
+ FROM media_file mf
+ JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL
+ GROUP BY jt.atom, role
+),
+artist_total_counters AS (
+ SELECT mfa.artist_id,
+ 'total' AS role,
+ count(DISTINCT mf.album_id) AS album_count,
+ count(DISTINCT mf.id) AS count,
+ sum(mf.size) AS size
+ FROM media_file_artists mfa
+ JOIN media_file mf ON mfa.media_file_id = mf.id
+ GROUP BY mfa.artist_id
+),
+artist_participant_counter AS (
+ SELECT mfa.artist_id,
+ 'maincredit' AS role,
+ count(DISTINCT mf.album_id) AS album_count,
+ count(DISTINCT mf.id) AS count,
+ sum(mf.size) AS size
+ FROM media_file_artists mfa
+ JOIN media_file mf ON mfa.media_file_id = mf.id
+ AND mfa.role IN ('albumartist', 'artist')
+ GROUP BY mfa.artist_id
+),
+combined_counters AS (
+ SELECT artist_id, role, album_count, count, size FROM artist_role_counters
+ UNION
+ SELECT artist_id, role, album_count, count, size FROM artist_total_counters
+ UNION
+ SELECT artist_id, role, album_count, count, size FROM artist_participant_counter
+),
+artist_counters AS (
+ SELECT artist_id AS id,
+ json_group_object(
+ replace(role, '"', ''),
+ json_object('a', album_count, 'm', count, 's', size)
+ ) AS counters
+ FROM combined_counters
+ GROUP BY artist_id
+)
+UPDATE artist
+SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'),
+ updated_at = datetime(current_timestamp, 'localtime')
+WHERE artist.id <> '';
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+-- +goose StatementEnd
diff --git a/db/migrations/20250701010107_add_mbid_indexes.sql b/db/migrations/20250701010107_add_mbid_indexes.sql
new file mode 100644
index 000000000..f8a5a444b
--- /dev/null
+++ b/db/migrations/20250701010107_add_mbid_indexes.sql
@@ -0,0 +1,27 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- Add indexes for MBID fields to improve lookup performance
+-- Artists table
+create index if not exists artist_mbz_artist_id
+ on artist (mbz_artist_id);
+
+-- Albums table
+create index if not exists album_mbz_album_id
+ on album (mbz_album_id);
+
+-- Media files table
+create index if not exists media_file_mbz_release_track_id
+ on media_file (mbz_release_track_id);
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+
+-- Remove MBID indexes
+drop index if exists artist_mbz_artist_id;
+drop index if exists album_mbz_album_id;
+drop index if exists media_file_mbz_release_track_id;
+
+-- +goose StatementEnd
diff --git a/db/migrations/20250701010108_add_multi_library_support.go b/db/migrations/20250701010108_add_multi_library_support.go
new file mode 100644
index 000000000..654784d09
--- /dev/null
+++ b/db/migrations/20250701010108_add_multi_library_support.go
@@ -0,0 +1,119 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upAddMultiLibrarySupport, downAddMultiLibrarySupport)
+}
+
+func upAddMultiLibrarySupport(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `
+ -- Create user_library association table
+ CREATE TABLE user_library (
+ user_id VARCHAR(255) NOT NULL,
+ library_id INTEGER NOT NULL,
+ PRIMARY KEY (user_id, library_id),
+ FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE,
+ FOREIGN KEY (library_id) REFERENCES library(id) ON DELETE CASCADE
+ );
+ -- Create indexes for performance
+ CREATE INDEX idx_user_library_user_id ON user_library(user_id);
+ CREATE INDEX idx_user_library_library_id ON user_library(library_id);
+
+ -- Populate with existing users having access to library ID 1 (existing setup)
+ -- Admin users get access to all libraries, regular users get access to library 1
+ INSERT INTO user_library (user_id, library_id)
+ SELECT u.id, 1
+ FROM user u;
+
+ -- Add total_duration column to library table
+ ALTER TABLE library ADD COLUMN total_duration real DEFAULT 0;
+ UPDATE library SET total_duration = (
+ SELECT IFNULL(SUM(duration),0) from album where album.library_id = library.id and missing = 0
+ );
+
+ -- Add default_new_users column to library table
+ ALTER TABLE library ADD COLUMN default_new_users boolean DEFAULT false;
+ -- Set library ID 1 (default library) as default for new users
+ UPDATE library SET default_new_users = true WHERE id = 1;
+
+ -- Add stats column to library_artist junction table for per-library artist statistics
+ ALTER TABLE library_artist ADD COLUMN stats text DEFAULT '{}';
+
+ -- Migrate existing global artist stats to per-library format in library_artist table
+ -- For each library_artist association, copy the artist's global stats
+ UPDATE library_artist
+ SET stats = (
+ SELECT COALESCE(artist.stats, '{}')
+ FROM artist
+ WHERE artist.id = library_artist.artist_id
+ );
+
+ -- Remove stats column from artist table to eliminate duplication
+ -- Stats are now stored per-library in library_artist table
+ ALTER TABLE artist DROP COLUMN stats;
+
+ -- Create library_tag table for per-library tag statistics
+ CREATE TABLE library_tag (
+ tag_id VARCHAR NOT NULL,
+ library_id INTEGER NOT NULL,
+ album_count INTEGER DEFAULT 0 NOT NULL,
+ media_file_count INTEGER DEFAULT 0 NOT NULL,
+ PRIMARY KEY (tag_id, library_id),
+ FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE,
+ FOREIGN KEY (library_id) REFERENCES library(id) ON DELETE CASCADE
+ );
+
+ -- Create indexes for optimal query performance
+ CREATE INDEX idx_library_tag_tag_id ON library_tag(tag_id);
+ CREATE INDEX idx_library_tag_library_id ON library_tag(library_id);
+
+ -- Migrate existing tag stats to per-library format in library_tag table
+ -- For existing installations, copy current global stats to library ID 1 (default library)
+ INSERT INTO library_tag (tag_id, library_id, album_count, media_file_count)
+ SELECT t.id, 1, t.album_count, t.media_file_count
+ FROM tag t
+ WHERE EXISTS (SELECT 1 FROM library WHERE id = 1);
+
+ -- Remove global stats from tag table as they are now per-library
+ ALTER TABLE tag DROP COLUMN album_count;
+ ALTER TABLE tag DROP COLUMN media_file_count;
+ `)
+
+ return err
+}
+
+func downAddMultiLibrarySupport(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `
+ -- Restore stats column to artist table before removing from library_artist
+ ALTER TABLE artist ADD COLUMN stats text DEFAULT '{}';
+
+ -- Restore global stats by aggregating from library_artist (simplified approach)
+ -- In a real rollback scenario, this might need more sophisticated logic
+ UPDATE artist
+ SET stats = (
+ SELECT COALESCE(la.stats, '{}')
+ FROM library_artist la
+ WHERE la.artist_id = artist.id
+ LIMIT 1
+ );
+
+ ALTER TABLE library_artist DROP COLUMN IF EXISTS stats;
+ DROP INDEX IF EXISTS idx_user_library_library_id;
+ DROP INDEX IF EXISTS idx_user_library_user_id;
+ DROP TABLE IF EXISTS user_library;
+ ALTER TABLE library DROP COLUMN IF EXISTS total_duration;
+ ALTER TABLE library DROP COLUMN IF EXISTS default_new_users;
+
+ -- Drop library_tag table and its indexes
+ DROP INDEX IF EXISTS idx_library_tag_library_id;
+ DROP INDEX IF EXISTS idx_library_tag_tag_id;
+ DROP TABLE IF EXISTS library_tag;
+ `)
+ return err
+}
diff --git a/db/migrations/20250823142158_make_playqueue_position_int.sql b/db/migrations/20250823142158_make_playqueue_position_int.sql
new file mode 100644
index 000000000..de20f0c79
--- /dev/null
+++ b/db/migrations/20250823142158_make_playqueue_position_int.sql
@@ -0,0 +1,9 @@
+-- +goose Up
+-- +goose StatementBegin
+ALTER TABLE playqueue ADD COLUMN position_int integer;
+UPDATE playqueue SET position_int = CAST(position as INTEGER) ;
+ALTER TABLE playqueue DROP COLUMN position;
+ALTER TABLE playqueue RENAME COLUMN position_int TO position;
+-- +goose StatementEnd
+
+-- +goose Down
diff --git a/db/migrations/20251109010105_add_annotation_rating_date.sql b/db/migrations/20251109010105_add_annotation_rating_date.sql
new file mode 100644
index 000000000..9dac46a5e
--- /dev/null
+++ b/db/migrations/20251109010105_add_annotation_rating_date.sql
@@ -0,0 +1,7 @@
+-- +goose Up
+-- +goose StatementBegin
+ALTER TABLE annotation ADD COLUMN rated_at datetime;
+-- +goose StatementEnd
+
+-- +goose Down
+
\ No newline at end of file
diff --git a/db/migrations/20251206013022_create_scrobbles_table.sql b/db/migrations/20251206013022_create_scrobbles_table.sql
new file mode 100644
index 000000000..9791c48e3
--- /dev/null
+++ b/db/migrations/20251206013022_create_scrobbles_table.sql
@@ -0,0 +1,20 @@
+-- +goose Up
+-- +goose StatementBegin
+CREATE TABLE scrobbles(
+ media_file_id VARCHAR(255) NOT NULL
+ REFERENCES media_file(id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE,
+ user_id VARCHAR(255) NOT NULL
+ REFERENCES user(id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE,
+ submission_time INTEGER NOT NULL
+);
+CREATE INDEX scrobbles_date ON scrobbles (submission_time);
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE scrobbles;
+-- +goose StatementEnd
diff --git a/db/migrations/20260104203627_playlist_case_insensitive_name.sql b/db/migrations/20260104203627_playlist_case_insensitive_name.sql
new file mode 100644
index 000000000..64b079cca
--- /dev/null
+++ b/db/migrations/20260104203627_playlist_case_insensitive_name.sql
@@ -0,0 +1,99 @@
+-- +goose Up
+-- Fix case-insensitive sorting for playlist names
+create table playlist_dg_tmp
+(
+ id varchar(255) not null
+ primary key,
+ name varchar(255) collate NOCASE default '' not null,
+ comment varchar(255) default '' not null,
+ duration real default 0 not null,
+ song_count integer default 0 not null,
+ public bool default FALSE not null,
+ created_at datetime,
+ updated_at datetime,
+ path string default '' not null,
+ sync bool default false not null,
+ size integer default 0 not null,
+ rules varchar,
+ evaluated_at datetime,
+ owner_id varchar(255) not null
+ constraint playlist_user_user_id_fk
+ references user
+ on update cascade on delete cascade
+);
+
+insert into playlist_dg_tmp(id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size,
+ rules, evaluated_at, owner_id)
+select id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at,
+ owner_id
+from playlist;
+
+drop table playlist;
+
+alter table playlist_dg_tmp
+ rename to playlist;
+
+create index playlist_name
+ on playlist (name);
+
+create index playlist_created_at
+ on playlist (created_at);
+
+create index playlist_updated_at
+ on playlist (updated_at);
+
+create index playlist_evaluated_at
+ on playlist (evaluated_at);
+
+create index playlist_size
+ on playlist (size);
+
+-- +goose Down
+-- Note: Downgrade loses the collation but preserves data
+create table playlist_dg_tmp
+(
+ id varchar(255) not null
+ primary key,
+ name varchar(255) default '' not null,
+ comment varchar(255) default '' not null,
+ duration real default 0 not null,
+ song_count integer default 0 not null,
+ public bool default FALSE not null,
+ created_at datetime,
+ updated_at datetime,
+ path string default '' not null,
+ sync bool default false not null,
+ size integer default 0 not null,
+ rules varchar,
+ evaluated_at datetime,
+ owner_id varchar(255) not null
+ constraint playlist_user_user_id_fk
+ references user
+ on update cascade on delete cascade
+);
+
+insert into playlist_dg_tmp(id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size,
+ rules, evaluated_at, owner_id)
+select id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at,
+ owner_id
+from playlist;
+
+drop table playlist;
+
+alter table playlist_dg_tmp
+ rename to playlist;
+
+create index playlist_name
+ on playlist (name);
+
+create index playlist_created_at
+ on playlist (created_at);
+
+create index playlist_updated_at
+ on playlist (updated_at);
+
+create index playlist_evaluated_at
+ on playlist (evaluated_at);
+
+create index playlist_size
+ on playlist (size);
diff --git a/db/migrations/20260106000620_create_plugin_table.sql b/db/migrations/20260106000620_create_plugin_table.sql
new file mode 100644
index 000000000..bcc83be0b
--- /dev/null
+++ b/db/migrations/20260106000620_create_plugin_table.sql
@@ -0,0 +1,19 @@
+-- +goose Up
+CREATE TABLE IF NOT EXISTS plugin (
+ id TEXT PRIMARY KEY,
+ path TEXT NOT NULL,
+ manifest JSONB NOT NULL,
+ config JSONB,
+ users JSONB,
+ all_users BOOL NOT NULL DEFAULT false,
+ libraries JSONB,
+ all_libraries BOOL NOT NULL DEFAULT false,
+ enabled BOOL NOT NULL DEFAULT false,
+ last_error TEXT,
+ sha256 TEXT NOT NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+);
+
+-- +goose Down
+DROP TABLE IF EXISTS plugin;
diff --git a/db/migrations/20260117201522_add_avg_rating_column.sql b/db/migrations/20260117201522_add_avg_rating_column.sql
new file mode 100644
index 000000000..f5c8d4522
--- /dev/null
+++ b/db/migrations/20260117201522_add_avg_rating_column.sql
@@ -0,0 +1,23 @@
+-- +goose Up
+ALTER TABLE album ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
+ALTER TABLE media_file ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
+ALTER TABLE artist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
+
+-- Populate average_rating from existing ratings
+UPDATE album SET average_rating = coalesce(
+ (SELECT round(avg(rating), 2) FROM annotation WHERE item_id = album.id AND item_type = 'album' AND rating > 0),
+ 0
+);
+UPDATE media_file SET average_rating = coalesce(
+ (SELECT round(avg(rating), 2) FROM annotation WHERE item_id = media_file.id AND item_type = 'media_file' AND rating > 0),
+ 0
+);
+UPDATE artist SET average_rating = coalesce(
+ (SELECT round(avg(rating), 2) FROM annotation WHERE item_id = artist.id AND item_type = 'artist' AND rating > 0),
+ 0
+);
+
+-- +goose Down
+ALTER TABLE artist DROP COLUMN average_rating;
+ALTER TABLE media_file DROP COLUMN average_rating;
+ALTER TABLE album DROP COLUMN average_rating;
diff --git a/db/migrations/20260220173400_add_fts5_search.go b/db/migrations/20260220173400_add_fts5_search.go
new file mode 100644
index 000000000..dc4cd647b
--- /dev/null
+++ b/db/migrations/20260220173400_add_fts5_search.go
@@ -0,0 +1,391 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upAddFts5Search, downAddFts5Search)
+}
+
+// stripPunct generates a SQL expression that strips common punctuation from a column or expression.
+// Used during migration to approximate the Go normalizeForFTS function for bulk-populating search_normalized.
+func stripPunct(col string) string {
+ return fmt.Sprintf(
+ `REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(%s, '.', ''), '/', ''), '-', ''), '''', ''), '&', ''), ',', '')`,
+ col,
+ )
+}
+
+func upAddFts5Search(ctx context.Context, tx *sql.Tx) error {
+ notice(tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.")
+
+ // Step 1: Add search_participants and search_normalized columns to media_file, album, and artist
+ _, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`)
+ if err != nil {
+ return fmt.Errorf("adding search_participants to media_file: %w", err)
+ }
+ _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
+ if err != nil {
+ return fmt.Errorf("adding search_normalized to media_file: %w", err)
+ }
+ _, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`)
+ if err != nil {
+ return fmt.Errorf("adding search_participants to album: %w", err)
+ }
+ _, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
+ if err != nil {
+ return fmt.Errorf("adding search_normalized to album: %w", err)
+ }
+ _, err = tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
+ if err != nil {
+ return fmt.Errorf("adding search_normalized to artist: %w", err)
+ }
+
+ // Step 2: Populate search_participants from participants JSON.
+ // Extract all "name" values from the participants JSON structure.
+ // participants is a JSON object like: {"artist":[{"name":"...","id":"..."}],"albumartist":[...]}
+ // We use json_each + json_extract to flatten all names into a space-separated string.
+ _, err = tx.ExecContext(ctx, `
+ UPDATE media_file SET search_participants = COALESCE(
+ (SELECT group_concat(json_extract(je2.value, '$.name'), ' ')
+ FROM json_each(media_file.participants) AS je1,
+ json_each(je1.value) AS je2
+ WHERE json_extract(je2.value, '$.name') IS NOT NULL),
+ ''
+ )
+ WHERE participants IS NOT NULL AND participants != '' AND participants != '{}'
+ `)
+ if err != nil {
+ return fmt.Errorf("populating media_file search_participants: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx, `
+ UPDATE album SET search_participants = COALESCE(
+ (SELECT group_concat(json_extract(je2.value, '$.name'), ' ')
+ FROM json_each(album.participants) AS je1,
+ json_each(je1.value) AS je2
+ WHERE json_extract(je2.value, '$.name') IS NOT NULL),
+ ''
+ )
+ WHERE participants IS NOT NULL AND participants != '' AND participants != '{}'
+ `)
+ if err != nil {
+ return fmt.Errorf("populating album search_participants: %w", err)
+ }
+
+ // Step 2b: Populate search_normalized using SQL REPLACE chains for common punctuation.
+ // The Go code will compute the precise value on next scan; this is a best-effort approximation.
+ _, err = tx.ExecContext(ctx, fmt.Sprintf(`
+ UPDATE artist SET search_normalized = %s
+ WHERE name != %s`,
+ stripPunct("name"), stripPunct("name")))
+ if err != nil {
+ return fmt.Errorf("populating artist search_normalized: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx, fmt.Sprintf(`
+ UPDATE album SET search_normalized = TRIM(%s || ' ' || %s)
+ WHERE name != %s OR COALESCE(album_artist, '') != %s`,
+ stripPunct("name"), stripPunct("COALESCE(album_artist, '')"),
+ stripPunct("name"), stripPunct("COALESCE(album_artist, '')")))
+ if err != nil {
+ return fmt.Errorf("populating album search_normalized: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx, fmt.Sprintf(`
+ UPDATE media_file SET search_normalized =
+ TRIM(%s || ' ' || %s || ' ' || %s || ' ' || %s)
+ WHERE title != %s
+ OR COALESCE(album, '') != %s
+ OR COALESCE(artist, '') != %s
+ OR COALESCE(album_artist, '') != %s`,
+ stripPunct("title"), stripPunct("COALESCE(album, '')"),
+ stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')"),
+ stripPunct("title"), stripPunct("COALESCE(album, '')"),
+ stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')")))
+ if err != nil {
+ return fmt.Errorf("populating media_file search_normalized: %w", err)
+ }
+
+ // Step 3: Create FTS5 virtual tables
+ _, err = tx.ExecContext(ctx, `
+ CREATE VIRTUAL TABLE IF NOT EXISTS media_file_fts USING fts5(
+ title, album, artist, album_artist,
+ sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
+ disc_subtitle, search_participants, search_normalized,
+ content='', content_rowid='rowid',
+ tokenize='unicode61 remove_diacritics 2'
+ )
+ `)
+ if err != nil {
+ return fmt.Errorf("creating media_file_fts: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx, `
+ CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5(
+ name, sort_album_name, album_artist,
+ search_participants, discs, catalog_num, album_version, search_normalized,
+ content='', content_rowid='rowid',
+ tokenize='unicode61 remove_diacritics 2'
+ )
+ `)
+ if err != nil {
+ return fmt.Errorf("creating album_fts: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx, `
+ CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5(
+ name, sort_artist_name, search_normalized,
+ content='', content_rowid='rowid',
+ tokenize='unicode61 remove_diacritics 2'
+ )
+ `)
+ if err != nil {
+ return fmt.Errorf("creating artist_fts: %w", err)
+ }
+
+ // Step 4: Bulk-populate FTS5 indexes from existing data
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
+ sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
+ disc_subtitle, search_participants, search_normalized)
+ SELECT rowid, title, album, artist, album_artist,
+ sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
+ COALESCE(disc_subtitle, ''), COALESCE(search_participants, ''),
+ COALESCE(search_normalized, '')
+ FROM media_file
+ `)
+ if err != nil {
+ return fmt.Errorf("populating media_file_fts: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
+ search_participants, discs, catalog_num, album_version, search_normalized)
+ SELECT rowid, name, COALESCE(sort_album_name, ''), COALESCE(album_artist, ''),
+ COALESCE(search_participants, ''), COALESCE(discs, ''),
+ COALESCE(catalog_num, ''),
+ COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
+ FROM json_each(album.tags, '$.albumversion') AS je), ''),
+ COALESCE(search_normalized, '')
+ FROM album
+ `)
+ if err != nil {
+ return fmt.Errorf("populating album_fts: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
+ SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '')
+ FROM artist
+ `)
+ if err != nil {
+ return fmt.Errorf("populating artist_fts: %w", err)
+ }
+
+ // Step 5: Create triggers for media_file
+ _, err = tx.ExecContext(ctx, `
+ CREATE TRIGGER media_file_fts_ai AFTER INSERT ON media_file BEGIN
+ INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
+ sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
+ disc_subtitle, search_participants, search_normalized)
+ VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
+ NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
+ COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
+ COALESCE(NEW.search_normalized, ''));
+ END
+ `)
+ if err != nil {
+ return fmt.Errorf("creating media_file_fts insert trigger: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx, `
+ CREATE TRIGGER media_file_fts_ad AFTER DELETE ON media_file BEGIN
+ INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
+ sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
+ disc_subtitle, search_participants, search_normalized)
+ VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
+ OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
+ COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
+ COALESCE(OLD.search_normalized, ''));
+ END
+ `)
+ if err != nil {
+ return fmt.Errorf("creating media_file_fts delete trigger: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx, `
+ CREATE TRIGGER media_file_fts_au AFTER UPDATE ON media_file
+ WHEN
+ OLD.title IS NOT NEW.title OR
+ OLD.album IS NOT NEW.album OR
+ OLD.artist IS NOT NEW.artist OR
+ OLD.album_artist IS NOT NEW.album_artist OR
+ OLD.sort_title IS NOT NEW.sort_title OR
+ OLD.sort_album_name IS NOT NEW.sort_album_name OR
+ OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
+ OLD.sort_album_artist_name IS NOT NEW.sort_album_artist_name OR
+ OLD.disc_subtitle IS NOT NEW.disc_subtitle OR
+ OLD.search_participants IS NOT NEW.search_participants OR
+ OLD.search_normalized IS NOT NEW.search_normalized
+ BEGIN
+ INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
+ sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
+ disc_subtitle, search_participants, search_normalized)
+ VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
+ OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
+ COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
+ COALESCE(OLD.search_normalized, ''));
+ INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
+ sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
+ disc_subtitle, search_participants, search_normalized)
+ VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
+ NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
+ COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
+ COALESCE(NEW.search_normalized, ''));
+ END
+ `)
+ if err != nil {
+ return fmt.Errorf("creating media_file_fts update trigger: %w", err)
+ }
+
+ // Step 6: Create triggers for album
+ _, err = tx.ExecContext(ctx, `
+ CREATE TRIGGER album_fts_ai AFTER INSERT ON album BEGIN
+ INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
+ search_participants, discs, catalog_num, album_version, search_normalized)
+ VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
+ COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
+ COALESCE(NEW.catalog_num, ''),
+ COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
+ FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
+ COALESCE(NEW.search_normalized, ''));
+ END
+ `)
+ if err != nil {
+ return fmt.Errorf("creating album_fts insert trigger: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx, `
+ CREATE TRIGGER album_fts_ad AFTER DELETE ON album BEGIN
+ INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
+ search_participants, discs, catalog_num, album_version, search_normalized)
+ VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
+ COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
+ COALESCE(OLD.catalog_num, ''),
+ COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
+ FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
+ COALESCE(OLD.search_normalized, ''));
+ END
+ `)
+ if err != nil {
+ return fmt.Errorf("creating album_fts delete trigger: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx, `
+ CREATE TRIGGER album_fts_au AFTER UPDATE ON album
+ WHEN
+ OLD.name IS NOT NEW.name OR
+ OLD.sort_album_name IS NOT NEW.sort_album_name OR
+ OLD.album_artist IS NOT NEW.album_artist OR
+ OLD.search_participants IS NOT NEW.search_participants OR
+ OLD.discs IS NOT NEW.discs OR
+ OLD.catalog_num IS NOT NEW.catalog_num OR
+ OLD.tags IS NOT NEW.tags OR
+ OLD.search_normalized IS NOT NEW.search_normalized
+ BEGIN
+ INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
+ search_participants, discs, catalog_num, album_version, search_normalized)
+ VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
+ COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
+ COALESCE(OLD.catalog_num, ''),
+ COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
+ FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
+ COALESCE(OLD.search_normalized, ''));
+ INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
+ search_participants, discs, catalog_num, album_version, search_normalized)
+ VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
+ COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
+ COALESCE(NEW.catalog_num, ''),
+ COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
+ FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
+ COALESCE(NEW.search_normalized, ''));
+ END
+ `)
+ if err != nil {
+ return fmt.Errorf("creating album_fts update trigger: %w", err)
+ }
+
+ // Step 7: Create triggers for artist
+ _, err = tx.ExecContext(ctx, `
+ CREATE TRIGGER artist_fts_ai AFTER INSERT ON artist BEGIN
+ INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
+ VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
+ COALESCE(NEW.search_normalized, ''));
+ END
+ `)
+ if err != nil {
+ return fmt.Errorf("creating artist_fts insert trigger: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx, `
+ CREATE TRIGGER artist_fts_ad AFTER DELETE ON artist BEGIN
+ INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
+ VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
+ COALESCE(OLD.search_normalized, ''));
+ END
+ `)
+ if err != nil {
+ return fmt.Errorf("creating artist_fts delete trigger: %w", err)
+ }
+
+ _, err = tx.ExecContext(ctx, `
+ CREATE TRIGGER artist_fts_au AFTER UPDATE ON artist
+ WHEN
+ OLD.name IS NOT NEW.name OR
+ OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
+ OLD.search_normalized IS NOT NEW.search_normalized
+ BEGIN
+ INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
+ VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
+ COALESCE(OLD.search_normalized, ''));
+ INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
+ VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
+ COALESCE(NEW.search_normalized, ''));
+ END
+ `)
+ if err != nil {
+ return fmt.Errorf("creating artist_fts update trigger: %w", err)
+ }
+
+ return nil
+}
+
+func downAddFts5Search(ctx context.Context, tx *sql.Tx) error {
+ for _, trigger := range []string{
+ "media_file_fts_ai", "media_file_fts_ad", "media_file_fts_au",
+ "album_fts_ai", "album_fts_ad", "album_fts_au",
+ "artist_fts_ai", "artist_fts_ad", "artist_fts_au",
+ } {
+ _, err := tx.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger)
+ if err != nil {
+ return fmt.Errorf("dropping trigger %s: %w", trigger, err)
+ }
+ }
+
+ for _, table := range []string{"media_file_fts", "album_fts", "artist_fts"} {
+ _, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table)
+ if err != nil {
+ return fmt.Errorf("dropping table %s: %w", table, err)
+ }
+ }
+
+ // Note: We don't drop search_participants columns because SQLite doesn't support DROP COLUMN
+ // on older versions, and the column is harmless if left in place.
+ return nil
+}
diff --git a/db/migrations/20260228020813_add_plugin_allow_write_access.sql b/db/migrations/20260228020813_add_plugin_allow_write_access.sql
new file mode 100644
index 000000000..e17d874a5
--- /dev/null
+++ b/db/migrations/20260228020813_add_plugin_allow_write_access.sql
@@ -0,0 +1,5 @@
+-- +goose Up
+ALTER TABLE plugin ADD COLUMN allow_write_access BOOL NOT NULL DEFAULT false;
+
+-- +goose Down
+ALTER TABLE plugin DROP COLUMN allow_write_access;
diff --git a/db/migrations/20260228172956_add_playlist_image_file.go b/db/migrations/20260228172956_add_playlist_image_file.go
new file mode 100644
index 000000000..da2177aba
--- /dev/null
+++ b/db/migrations/20260228172956_add_playlist_image_file.go
@@ -0,0 +1,22 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upAddPlaylistImageFile, downAddPlaylistImageFile)
+}
+
+func upAddPlaylistImageFile(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN image_file VARCHAR(255) DEFAULT '';`)
+ return err
+}
+
+func downAddPlaylistImageFile(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN image_file;`)
+ return err
+}
diff --git a/db/migrations/20260302021413_rename_playlist_image_fields.go b/db/migrations/20260302021413_rename_playlist_image_fields.go
new file mode 100644
index 000000000..1e9754637
--- /dev/null
+++ b/db/migrations/20260302021413_rename_playlist_image_fields.go
@@ -0,0 +1,30 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upRenamePlaylistImageFields, downRenamePlaylistImageFields)
+}
+
+func upRenamePlaylistImageFields(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `ALTER TABLE playlist RENAME COLUMN image_file TO uploaded_image;`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN external_image_url VARCHAR(255) DEFAULT '';`)
+ return err
+}
+
+func downRenamePlaylistImageFields(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN external_image_url;`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.ExecContext(ctx, `ALTER TABLE playlist RENAME COLUMN uploaded_image TO image_file;`)
+ return err
+}
diff --git a/db/migrations/20260307175815_add_codec_and_update_transcodings.go b/db/migrations/20260307175815_add_codec_and_update_transcodings.go
new file mode 100644
index 000000000..4e8b1b7f5
--- /dev/null
+++ b/db/migrations/20260307175815_add_codec_and_update_transcodings.go
@@ -0,0 +1,73 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/navidrome/navidrome/model/id"
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings)
+}
+
+func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
+ // Add codec column to media_file.
+ _, err := tx.Exec(`ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`)
+ if err != nil {
+ return err
+ }
+
+ // Update old AAC default (adts) to new default (ipod with fragmented MP4).
+ // Only affects users who still have the unmodified old default command.
+ _, err = tx.Exec(
+ `UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?`,
+ "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
+ "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
+ )
+ if err != nil {
+ return err
+ }
+
+ // Add FLAC transcoding for existing installations that were seeded before FLAC was added.
+ var count int
+ err = tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count)
+ if err != nil {
+ return err
+ }
+ if count == 0 {
+ _, err = tx.Exec(
+ "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)",
+ id.NewRandom(), "flac audio", "flac", 0,
+ "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
+ )
+ if err != nil {
+ return err
+ }
+ }
+
+ // Add probe_data column for caching ffprobe results.
+ _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func downAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
+ _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`DROP INDEX IF EXISTS media_file_codec`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`ALTER TABLE media_file DROP COLUMN codec`)
+ return err
+}
diff --git a/db/migrations/20260309120007_fix_probe_data_null.go b/db/migrations/20260309120007_fix_probe_data_null.go
new file mode 100644
index 000000000..a7e7366ed
--- /dev/null
+++ b/db/migrations/20260309120007_fix_probe_data_null.go
@@ -0,0 +1,28 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upFixProbeDataNull, downFixProbeDataNull)
+}
+
+func upFixProbeDataNull(_ context.Context, tx *sql.Tx) error {
+ // Recreate probe_data column as NOT NULL with empty string default.
+ // The previous migration created it with DEFAULT NULL, which causes
+ // scan errors when reading into Go string fields.
+ _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`)
+ return err
+}
+
+func downFixProbeDataNull(_ context.Context, tx *sql.Tx) error {
+ return nil
+}
diff --git a/db/migrations/20260309203355_ensure_default_transcodings.go b/db/migrations/20260309203355_ensure_default_transcodings.go
new file mode 100644
index 000000000..ab6d24952
--- /dev/null
+++ b/db/migrations/20260309203355_ensure_default_transcodings.go
@@ -0,0 +1,44 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/model/id"
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upEnsureDefaultTranscodings, downEnsureDefaultTranscodings)
+}
+
+func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
+ // Older installations may be missing default transcodings that were added
+ // after the initial seeding (e.g., aac was added later than mp3/opus).
+ // Insert any missing defaults without touching user-customized entries.
+ // Check both target_format and name since both have UNIQUE constraints,
+ // and older entries may have a different target_format (e.g., 'oga' vs 'opus')
+ // but the same name.
+ for _, t := range consts.DefaultTranscodings {
+ var count int
+ err := tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count)
+ if err != nil {
+ return err
+ }
+ if count == 0 {
+ _, err = tx.Exec(
+ "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)",
+ id.NewRandom(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command,
+ )
+ if err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func downEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
+ return nil
+}
diff --git a/db/migrations/20260310113858_fix_aac_transcode_command.go b/db/migrations/20260310113858_fix_aac_transcode_command.go
new file mode 100644
index 000000000..588137383
--- /dev/null
+++ b/db/migrations/20260310113858_fix_aac_transcode_command.go
@@ -0,0 +1,30 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upFixAacTranscodeCommand, downFixAacTranscodeCommand)
+}
+
+func upFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error {
+ // The old AAC command used `-f ipod -movflags frag_keyframe+empty_moov` which produces
+ // corrupt/silent audio when ffmpeg pipes to stdout (confirmed in ffmpeg 8.0+).
+ // Switch to `-f adts` (raw AAC framing) which works reliably via pipe.
+ // Only update rows that still have the old default command.
+ const oldCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"
+ const newCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -"
+ _, err := tx.Exec(
+ "UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?",
+ newCommand, oldCommand,
+ )
+ return err
+}
+
+func downFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error {
+ return nil
+}
diff --git a/db/migrations/20260315233131_add_artist_uploaded_image.go b/db/migrations/20260315233131_add_artist_uploaded_image.go
new file mode 100644
index 000000000..964e346f5
--- /dev/null
+++ b/db/migrations/20260315233131_add_artist_uploaded_image.go
@@ -0,0 +1,22 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upAddArtistUploadedImage, downAddArtistUploadedImage)
+}
+
+func upAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN uploaded_image VARCHAR(255) DEFAULT ''`)
+ return err
+}
+
+func downAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error {
+ // This code is executed when the migration is rolled back.
+ return nil
+}
diff --git a/db/migrations/20260316000000_normalize_timestamps.sql b/db/migrations/20260316000000_normalize_timestamps.sql
new file mode 100644
index 000000000..a2e1183e9
--- /dev/null
+++ b/db/migrations/20260316000000_normalize_timestamps.sql
@@ -0,0 +1,74 @@
+-- +goose Up
+
+-- Normalize T-format timestamps (RFC3339Nano with 'T' separator) to SQLite-compatible format.
+-- SQLite uses string comparison for ORDER BY on TEXT columns, so 'T' (ASCII 84) > ' ' (ASCII 32)
+-- causes T-format timestamps to sort after space-format ones, breaking "Recently Added" ordering.
+
+UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
+UPDATE album SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
+UPDATE album SET imported_at = replace(replace(imported_at, 'T', ' '), 'Z', '+00:00') WHERE imported_at LIKE '%T%';
+UPDATE album SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%';
+
+UPDATE media_file SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
+UPDATE media_file SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
+UPDATE media_file SET birth_time = replace(replace(birth_time, 'T', ' '), 'Z', '+00:00') WHERE birth_time LIKE '%T%';
+
+UPDATE artist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
+UPDATE artist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
+UPDATE artist SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%';
+
+UPDATE annotation SET play_date = replace(replace(play_date, 'T', ' '), 'Z', '+00:00') WHERE play_date LIKE '%T%';
+UPDATE annotation SET starred_at = replace(replace(starred_at, 'T', ' '), 'Z', '+00:00') WHERE starred_at LIKE '%T%';
+UPDATE annotation SET rated_at = replace(replace(rated_at, 'T', ' '), 'Z', '+00:00') WHERE rated_at LIKE '%T%';
+
+UPDATE playlist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
+UPDATE playlist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
+UPDATE playlist SET evaluated_at = replace(replace(evaluated_at, 'T', ' '), 'Z', '+00:00') WHERE evaluated_at LIKE '%T%';
+
+UPDATE user SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
+UPDATE user SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
+UPDATE user SET last_login_at = replace(replace(last_login_at, 'T', ' '), 'Z', '+00:00') WHERE last_login_at LIKE '%T%';
+UPDATE user SET last_access_at = replace(replace(last_access_at, 'T', ' '), 'Z', '+00:00') WHERE last_access_at LIKE '%T%';
+
+UPDATE player SET last_seen = replace(replace(last_seen, 'T', ' '), 'Z', '+00:00') WHERE last_seen LIKE '%T%';
+
+UPDATE playqueue SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
+UPDATE playqueue SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
+
+UPDATE bookmark SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
+UPDATE bookmark SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
+
+UPDATE share SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
+UPDATE share SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
+UPDATE share SET expires_at = replace(replace(expires_at, 'T', ' '), 'Z', '+00:00') WHERE expires_at LIKE '%T%';
+UPDATE share SET last_visited_at = replace(replace(last_visited_at, 'T', ' '), 'Z', '+00:00') WHERE last_visited_at LIKE '%T%';
+
+UPDATE radio SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
+UPDATE radio SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
+
+UPDATE folder SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
+UPDATE folder SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
+UPDATE folder SET images_updated_at = replace(replace(images_updated_at, 'T', ' '), 'Z', '+00:00') WHERE images_updated_at LIKE '%T%';
+
+UPDATE library SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
+UPDATE library SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
+UPDATE library SET last_scan_at = replace(replace(last_scan_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_at LIKE '%T%';
+UPDATE library SET last_scan_started_at = replace(replace(last_scan_started_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_started_at LIKE '%T%';
+
+UPDATE scrobble_buffer SET play_time = replace(replace(play_time, 'T', ' '), 'Z', '+00:00') WHERE play_time LIKE '%T%';
+UPDATE scrobble_buffer SET enqueue_time = replace(replace(enqueue_time, 'T', ' '), 'Z', '+00:00') WHERE enqueue_time LIKE '%T%';
+
+UPDATE plugin SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
+UPDATE plugin SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
+
+-- Replace plain indexes with expression indexes for datetime()-based sorting
+DROP INDEX IF EXISTS album_created_at;
+CREATE INDEX album_created_at ON album(datetime(created_at));
+DROP INDEX IF EXISTS album_updated_at;
+CREATE INDEX album_updated_at ON album(datetime(updated_at));
+
+-- +goose Down
+DROP INDEX IF EXISTS album_created_at;
+CREATE INDEX album_created_at ON album(created_at);
+DROP INDEX IF EXISTS album_updated_at;
+CREATE INDEX album_updated_at ON album(updated_at);
diff --git a/db/migrations/20260318182414_add_radio_uploaded_image.go b/db/migrations/20260318182414_add_radio_uploaded_image.go
new file mode 100644
index 000000000..e92a6d2ef
--- /dev/null
+++ b/db/migrations/20260318182414_add_radio_uploaded_image.go
@@ -0,0 +1,22 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upAddRadioUploadedImage, downAddRadioUploadedImage)
+}
+
+func upAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `ALTER TABLE radio ADD COLUMN uploaded_image VARCHAR(255) NOT NULL DEFAULT ''`)
+ return err
+}
+
+func downAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error {
+ // This code is executed when the migration is rolled back.
+ return nil
+}
diff --git a/db/migrations/20260405124200_fix_schema_inconsistencies.sql b/db/migrations/20260405124200_fix_schema_inconsistencies.sql
new file mode 100644
index 000000000..15fe95308
--- /dev/null
+++ b/db/migrations/20260405124200_fix_schema_inconsistencies.sql
@@ -0,0 +1,55 @@
+-- +goose Up
+
+-- NOTE: This migration recreates two tables to fix schema inconsistencies.
+-- On large production databases, the data copy may take some time as tables are locked during the transaction.
+-- This is necessary because SQLite does not support altering table constraints directly.
+-- Consider applying this migration during a maintenance window if the tables are large.
+
+-- Fix library_artist table: Remove contradictory 'default null' from 'not null' column
+-- This is a cosmetic fix (NOT NULL takes precedence), but improves schema consistency
+CREATE TABLE library_artist_new
+(
+ library_id integer NOT NULL DEFAULT 1
+ REFERENCES library(id) ON DELETE CASCADE,
+ artist_id varchar NOT NULL
+ REFERENCES artist(id) ON DELETE CASCADE,
+ stats text DEFAULT '{}',
+ CONSTRAINT library_artist_ux UNIQUE (library_id, artist_id)
+);
+
+INSERT INTO library_artist_new (library_id, artist_id, stats)
+SELECT library_id, artist_id, stats FROM library_artist;
+
+DROP TABLE library_artist;
+
+ALTER TABLE library_artist_new RENAME TO library_artist;
+
+-- Fix scrobble_buffer table: Remove duplicate user_id from unique constraint
+-- Original constraint had: UNIQUE (user_id, service, media_file_id, play_time, user_id)
+-- Fixed constraint is: UNIQUE (user_id, service, media_file_id, play_time)
+CREATE TABLE scrobble_buffer_new
+(
+ user_id varchar NOT NULL
+ CONSTRAINT scrobble_buffer_user_id_fk
+ REFERENCES user ON UPDATE CASCADE ON DELETE CASCADE,
+ service varchar NOT NULL,
+ media_file_id varchar NOT NULL
+ CONSTRAINT scrobble_buffer_media_file_id_fk
+ REFERENCES media_file ON UPDATE CASCADE ON DELETE CASCADE,
+ play_time datetime NOT NULL,
+ enqueue_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ id varchar NOT NULL DEFAULT '',
+ CONSTRAINT scrobble_buffer_pk UNIQUE (user_id, service, media_file_id, play_time)
+);
+
+INSERT INTO scrobble_buffer_new (user_id, service, media_file_id, play_time, enqueue_time, id)
+SELECT user_id, service, media_file_id, play_time, enqueue_time, id FROM scrobble_buffer;
+
+DROP TABLE scrobble_buffer;
+
+ALTER TABLE scrobble_buffer_new RENAME TO scrobble_buffer;
+
+CREATE UNIQUE INDEX scrobble_buffer_id_ix ON scrobble_buffer (id);
+
+-- +goose Down
+-- Down migration is intentionally a no-op: Navidrome does not run down migrations.
diff --git a/db/migrations/20260410201914_fix_zero_album_created_at.sql b/db/migrations/20260410201914_fix_zero_album_created_at.sql
new file mode 100644
index 000000000..ff47eb95f
--- /dev/null
+++ b/db/migrations/20260410201914_fix_zero_album_created_at.sql
@@ -0,0 +1,22 @@
+-- +goose Up
+
+-- Backfill album.created_at for rows poisoned by early scanner versions or
+-- propagated via CopyAttributes during metadata-driven ID changes. Prefer the
+-- oldest valid birth_time from the album's media files, fall back to updated_at.
+UPDATE album
+SET created_at = COALESCE(
+ (SELECT MIN(birth_time)
+ FROM media_file
+ WHERE media_file.album_id = album.id
+ AND birth_time IS NOT NULL
+ AND birth_time != ''
+ AND birth_time NOT LIKE '0001-%'),
+ updated_at
+)
+WHERE created_at IS NULL
+ OR created_at = ''
+ OR created_at LIKE '0001-%';
+
+-- +goose Down
+
+SELECT 1;
diff --git a/db/migrations/20260513173954_move_ss_before_input.go b/db/migrations/20260513173954_move_ss_before_input.go
new file mode 100644
index 000000000..c16583aa0
--- /dev/null
+++ b/db/migrations/20260513173954_move_ss_before_input.go
@@ -0,0 +1,55 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upMoveSsBeforeInput, downMoveSsBeforeInput)
+}
+
+// ssSeekPairs maps old commands (output seeking) to new commands (input seeking).
+// Index 0 = old (after -i), index 1 = new (before -i).
+var ssSeekPairs = [][2]string{
+ {
+ "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
+ "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
+ },
+ {
+ "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
+ "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
+ },
+ {
+ "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
+ "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
+ },
+ {
+ "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
+ "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
+ },
+ {
+ "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
+ "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
+ },
+}
+
+func upMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error {
+ for _, p := range ssSeekPairs {
+ if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func downMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error {
+ for _, p := range ssSeekPairs {
+ if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/db/migrations/20260520211813_add_media_file_artists_composite_index.sql b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql
new file mode 100644
index 000000000..f65050d80
--- /dev/null
+++ b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql
@@ -0,0 +1,9 @@
+-- +goose Up
+CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id_role
+ ON media_file_artists (media_file_id, role);
+DROP INDEX IF EXISTS media_file_artists_media_file_id;
+
+-- +goose Down
+CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id
+ ON media_file_artists (media_file_id);
+DROP INDEX IF EXISTS media_file_artists_media_file_id_role;
diff --git a/db/migrations/migration.go b/db/migrations/migration.go
index 8d8f8a91e..fde6f5817 100644
--- a/db/migrations/migration.go
+++ b/db/migrations/migration.go
@@ -7,6 +7,7 @@ import (
"strings"
"sync"
+ "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
)
@@ -21,11 +22,13 @@ func notice(tx *sql.Tx, msg string) {
// Call this in migrations that requires a full rescan
func forceFullRescan(tx *sql.Tx) error {
// If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`.
- _, err := tx.Exec(`ANALYZE;`)
- if err != nil {
- return err
+ if conf.Server.DevOptimizeDB {
+ _, err := tx.Exec(`ANALYZE;`)
+ if err != nil {
+ return err
+ }
}
- _, err = tx.Exec(fmt.Sprintf(`
+ _, err := tx.Exec(fmt.Sprintf(`
INSERT OR REPLACE into property (id, value) values ('%s', '1');
`, consts.FullScanAfterMigrationFlagKey))
return err
diff --git a/git/pre-commit b/git/pre-commit
index 04f87994b..39ec8797f 100755
--- a/git/pre-commit
+++ b/git/pre-commit
@@ -12,7 +12,7 @@
gofmtcmd="go tool goimports"
-gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$' | grep -v '_gen.go$')
+gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$' | grep -v '_gen.go$' | grep -v '.pb.go$')
[ -z "$gofiles" ] && exit 0
unformatted=$($gofmtcmd -l $gofiles)
diff --git a/go.mod b/go.mod
index 5c09c0b17..29a415126 100644
--- a/go.mod
+++ b/go.mod
@@ -1,129 +1,149 @@
module github.com/navidrome/navidrome
-go 1.24.2
+go 1.26
-// Fork to fix https://github.com/navidrome/navidrome/pull/3254
-replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
+// Fork to implement raw tags support
+replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a
require (
github.com/Masterminds/squirrel v1.5.4
- github.com/RaveNoX/go-jsoncommentstrip v1.0.0
github.com/andybalholm/cascadia v1.3.3
- github.com/bmatcuk/doublestar/v4 v4.8.1
- github.com/bradleyjkemp/cupaloy/v2 v2.8.0
+ github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933
- github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
- github.com/disintegration/imaging v1.6.2
github.com/djherbis/atime v1.1.0
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4
github.com/djherbis/stream v1.4.0
github.com/djherbis/times v1.6.0
github.com/dustin/go-humanize v1.0.1
+ github.com/extism/go-sdk v1.7.1
github.com/fatih/structs v1.1.0
- github.com/go-chi/chi/v5 v5.2.1
- github.com/go-chi/cors v1.2.1
+ github.com/gen2brain/webp v0.5.5
+ github.com/go-chi/chi/v5 v5.3.0
+ github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.15.0
- github.com/go-chi/jwtauth/v5 v5.3.3
+ github.com/go-chi/jwtauth/v5 v5.4.0
github.com/go-viper/encoding/ini v0.1.1
- github.com/gohugoio/hashstructure v0.5.0
+ github.com/go-viper/mapstructure/v2 v2.5.0
+ github.com/gohugoio/hashstructure v0.6.0
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
github.com/google/uuid v1.6.0
- github.com/google/wire v0.6.0
+ github.com/google/wire v0.7.0
+ github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-multierror v1.1.1
- github.com/jellydator/ttlcache/v3 v3.3.0
- github.com/kardianos/service v1.2.2
+ github.com/jellydator/ttlcache/v3 v3.4.0
+ github.com/kardianos/service v1.2.4
github.com/kr/pretty v0.3.1
- github.com/lestrrat-go/jwx/v2 v2.1.6
- github.com/matoous/go-nanoid/v2 v2.1.0
- github.com/mattn/go-sqlite3 v1.14.28
+ github.com/lestrrat-go/jwx/v3 v3.1.1
+ github.com/mattn/go-sqlite3 v1.14.44
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
- github.com/onsi/ginkgo/v2 v2.23.4
- github.com/onsi/gomega v1.37.0
- github.com/pelletier/go-toml/v2 v2.2.4
- github.com/pocketbase/dbx v1.11.0
- github.com/pressly/goose/v3 v3.24.3
- github.com/prometheus/client_golang v1.22.0
+ github.com/onsi/ginkgo/v2 v2.29.0
+ github.com/onsi/gomega v1.41.0
+ github.com/pelletier/go-toml/v2 v2.3.1
+ github.com/pmezard/go-difflib v1.0.0
+ github.com/pocketbase/dbx v1.12.0
+ github.com/pressly/goose/v3 v3.27.1
+ github.com/prometheus/client_golang v1.23.2
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
- github.com/sirupsen/logrus v1.9.3
- github.com/spf13/cobra v1.9.1
- github.com/spf13/viper v1.20.1
- github.com/stretchr/testify v1.10.0
+ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
+ github.com/sirupsen/logrus v1.9.4
+ github.com/spf13/cobra v1.10.2
+ github.com/spf13/viper v1.21.0
+ github.com/stretchr/testify v1.11.1
+ github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633
github.com/unrolled/secure v1.17.0
- github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
+ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
+ go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0
- golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6
- golang.org/x/image v0.27.0
- golang.org/x/net v0.40.0
- golang.org/x/sync v0.14.0
- golang.org/x/sys v0.33.0
- golang.org/x/text v0.25.0
- golang.org/x/time v0.11.0
+ golang.org/x/image v0.41.0
+ golang.org/x/net v0.55.0
+ golang.org/x/sync v0.20.0
+ golang.org/x/sys v0.45.0
+ golang.org/x/term v0.43.0
+ golang.org/x/text v0.37.0
+ golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
)
require (
+ dario.cat/mergo v1.0.2 // indirect
+ github.com/Masterminds/semver/v3 v3.5.0 // indirect
+ github.com/atombender/go-jsonschema v0.20.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/reflex v0.3.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/creack/pty v1.1.11 // indirect
+ github.com/creack/pty v1.1.24 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
- github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
- github.com/fsnotify/fsnotify v1.9.0 // indirect
- github.com/go-logr/logr v1.4.2 // indirect
+ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
+ github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
+ github.com/ebitengine/purego v0.10.1 // indirect
+ github.com/fsnotify/fsnotify v1.10.1 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
- github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
- github.com/goccy/go-json v0.10.5 // indirect
+ github.com/gobwas/glob v0.2.3 // indirect
+ github.com/goccy/go-json v0.10.6 // indirect
+ github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
- github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect
+ github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
+ github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
- github.com/klauspost/cpuid/v2 v2.2.10 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
- github.com/lestrrat-go/blackmagic v1.0.3 // indirect
+ github.com/lestrrat-go/blackmagic v1.0.4 // indirect
+ github.com/lestrrat-go/dsig v1.3.0 // indirect
+ github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
- github.com/lestrrat-go/httprc v1.0.6 // indirect
- github.com/lestrrat-go/iter v1.0.2 // indirect
- github.com/lestrrat-go/option v1.0.1 // indirect
+ github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect
+ github.com/lestrrat-go/option/v2 v2.0.0 // indirect
+ github.com/maruel/natural v1.3.0 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
+ github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ogier/pflag v0.0.1 // indirect
- github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
- github.com/prometheus/client_model v0.6.1 // indirect
- github.com/prometheus/common v0.62.0 // indirect
- github.com/prometheus/procfs v0.16.1 // indirect
- github.com/rogpeppe/go-internal v1.14.1 // indirect
- github.com/sagikazarmark/locafero v0.9.0 // indirect
- github.com/segmentio/asm v1.2.0 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.67.5 // indirect
+ github.com/prometheus/procfs v0.20.1 // indirect
+ github.com/rogpeppe/go-internal v1.15.0 // indirect
+ github.com/sagikazarmark/locafero v0.12.0 // indirect
+ github.com/sanity-io/litter v1.5.8 // indirect
+ github.com/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
- github.com/sourcegraph/conc v0.3.0 // indirect
- github.com/spf13/afero v1.14.0 // indirect
- github.com/spf13/cast v1.8.0 // indirect
- github.com/spf13/pflag v1.0.6 // indirect
- github.com/stretchr/objx v0.5.2 // indirect
+ github.com/sosodev/duration v1.3.1 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
- github.com/zeebo/xxh3 v1.0.2 // indirect
- go.uber.org/automaxprocs v1.6.0 // indirect
+ github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
+ github.com/valyala/fastjson v1.6.10 // indirect
+ github.com/zeebo/xxh3 v1.1.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
- golang.org/x/crypto v0.38.0 // indirect
- golang.org/x/mod v0.24.0 // indirect
- golang.org/x/tools v0.33.0 // indirect
- google.golang.org/protobuf v1.36.6 // indirect
- gopkg.in/ini.v1 v1.67.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.3 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/crypto v0.52.0 // indirect
+ golang.org/x/mod v0.36.0 // indirect
+ golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect
+ golang.org/x/tools v0.45.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+ gopkg.in/ini.v1 v1.67.2 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
)
tool (
+ github.com/atombender/go-jsonschema
github.com/cespare/reflex
github.com/google/wire/cmd/wire
github.com/onsi/ginkgo/v2/ginkgo
diff --git a/go.sum b/go.sum
index d8a1a8c45..57289abfd 100644
--- a/go.sum
+++ b/go.sum
@@ -1,43 +1,45 @@
-filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
-filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
+dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
+filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
+filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
+github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
+github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
-github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0=
-github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
+github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
+github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
-github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
-github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
-github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
+github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
+github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
-github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
-github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
+github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a h1:L5E3uF4hKLEqoEYT0tXXuFH6c3PEEzQSWLfTqF5Lpqw=
+github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a/go.mod h1:+k5CamBu88xgydgNGJjugYVeafoCCswoGjpw5w5CvD4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
-github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d h1:x/R3+oPEjnisl1zBx2f2v7Gf6f11l0N0JoD6BkwcJyA=
-github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg=
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc=
-github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
-github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g=
github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE=
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 h1:wdZllsLrDJtYfHiAKogB4PNHSDeO+v+5S3eqSWHGDlc=
@@ -46,76 +48,101 @@ github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE
github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
+github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
+github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
+github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
+github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
-github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
-github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
-github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
-github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
-github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
+github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg=
+github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0=
+github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
+github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
+github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
+github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
+github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
+github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
+github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
+github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
+github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
+github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
-github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
-github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
-github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
-github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
+github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
-github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
-github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
+github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
+github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
github.com/go-viper/encoding/ini v0.1.1/go.mod h1:Pfi4M2V1eAGJVZ5q6FrkHPhtHED2YgLlXhvgMVrB+YQ=
-github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
-github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
-github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
-github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
-github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
-github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
+github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
+github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
+github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
+github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
-github.com/google/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4=
-github.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
+github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M=
+github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
-github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
+github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
+github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QSchEk63/fjeEvWOa2cE02FSv9ox//VE/N4c8g=
+github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc=
-github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw=
+github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
+github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
+github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
+github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
-github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60=
-github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
+github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk=
+github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
-github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
-github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
-github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
-github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
+github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -130,60 +157,66 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
-github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
-github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
+github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
+github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
+github.com/lestrrat-go/dsig v1.3.0 h1:phjMOCXvYzhuIgn7Voe2rex8z166vGfxRxmqM25P9/Q=
+github.com/lestrrat-go/dsig v1.3.0/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc=
+github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
+github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
-github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
-github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
-github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
-github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
-github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
-github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
-github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
-github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
-github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
-github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
-github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
+github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
+github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw=
+github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
+github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
+github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
+github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
+github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
+github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
+github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
+github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
+github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
+github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
+github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
+github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
+github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
-github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
-github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
-github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
-github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
-github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
-github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
-github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag=
+github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
+github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA=
+github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
+github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
+github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
-github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
-github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
-github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
-github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
-github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
-github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
-github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
-github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
-github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
-github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
-github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
-github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
-github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
+github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
+github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
+github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
+github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
+github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
+github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
+github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
@@ -191,86 +224,109 @@ github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4Ug
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
-github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
+github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
-github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
-github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
-github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
-github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
+github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
+github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
+github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
+github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
-github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
-github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
+github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
-github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
-github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
-github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
-github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
-github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
-github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
-github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
-github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
-github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
-github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
-github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
+github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
+github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
+github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
+github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=
+github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk=
+github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 h1:6GN/lazdqr69FIzz1U6c4TF/ppE2dInMR4GzU9QKxjg=
+github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633/go.mod h1:3ghOSSWYnzX0zd/3Ns4ni2tKxcXDE9/QgkwuH1PW3Rs=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
-github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
-github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
+github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
+github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
+github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
+github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
-github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
-github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
-go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
-go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
+go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
+go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
+go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
-golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
-golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
-golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
-golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
-golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
-golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
+golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
+golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
+golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
+golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
-golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
+golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -279,12 +335,11 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
-golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
-golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -292,38 +347,38 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
-golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
-golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE=
+golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
-golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
+golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
+golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -334,40 +389,39 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
-golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
-golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
-golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
-golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
-golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
+golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
+golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
-google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
-gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss=
+gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
-modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
+modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
+modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
-modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
-modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
-modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
-modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
+modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
diff --git a/log/journal.go b/log/journal.go
new file mode 100644
index 000000000..dd7cf5400
--- /dev/null
+++ b/log/journal.go
@@ -0,0 +1,41 @@
+package log
+
+import (
+ "fmt"
+
+ "github.com/sirupsen/logrus"
+)
+
+// journalFormatter wraps a logrus.Formatter and prepends a syslog priority
+// prefix () to each log line. When stderr is captured by systemd-journald,
+// this prefix tells journald the correct severity for each message.
+//
+// See https://www.freedesktop.org/software/systemd/man/sd-daemon.html
+type journalFormatter struct {
+ inner logrus.Formatter
+}
+
+// levelToPriority maps logrus levels to syslog priority values.
+// The mapping follows RFC 5424 severity levels.
+var levelToPriority = map[logrus.Level]int{
+ logrus.PanicLevel: 0, // emerg
+ logrus.FatalLevel: 2, // crit
+ logrus.ErrorLevel: 3, // err
+ logrus.WarnLevel: 4, // warning
+ logrus.InfoLevel: 6, // info
+ logrus.DebugLevel: 7, // debug
+ logrus.TraceLevel: 7, // debug
+}
+
+func (f *journalFormatter) Format(entry *logrus.Entry) ([]byte, error) {
+ formatted, err := f.inner.Format(entry)
+ if err != nil {
+ return formatted, err
+ }
+ priority, ok := levelToPriority[entry.Level]
+ if !ok {
+ priority = 6 // default to info for unknown levels
+ }
+ prefix := fmt.Appendf(nil, "<%d>", priority)
+ return append(prefix, formatted...), nil
+}
diff --git a/log/journal_test.go b/log/journal_test.go
new file mode 100644
index 000000000..770f12b6d
--- /dev/null
+++ b/log/journal_test.go
@@ -0,0 +1,41 @@
+package log
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/sirupsen/logrus"
+)
+
+var _ = Describe("journalFormatter", func() {
+ var formatter *journalFormatter
+
+ BeforeEach(func() {
+ inner := &logrus.TextFormatter{
+ DisableTimestamp: true,
+ DisableColors: true,
+ }
+ formatter = &journalFormatter{inner: inner}
+ })
+
+ DescribeTable("prefixes log lines with syslog priority",
+ func(level logrus.Level, expectedPrefix string) {
+ entry := &logrus.Entry{
+ Logger: logrus.New(),
+ Level: level,
+ Message: "test message",
+ Data: logrus.Fields{},
+ }
+ out, err := formatter.Format(entry)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(out)).To(HavePrefix(expectedPrefix))
+ },
+ Entry("error", logrus.ErrorLevel, "<3>"),
+ Entry("warning", logrus.WarnLevel, "<4>"),
+ Entry("info", logrus.InfoLevel, "<6>"),
+ Entry("debug", logrus.DebugLevel, "<7>"),
+ Entry("trace", logrus.TraceLevel, "<7>"),
+ Entry("fatal", logrus.FatalLevel, "<2>"),
+ Entry("panic", logrus.PanicLevel, "<0>"),
+ Entry("unknown level defaults to info", logrus.Level(99), "<6>"),
+ )
+})
diff --git a/log/log.go b/log/log.go
index 08a487fcd..2764d80e5 100644
--- a/log/log.go
+++ b/log/log.go
@@ -11,6 +11,7 @@ import (
"runtime"
"sort"
"strings"
+ "sync"
"time"
"github.com/sirupsen/logrus"
@@ -18,7 +19,7 @@ import (
type Level uint32
-type LevelFunc = func(ctx interface{}, msg interface{}, keyValuePairs ...interface{})
+type LevelFunc = func(ctx any, msg any, keyValuePairs ...any)
var redacted = &Hook{
AcceptedLevels: logrus.AllLevels,
@@ -26,10 +27,9 @@ var redacted = &Hook{
// Keys from the config
"(ApiKey:\")[\\w]*",
"(Secret:\")[\\w]*",
- "(Spotify.*ID:\")[\\w]*",
"(PasswordEncryptionKey:[\\s]*\")[^\"]*",
- "(ReverseProxyUserHeader:[\\s]*\")[^\"]*",
- "(ReverseProxyWhitelist:[\\s]*\")[^\"]*",
+ "(UserHeader:[\\s]*\")[^\"]*",
+ "(TrustedSources:[\\s]*\")[^\"]*",
"(MetricsPath:[\\s]*\")[^\"]*",
"(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*",
"(DevAutoLoginUsername:[\\s]*\")[^\"]*",
@@ -70,6 +70,7 @@ type levelPath struct {
var (
currentLevel Level
+ loggerMu sync.RWMutex
defaultLogger = logrus.New()
logSourceLine = false
rootPath string
@@ -78,17 +79,19 @@ var (
// SetLevel sets the global log level used by the simple logger.
func SetLevel(l Level) {
+ loggerMu.Lock()
currentLevel = l
defaultLogger.Level = logrus.TraceLevel
+ loggerMu.Unlock()
logrus.SetLevel(logrus.Level(l))
}
func SetLevelString(l string) {
- level := levelFromString(l)
+ level := ParseLogLevel(l)
SetLevel(level)
}
-func levelFromString(l string) Level {
+func ParseLogLevel(l string) Level {
envLevel := strings.ToLower(l)
var level Level
switch envLevel {
@@ -110,9 +113,11 @@ func levelFromString(l string) Level {
// SetLogLevels sets the log levels for specific paths in the codebase.
func SetLogLevels(levels map[string]string) {
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
logLevels = nil
for k, v := range levels {
- logLevels = append(logLevels, levelPath{path: k, level: levelFromString(v)})
+ logLevels = append(logLevels, levelPath{path: k, level: ParseLogLevel(v)})
}
sort.Slice(logLevels, func(i, j int) bool {
return logLevels[i].path > logLevels[j].path
@@ -125,6 +130,8 @@ func SetLogSourceLine(enabled bool) {
func SetRedacting(enabled bool) {
if enabled {
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
defaultLogger.AddHook(redacted)
}
}
@@ -133,16 +140,27 @@ func SetOutput(w io.Writer) {
if runtime.GOOS == "windows" {
w = CRLFWriter(w)
}
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
defaultLogger.SetOutput(w)
}
+// EnableJournalFormat wraps the current logger formatter with syslog
+// priority prefixes for systemd-journald. Only call this when output
+// goes to stderr and JOURNAL_STREAM is set.
+func EnableJournalFormat() {
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
+ defaultLogger.Formatter = &journalFormatter{inner: defaultLogger.Formatter}
+}
+
// Redact applies redaction to a single string
func Redact(msg string) string {
r, _ := redacted.redact(msg)
return r
}
-func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Context {
+func NewContext(ctx context.Context, keyValuePairs ...any) context.Context {
if ctx == nil {
ctx = context.Background()
}
@@ -158,10 +176,14 @@ func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Conte
}
func SetDefaultLogger(l *logrus.Logger) {
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
defaultLogger = l
}
func CurrentLevel() Level {
+ loggerMu.RLock()
+ defer loggerMu.RUnlock()
return currentLevel
}
@@ -170,32 +192,32 @@ func IsGreaterOrEqualTo(level Level) bool {
return shouldLog(level, 2)
}
-func Fatal(args ...interface{}) {
- log(LevelFatal, args...)
+func Fatal(args ...any) {
+ Log(LevelFatal, args...)
os.Exit(1)
}
-func Error(args ...interface{}) {
- log(LevelError, args...)
+func Error(args ...any) {
+ Log(LevelError, args...)
}
-func Warn(args ...interface{}) {
- log(LevelWarn, args...)
+func Warn(args ...any) {
+ Log(LevelWarn, args...)
}
-func Info(args ...interface{}) {
- log(LevelInfo, args...)
+func Info(args ...any) {
+ Log(LevelInfo, args...)
}
-func Debug(args ...interface{}) {
- log(LevelDebug, args...)
+func Debug(args ...any) {
+ Log(LevelDebug, args...)
}
-func Trace(args ...interface{}) {
- log(LevelTrace, args...)
+func Trace(args ...any) {
+ Log(LevelTrace, args...)
}
-func log(level Level, args ...interface{}) {
+func Log(level Level, args ...any) {
if !shouldLog(level, 3) {
return
}
@@ -203,11 +225,22 @@ func log(level Level, args ...interface{}) {
logger.Log(logrus.Level(level), msg)
}
+func Writer() io.Writer {
+ loggerMu.RLock()
+ defer loggerMu.RUnlock()
+ return defaultLogger.Writer()
+}
+
func shouldLog(requiredLevel Level, skip int) bool {
- if currentLevel >= requiredLevel {
+ loggerMu.RLock()
+ level := currentLevel
+ levels := logLevels
+ loggerMu.RUnlock()
+
+ if level >= requiredLevel {
return true
}
- if len(logLevels) == 0 {
+ if len(levels) == 0 {
return false
}
@@ -217,7 +250,7 @@ func shouldLog(requiredLevel Level, skip int) bool {
}
file = strings.TrimPrefix(file, rootPath)
- for _, lp := range logLevels {
+ for _, lp := range levels {
if strings.HasPrefix(file, lp.path) {
return lp.level >= requiredLevel
}
@@ -225,7 +258,7 @@ func shouldLog(requiredLevel Level, skip int) bool {
return false
}
-func parseArgs(args []interface{}) (*logrus.Entry, string) {
+func parseArgs(args []any) (*logrus.Entry, string) {
var l *logrus.Entry
var err error
if args[0] == nil {
@@ -264,7 +297,7 @@ func parseArgs(args []interface{}) (*logrus.Entry, string) {
return l, ""
}
-func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry {
+func addFields(logger *logrus.Entry, keyValuePairs []any) *logrus.Entry {
for i := 0; i < len(keyValuePairs); i += 2 {
switch name := keyValuePairs[i].(type) {
case error:
@@ -291,7 +324,7 @@ func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry
return logger
}
-func extractLogger(ctx interface{}) (*logrus.Entry, error) {
+func extractLogger(ctx any) (*logrus.Entry, error) {
switch ctx := ctx.(type) {
case *logrus.Entry:
return ctx, nil
@@ -310,6 +343,8 @@ func extractLogger(ctx interface{}) (*logrus.Entry, error) {
func createNewLogger() *logrus.Entry {
//logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true})
//l.Formatter = &logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true}
+ loggerMu.RLock()
+ defer loggerMu.RUnlock()
logger := logrus.NewEntry(defaultLogger)
return logger
}
diff --git a/log/redactrus.go b/log/redactrus.go
index d743e3f2d..6e17243e7 100755
--- a/log/redactrus.go
+++ b/log/redactrus.go
@@ -42,8 +42,9 @@ func (h *Hook) Fire(e *logrus.Entry) error {
e.Data[k] = "[REDACTED]"
continue
}
-
- // Redact based on value matching in Data fields
+ if v == nil {
+ continue
+ }
switch reflect.TypeOf(v).Kind() {
case reflect.String:
e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2")
diff --git a/main.go b/main.go
index 65db162ac..b5fb508b4 100644
--- a/main.go
+++ b/main.go
@@ -9,11 +9,12 @@ import (
//goland:noinspection GoBoolExpressions
func main() {
- // This import is used to force the inclusion of the `netgo` tag when compiling the project.
+ // These references force the inclusion of build tags when compiling the project.
// If you get compilation errors like "undefined: buildtags.NETGO", this means you forgot to specify
- // the `netgo` build tag when compiling the project.
+ // the required build tags when compiling the project.
// To avoid these kind of errors, you should use `make build` to compile the project.
_ = buildtags.NETGO
+ _ = buildtags.SQLITE_FTS5
cmd.Execute()
}
diff --git a/model/album.go b/model/album.go
index c9dc022cb..667f4695b 100644
--- a/model/album.go
+++ b/model/album.go
@@ -1,11 +1,14 @@
package model
import (
+ "fmt"
"iter"
"math"
"sync"
"time"
+ "github.com/navidrome/navidrome/conf"
+
"github.com/gohugoio/hashstructure"
)
@@ -14,6 +17,8 @@ type Album struct {
ID string `structs:"id" json:"id"`
LibraryID int `structs:"library_id" json:"libraryId"`
+ LibraryPath string `structs:"-" json:"libraryPath" hash:"ignore"`
+ LibraryName string `structs:"-" json:"libraryName" hash:"ignore"`
Name string `structs:"name" json:"name"`
EmbedArtPath string `structs:"embed_art_path" json:"-"`
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated, use Participants
@@ -68,6 +73,13 @@ func (a Album) CoverArtID() ArtworkID {
return artworkIDFromAlbum(a)
}
+func (a Album) FullName() string {
+ if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 {
+ return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0])
+ }
+ return a.Name
+}
+
// Equals compares two Album structs, ignoring calculated fields
func (a Album) Equals(other Album) bool {
// Normalize float32 values to avoid false negatives
diff --git a/model/album_test.go b/model/album_test.go
index a45d16dd5..0f4c912cd 100644
--- a/model/album_test.go
+++ b/model/album_test.go
@@ -3,11 +3,30 @@ package model_test
import (
"encoding/json"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
. "github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
+var _ = Describe("Album", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ })
+ DescribeTable("FullName",
+ func(enabled bool, tags Tags, expected string) {
+ conf.Server.Subsonic.AppendAlbumVersion = enabled
+ a := Album{Name: "Album", Tags: tags}
+ Expect(a.FullName()).To(Equal(expected))
+ },
+ Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album (Remastered)"),
+ Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"),
+ Entry("returns just name when tag is absent", true, Tags{}, "Album"),
+ Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
+ )
+})
+
var _ = Describe("Albums", func() {
var albums Albums
diff --git a/model/annotation.go b/model/annotation.go
index 2ec72c1b7..5228028a6 100644
--- a/model/annotation.go
+++ b/model/annotation.go
@@ -3,11 +3,13 @@ package model
import "time"
type Annotations struct {
- PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
- PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
- Rating int `structs:"rating" json:"rating,omitempty" `
- Starred bool `structs:"starred" json:"starred,omitempty" `
- StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
+ PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
+ PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
+ Rating int `structs:"rating" json:"rating,omitempty" `
+ RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
+ Starred bool `structs:"starred" json:"starred,omitempty" `
+ StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
+ AverageRating float64 `structs:"average_rating" json:"averageRating,omitempty"`
}
type AnnotatedRepository interface {
diff --git a/model/artist.go b/model/artist.go
index 68836ff28..2085f0051 100644
--- a/model/artist.go
+++ b/model/artist.go
@@ -4,6 +4,8 @@ import (
"maps"
"slices"
"time"
+
+ "github.com/navidrome/navidrome/consts"
)
type Artist struct {
@@ -34,6 +36,8 @@ type Artist struct {
Missing bool `structs:"missing" json:"missing"`
+ UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"`
+
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
}
@@ -58,6 +62,10 @@ func (a Artist) CoverArtID() ArtworkID {
return artworkIDFromArtist(a)
}
+func (a Artist) UploadedImagePath() string {
+ return UploadedImagePath(consts.EntityArtist, a.UploadedImage)
+}
+
// Roles returns the roles this artist has participated in., based on the Stats field
func (a Artist) Roles() []Role {
return slices.Collect(maps.Keys(a.Stats))
@@ -78,11 +86,11 @@ type ArtistRepository interface {
UpdateExternalInfo(a *Artist) error
Get(id string) (*Artist, error)
GetAll(options ...QueryOptions) (Artists, error)
- GetIndex(includeMissing bool, roles ...Role) (ArtistIndexes, error)
+ GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error)
// The following methods are used exclusively by the scanner:
RefreshPlayCounts() (int64, error)
- RefreshStats() (int64, error)
+ RefreshStats(allArtists bool) (int64, error)
AnnotatedRepository
SearchableRepository[Artists]
diff --git a/model/artist_test.go b/model/artist_test.go
new file mode 100644
index 000000000..db897d3d5
--- /dev/null
+++ b/model/artist_test.go
@@ -0,0 +1,30 @@
+package model_test
+
+import (
+ "path/filepath"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/model"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Artist", func() {
+ Describe("UploadedImagePath", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.DataFolder = conf.NewDir("/data")
+ })
+
+ It("returns empty string when no image uploaded", func() {
+ a := model.Artist{ID: "ar-1"}
+ Expect(a.UploadedImagePath()).To(BeEmpty())
+ })
+
+ It("returns full path when image is set", func() {
+ a := model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"}
+ Expect(a.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "artist", "ar-1_test.jpg")))
+ })
+ })
+})
diff --git a/model/artwork_id.go b/model/artwork_id.go
index 36026dd03..1bd146c1f 100644
--- a/model/artwork_id.go
+++ b/model/artwork_id.go
@@ -22,6 +22,8 @@ var (
KindArtistArtwork = Kind{"ar", "artist"}
KindAlbumArtwork = Kind{"al", "album"}
KindPlaylistArtwork = Kind{"pl", "playlist"}
+ KindDiscArtwork = Kind{"dc", "disc"}
+ KindRadioArtwork = Kind{"ra", "radio"}
)
var artworkKindMap = map[string]Kind{
@@ -29,6 +31,8 @@ var artworkKindMap = map[string]Kind{
KindArtistArtwork.prefix: KindArtistArtwork,
KindAlbumArtwork.prefix: KindAlbumArtwork,
KindPlaylistArtwork.prefix: KindPlaylistArtwork,
+ KindDiscArtwork.prefix: KindDiscArtwork,
+ KindRadioArtwork.prefix: KindRadioArtwork,
}
type ArtworkID struct {
@@ -91,6 +95,22 @@ func MustParseArtworkID(id string) ArtworkID {
return artID
}
+func DiscArtworkID(albumID string, discNumber int) string {
+ return fmt.Sprintf("%s:%d", albumID, discNumber)
+}
+
+func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) {
+ parts := strings.SplitN(id, ":", 2)
+ if len(parts) != 2 || parts[1] == "" {
+ return "", 0, errors.New("invalid disc artwork id")
+ }
+ num, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return "", 0, fmt.Errorf("invalid disc number in artwork id: %w", err)
+ }
+ return parts[0], num, nil
+}
+
func artworkIDFromAlbum(al Album) ArtworkID {
return ArtworkID{
Kind: KindAlbumArtwork,
@@ -121,3 +141,11 @@ func artworkIDFromArtist(ar Artist) ArtworkID {
ID: ar.ID,
}
}
+
+func artworkIDFromRadio(r Radio) ArtworkID {
+ return ArtworkID{
+ Kind: KindRadioArtwork,
+ ID: r.ID,
+ LastUpdate: r.UpdatedAt,
+ }
+}
diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go
index 2f42217f9..ad66f7bb5 100644
--- a/model/artwork_id_test.go
+++ b/model/artwork_id_test.go
@@ -11,8 +11,7 @@ import (
var _ = Describe("ArtworkID", func() {
Describe("NewArtworkID()", func() {
It("creates a valid parseable ArtworkID", func() {
- now := time.Now()
- id := model.NewArtworkID(model.KindAlbumArtwork, "1234", &now)
+ id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now()))
parsedId, err := model.ParseArtworkID(id.String())
Expect(err).ToNot(HaveOccurred())
Expect(parsedId.Kind).To(Equal(id.Kind))
@@ -28,6 +27,40 @@ var _ = Describe("ArtworkID", func() {
Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix()))
})
})
+ Describe("ParseArtworkID - disc kind", func() {
+ It("parses a disc artwork ID with dc prefix", func() {
+ now := time.Now()
+ id := model.NewArtworkID(model.KindDiscArtwork, "albumid123:2", &now)
+ parsedId, err := model.ParseArtworkID(id.String())
+ Expect(err).ToNot(HaveOccurred())
+ Expect(parsedId.Kind).To(Equal(model.KindDiscArtwork))
+ Expect(parsedId.ID).To(Equal("albumid123:2"))
+ Expect(parsedId.LastUpdate.Unix()).To(Equal(now.Unix()))
+ })
+ })
+
+ Describe("ParseDiscArtworkID", func() {
+ DescribeTable("parses composite disc artwork IDs",
+ func(id string, expectedAlbum string, expectedDisc int, expectErr bool) {
+ albumID, discNumber, err := model.ParseDiscArtworkID(id)
+ if expectErr {
+ Expect(err).To(HaveOccurred())
+ } else {
+ Expect(err).ToNot(HaveOccurred())
+ Expect(albumID).To(Equal(expectedAlbum))
+ Expect(discNumber).To(Equal(expectedDisc))
+ }
+ },
+ Entry("valid id", "albumid123:2", "albumid123", 2, false),
+ Entry("disc number 1", "abc:1", "abc", 1, false),
+ Entry("large disc number", "abc:10", "abc", 10, false),
+ Entry("missing colon", "albumid123", "", 0, true),
+ Entry("missing disc number", "albumid123:", "", 0, true),
+ Entry("non-numeric disc", "albumid123:abc", "", 0, true),
+ Entry("empty string", "", "", 0, true),
+ )
+ })
+
Describe("ParseArtworkID()", func() {
It("parses album artwork ids", func() {
id, err := model.ParseArtworkID("al-1234")
diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go
index 493e53173..31d208d08 100644
--- a/model/criteria/criteria.go
+++ b/model/criteria/criteria.go
@@ -1,62 +1,62 @@
-// Package criteria implements a Criteria API based on Masterminds/squirrel
+// Package criteria implements the smart playlist criteria DSL.
package criteria
import (
"encoding/json"
"errors"
- "fmt"
- "strings"
+ "slices"
- "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
)
-type Expression = squirrel.Sqlizer
+type Expression interface {
+ fields() map[string]any
+}
type Criteria struct {
Expression
- Sort string
- Order string
- Limit int
- Offset int
+ Sort string
+ Order string
+ Limit int
+ LimitPercent int
+ Offset int
}
-func (c Criteria) OrderBy() string {
- if c.Sort == "" {
- c.Sort = "title"
+// EffectiveLimit resolves the effective limit for a query. If a fixed Limit is
+// set it takes precedence. Otherwise, if LimitPercent is set, the limit is
+// computed as a percentage of totalCount (minimum 1 when totalCount > 0).
+// Returns 0 when no limit applies.
+func (c Criteria) EffectiveLimit(totalCount int64) int {
+ if c.Limit > 0 {
+ return c.Limit
}
- sortField := strings.ToLower(c.Sort)
- f := fieldMap[sortField]
- var mapped string
- if f == nil {
- log.Error("Invalid field in 'sort' field. Using 'title'", "sort", c.Sort)
- mapped = fieldMap["title"].field
- } else {
- if f.order != "" {
- mapped = f.order
- } else if f.isTag {
- mapped = "COALESCE(json_extract(media_file.tags, '$." + sortField + "[0].value'), '')"
- } else if f.isRole {
- mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')"
- } else {
- mapped = f.field
+ if c.LimitPercent > 0 && c.LimitPercent <= 100 {
+ if totalCount <= 0 {
+ return 0
}
- if f.numeric {
- mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
+ result := int(totalCount) * c.LimitPercent / 100
+ if result < 1 {
+ return 1
}
+ return result
}
- if c.Order != "" {
- if strings.EqualFold(c.Order, "asc") || strings.EqualFold(c.Order, "desc") {
- mapped = mapped + " " + c.Order
- } else {
- log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order)
- }
- }
- return mapped
+ return 0
}
-func (c Criteria) ToSql() (sql string, args []any, err error) {
- return c.Expression.ToSql()
+// ResolveLimit converts a percentage-based limit into an absolute Limit using
+// the given totalCount. It is a no-op when a fixed Limit is already set or when
+// no percentage limit is configured.
+func (c *Criteria) ResolveLimit(totalCount int64) {
+ if !c.IsPercentageLimit() {
+ return
+ }
+ c.Limit = c.EffectiveLimit(totalCount)
+}
+
+// IsPercentageLimit returns true when the criteria uses a valid percentage-based
+// limit (i.e. LimitPercent is in [1, 100] and no fixed Limit overrides it).
+func (c Criteria) IsPercentageLimit() bool {
+ return c.Limit == 0 && c.LimitPercent > 0 && c.LimitPercent <= 100
}
func (c Criteria) ChildPlaylistIds() []string {
@@ -64,26 +64,31 @@ func (c Criteria) ChildPlaylistIds() []string {
return nil
}
- if parent := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); parent != nil {
- return parent.ChildPlaylistIds()
+ parent, ok := c.Expression.(conjunction)
+ if !ok {
+ return nil
}
- return nil
+ ids := parent.ChildPlaylistIds()
+ slices.Sort(ids)
+ return slices.Compact(ids)
}
func (c Criteria) MarshalJSON() ([]byte, error) {
aux := struct {
- All []Expression `json:"all,omitempty"`
- Any []Expression `json:"any,omitempty"`
- Sort string `json:"sort,omitempty"`
- Order string `json:"order,omitempty"`
- Limit int `json:"limit,omitempty"`
- Offset int `json:"offset,omitempty"`
+ All []Expression `json:"all,omitempty"`
+ Any []Expression `json:"any,omitempty"`
+ Sort string `json:"sort,omitempty"`
+ Order string `json:"order,omitempty"`
+ Limit int `json:"limit,omitempty"`
+ LimitPercent int `json:"limitPercent,omitempty"`
+ Offset int `json:"offset,omitempty"`
}{
- Sort: c.Sort,
- Order: c.Order,
- Limit: c.Limit,
- Offset: c.Offset,
+ Sort: c.Sort,
+ Order: c.Order,
+ Limit: c.Limit,
+ LimitPercent: c.LimitPercent,
+ Offset: c.Offset,
}
switch rules := c.Expression.(type) {
case Any:
@@ -98,12 +103,13 @@ func (c Criteria) MarshalJSON() ([]byte, error) {
func (c *Criteria) UnmarshalJSON(data []byte) error {
var aux struct {
- All unmarshalConjunctionType `json:"all"`
- Any unmarshalConjunctionType `json:"any"`
- Sort string `json:"sort"`
- Order string `json:"order"`
- Limit int `json:"limit"`
- Offset int `json:"offset"`
+ All unmarshalConjunctionType `json:"all"`
+ Any unmarshalConjunctionType `json:"any"`
+ Sort string `json:"sort"`
+ Order string `json:"order"`
+ Limit int `json:"limit"`
+ LimitPercent int `json:"limitPercent"`
+ Offset int `json:"offset"`
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
@@ -119,5 +125,15 @@ func (c *Criteria) UnmarshalJSON(data []byte) error {
c.Order = aux.Order
c.Limit = aux.Limit
c.Offset = aux.Offset
+
+ // Clamp LimitPercent to [0, 100]
+ if aux.LimitPercent < 0 {
+ log.Warn("limitPercent value out of range, clamping to 0", "value", aux.LimitPercent)
+ aux.LimitPercent = 0
+ } else if aux.LimitPercent > 100 {
+ log.Warn("limitPercent value out of range, clamping to 100", "value", aux.LimitPercent)
+ aux.LimitPercent = 100
+ }
+ c.LimitPercent = aux.LimitPercent
return nil
}
diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go
index 7afb6ec0d..092cfd36a 100644
--- a/model/criteria/criteria_test.go
+++ b/model/criteria/criteria_test.go
@@ -27,6 +27,7 @@ var _ = Describe("Criteria", func() {
StartsWith{"comment": "this"},
InTheRange{"year": []int{1980, 1990}},
IsNot{"genre": "Rock"},
+ Gt{"albumrating": 3},
},
},
Sort: "title",
@@ -48,7 +49,8 @@ var _ = Describe("Criteria", func() {
{ "all": [
{ "startsWith": {"comment": "this"} },
{ "inTheRange": {"year":[1980,1990]} },
- { "isNot": { "genre": "Rock" }}
+ { "isNot": { "genre": "Rock" }},
+ { "gt": { "albumrating": 3 } }
]
}
],
@@ -63,16 +65,6 @@ var _ = Describe("Criteria", func() {
}
jsonObj = b.String()
})
- It("generates valid SQL", func() {
- sql, args, err := goObj.ToSql()
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(sql).To(gomega.Equal(
- `(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` +
- `AND (not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) ` +
- `OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` +
- `AND not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)))`))
- gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock"))
- })
It("marshals to JSON", func() {
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
@@ -86,64 +78,158 @@ var _ = Describe("Criteria", func() {
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
})
- Describe("OrderBy", func() {
- It("sorts by regular fields", func() {
- gomega.Expect(goObj.OrderBy()).To(gomega.Equal("media_file.title asc"))
- })
-
- It("sorts by tag fields", func() {
- goObj.Sort = "genre"
- gomega.Expect(goObj.OrderBy()).To(
- gomega.Equal(
- "COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc",
- ),
- )
- })
-
- It("sorts by role fields", func() {
- goObj.Sort = "artist"
- gomega.Expect(goObj.OrderBy()).To(
- gomega.Equal(
- "COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc",
- ),
- )
- })
-
- It("casts numeric tags when sorting", func() {
- AddTagNames([]string{"rate"})
- AddNumericTags([]string{"rate"})
- goObj.Sort = "rate"
- gomega.Expect(goObj.OrderBy()).To(
- gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"),
- )
- })
-
- It("sorts by random", func() {
- newObj := goObj
- newObj.Sort = "random"
- gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc"))
- })
- })
})
- Context("with artist roles", func() {
- BeforeEach(func() {
- goObj = Criteria{
- Expression: All{
- Is{"artist": "The Beatles"},
- Contains{"composer": "Lennon"},
- },
- }
+ Describe("LimitPercent", func() {
+ Describe("JSON round-trip", func() {
+ It("marshals and unmarshals limitPercent", func() {
+ goObj := Criteria{
+ Expression: All{Contains{"title": "love"}},
+ Sort: "title",
+ Order: "asc",
+ LimitPercent: 10,
+ }
+ j, err := json.Marshal(goObj)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limitPercent":10`))
+ gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`"limit"`))
+
+ var newObj Criteria
+ err = json.Unmarshal(j, &newObj)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(newObj.LimitPercent).To(gomega.Equal(10))
+ gomega.Expect(newObj.Limit).To(gomega.Equal(0))
+ })
+
+ It("does not include limitPercent when zero", func() {
+ goObj := Criteria{
+ Expression: All{Contains{"title": "love"}},
+ Limit: 50,
+ }
+ j, err := json.Marshal(goObj)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limit":50`))
+ gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`limitPercent`))
+ })
+
+ It("backward compatible: JSON with only limit still works", func() {
+ jsonStr := `{"all":[{"contains":{"title":"love"}}],"limit":20}`
+ var c Criteria
+ err := json.Unmarshal([]byte(jsonStr), &c)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(c.Limit).To(gomega.Equal(20))
+ gomega.Expect(c.LimitPercent).To(gomega.Equal(0))
+ })
})
- It("generates valid SQL", func() {
- sql, args, err := goObj.ToSql()
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(sql).To(gomega.Equal(
- `(exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) AND ` +
- `exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?))`,
- ))
- gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%"))
+ Describe("UnmarshalJSON clamping", func() {
+ It("clamps values above 100 to 100", func() {
+ jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":150}`
+ var c Criteria
+ err := json.Unmarshal([]byte(jsonStr), &c)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(c.LimitPercent).To(gomega.Equal(100))
+ })
+
+ It("clamps negative values to 0", func() {
+ jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":-5}`
+ var c Criteria
+ err := json.Unmarshal([]byte(jsonStr), &c)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(c.LimitPercent).To(gomega.Equal(0))
+ })
+ })
+
+ Describe("EffectiveLimit", func() {
+ It("returns fixed limit when Limit is set", func() {
+ c := Criteria{Limit: 50, LimitPercent: 10}
+ gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(50))
+ })
+
+ It("returns percentage-based limit", func() {
+ c := Criteria{LimitPercent: 10}
+ gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(45))
+ })
+
+ It("returns minimum 1 when totalCount > 0 and percentage rounds to 0", func() {
+ c := Criteria{LimitPercent: 1}
+ gomega.Expect(c.EffectiveLimit(5)).To(gomega.Equal(1))
+ })
+
+ It("returns 0 when totalCount is 0", func() {
+ c := Criteria{LimitPercent: 10}
+ gomega.Expect(c.EffectiveLimit(0)).To(gomega.Equal(0))
+ })
+
+ It("returns 0 when no limit is set", func() {
+ c := Criteria{}
+ gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(0))
+ })
+
+ It("returns full count for 100%", func() {
+ c := Criteria{LimitPercent: 100}
+ gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(450))
+ })
+
+ It("returns 1 for 1% of 50 items", func() {
+ c := Criteria{LimitPercent: 1}
+ gomega.Expect(c.EffectiveLimit(50)).To(gomega.Equal(1))
+ })
+ })
+
+ Describe("ResolveLimit", func() {
+ It("resolves percentage to absolute limit preserving LimitPercent", func() {
+ c := Criteria{LimitPercent: 10}
+ c.ResolveLimit(450)
+ gomega.Expect(c.Limit).To(gomega.Equal(45))
+ })
+
+ It("does nothing when Limit is already set", func() {
+ c := Criteria{Limit: 50, LimitPercent: 10}
+ c.ResolveLimit(1000)
+ gomega.Expect(c.Limit).To(gomega.Equal(50))
+ })
+
+ It("does nothing when no limit is configured", func() {
+ c := Criteria{}
+ c.ResolveLimit(1000)
+ gomega.Expect(c.Limit).To(gomega.Equal(0))
+ })
+
+ It("sets minimum 1 when percentage rounds to 0 and totalCount > 0", func() {
+ c := Criteria{LimitPercent: 1}
+ c.ResolveLimit(5)
+ gomega.Expect(c.Limit).To(gomega.Equal(1))
+ })
+
+ It("is idempotent when called twice", func() {
+ c := Criteria{LimitPercent: 10}
+ c.ResolveLimit(450)
+ c.ResolveLimit(450)
+ gomega.Expect(c.Limit).To(gomega.Equal(45))
+ })
+ })
+
+ Describe("IsPercentageLimit", func() {
+ It("returns true when LimitPercent is set and Limit is 0", func() {
+ c := Criteria{LimitPercent: 10}
+ gomega.Expect(c.IsPercentageLimit()).To(gomega.BeTrue())
+ })
+
+ It("returns false when Limit is set", func() {
+ c := Criteria{Limit: 50, LimitPercent: 10}
+ gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
+ })
+
+ It("returns false when neither is set", func() {
+ c := Criteria{}
+ gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
+ })
+
+ It("returns false when LimitPercent is out of range", func() {
+ c := Criteria{LimitPercent: 150}
+ gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
+ })
})
})
@@ -212,5 +298,23 @@ var _ = Describe("Criteria", func() {
ids := Criteria{}.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.BeEmpty())
})
+ It("returns empty list for leaf expressions", func() {
+ ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds()
+ gomega.Expect(ids).To(gomega.BeEmpty())
+ })
+ It("deduplicates repeated playlist IDs", func() {
+ sharedID := uuid.NewString()
+ goObj = Criteria{
+ Expression: All{
+ InPlaylist{"id": sharedID},
+ Any{
+ InPlaylist{"id": sharedID},
+ NotInPlaylist{"id": sharedID},
+ },
+ },
+ }
+ ids := goObj.ChildPlaylistIds()
+ gomega.Expect(ids).To(gomega.Equal([]string{sharedID}))
+ })
})
})
diff --git a/model/criteria/export_test.go b/model/criteria/export_test.go
index 9f3f3922b..e2109aa1a 100644
--- a/model/criteria/export_test.go
+++ b/model/criteria/export_test.go
@@ -1,5 +1,3 @@
package criteria
-var StartOfPeriod = startOfPeriod
-
type UnmarshalConjunctionType = unmarshalConjunctionType
diff --git a/model/criteria/fields.go b/model/criteria/fields.go
index b7178e540..9eafff7ab 100644
--- a/model/criteria/fields.go
+++ b/model/criteria/fields.go
@@ -1,227 +1,164 @@
package criteria
-import (
- "fmt"
- "reflect"
- "strings"
+import "strings"
- "github.com/Masterminds/squirrel"
- "github.com/navidrome/navidrome/log"
-)
+// FieldInfo contains semantic metadata about a criteria field.
+type FieldInfo struct {
+ Alias string // If set, this field is a backward-compat alias for another canonical name
+ IsTag bool
+ IsRole bool
+ Numeric bool
+ Boolean bool
-var fieldMap = map[string]*mappedField{
- "title": {field: "media_file.title"},
- "album": {field: "media_file.album"},
- "hascoverart": {field: "media_file.has_cover_art"},
- "tracknumber": {field: "media_file.track_number"},
- "discnumber": {field: "media_file.disc_number"},
- "year": {field: "media_file.year"},
- "date": {field: "media_file.date", alias: "recordingdate"},
- "originalyear": {field: "media_file.original_year"},
- "originaldate": {field: "media_file.original_date"},
- "releaseyear": {field: "media_file.release_year"},
- "releasedate": {field: "media_file.release_date"},
- "size": {field: "media_file.size"},
- "compilation": {field: "media_file.compilation"},
- "dateadded": {field: "media_file.created_at"},
- "datemodified": {field: "media_file.updated_at"},
- "discsubtitle": {field: "media_file.disc_subtitle"},
- "comment": {field: "media_file.comment"},
- "lyrics": {field: "media_file.lyrics"},
- "sorttitle": {field: "media_file.sort_title"},
- "sortalbum": {field: "media_file.sort_album_name"},
- "sortartist": {field: "media_file.sort_artist_name"},
- "sortalbumartist": {field: "media_file.sort_album_artist_name"},
- "albumtype": {field: "media_file.mbz_album_type", alias: "releasetype"},
- "albumcomment": {field: "media_file.mbz_album_comment"},
- "catalognumber": {field: "media_file.catalog_num"},
- "filepath": {field: "media_file.path"},
- "filetype": {field: "media_file.suffix"},
- "duration": {field: "media_file.duration"},
- "bitrate": {field: "media_file.bit_rate"},
- "bitdepth": {field: "media_file.bit_depth"},
- "bpm": {field: "media_file.bpm"},
- "channels": {field: "media_file.channels"},
- "loved": {field: "COALESCE(annotation.starred, false)"},
- "dateloved": {field: "annotation.starred_at"},
- "lastplayed": {field: "annotation.play_date"},
- "playcount": {field: "COALESCE(annotation.play_count, 0)"},
- "rating": {field: "COALESCE(annotation.rating, 0)"},
-
- // special fields
- "random": {field: "", order: "random()"}, // pseudo-field for random sorting
- "value": {field: "value"}, // pseudo-field for tag and roles values
+ tagAlias string // If set, a tag name from mappings.yml that resolves to this field
+ name string // Canonical name, populated by LookupField from the map key
}
-type mappedField struct {
- field string
- order string
- isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
- isTag bool // true if the field is a tag imported from the file metadata
- alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
- numeric bool // true if the field/tag should be treated as numeric
+// Name returns the canonical field name (the map key used to register this field).
+func (f FieldInfo) Name() string {
+ return f.name
}
-func mapFields(expr map[string]any) map[string]any {
- m := make(map[string]any)
- for f, v := range expr {
- if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" {
- m[dbf.field] = v
+var fieldMap = map[string]FieldInfo{
+ "title": {},
+ "album": {},
+ "hascoverart": {Boolean: true},
+ "tracknumber": {},
+ "discnumber": {},
+ "year": {},
+ "date": {tagAlias: "recordingdate"},
+ "originalyear": {},
+ "originaldate": {},
+ "releaseyear": {},
+ "releasedate": {},
+ "size": {},
+ "compilation": {Boolean: true},
+ "missing": {Boolean: true},
+ "explicitstatus": {},
+ "dateadded": {},
+ "datemodified": {},
+ "discsubtitle": {},
+ "comment": {},
+ "lyrics": {},
+ "sorttitle": {},
+ "sortalbum": {},
+ "sortartist": {},
+ "sortalbumartist": {},
+ "albumcomment": {},
+ "catalognumber": {},
+ "filepath": {},
+ "filetype": {},
+ "codec": {},
+ "duration": {},
+ "bitrate": {},
+ "bitdepth": {},
+ "samplerate": {},
+ "bpm": {},
+ "channels": {},
+ "loved": {Boolean: true},
+ "dateloved": {},
+ "lastplayed": {},
+ "daterated": {},
+ "playcount": {},
+ "rating": {},
+ "averagerating": {Numeric: true},
+ "albumrating": {},
+ "albumloved": {Boolean: true},
+ "albumplaycount": {},
+ "albumlastplayed": {},
+ "albumdateloved": {},
+ "albumdaterated": {},
+ "artistrating": {},
+ "artistloved": {Boolean: true},
+ "artistplaycount": {},
+ "artistlastplayed": {},
+ "artistdateloved": {},
+ "artistdaterated": {},
+ "mbz_album_id": {},
+ "mbz_album_artist_id": {},
+ "mbz_artist_id": {},
+ "mbz_recording_id": {},
+ "mbz_release_track_id": {},
+ "mbz_release_group_id": {},
+ "rgalbumgain": {Numeric: true},
+ "rgalbumpeak": {Numeric: true},
+ "rgtrackgain": {Numeric: true},
+ "rgtrackpeak": {Numeric: true},
+ "library_id": {Numeric: true},
+
+ // Backward compatibility: albumtype is an alias for the releasetype tag.
+ "albumtype": {Alias: "releasetype", IsTag: true},
+
+ // Pseudo-field for random sorting
+ "random": {},
+}
+
+// AllFieldNames returns the names of all registered criteria fields.
+func AllFieldNames() []string {
+ names := make([]string, 0, len(fieldMap))
+ for name := range fieldMap {
+ names = append(names, name)
+ }
+ return names
+}
+
+// LookupField returns semantic metadata for a criteria field name.
+func LookupField(name string) (FieldInfo, bool) {
+ key := strings.ToLower(name)
+ f, ok := fieldMap[key]
+ if ok {
+ if f.Alias != "" {
+ f.name = f.Alias
} else {
- log.Error("Invalid field in criteria", "field", f)
+ f.name = key
}
}
- return m
-}
-
-// mapExpr maps a normal field expression to a specific type of expression (tag or role).
-// This is required because tags are handled differently than other fields,
-// as they are stored as a JSON column in the database.
-func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.Sqlizer, bool) squirrel.Sqlizer) squirrel.Sqlizer {
- rv := reflect.ValueOf(expr)
- if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String {
- log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr))
- }
-
- // Extract into a generic map
- var k string
- m := make(map[string]any, rv.Len())
- for _, key := range rv.MapKeys() {
- // Save the key to build the expression, and use the provided keyName as the key
- k = key.String()
- m["value"] = rv.MapIndex(key).Interface()
- break // only one key is expected (and supported)
- }
-
- // Clear the original map
- for _, key := range rv.MapKeys() {
- rv.SetMapIndex(key, reflect.Value{})
- }
-
- // Write the updated map back into the original variable
- for key, val := range m {
- rv.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(val))
- }
-
- return exprFunc(k, expr, negate)
-}
-
-// mapTagExpr maps a normal field expression to a tag expression.
-func mapTagExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
- return mapExpr(expr, negate, tagExpr)
-}
-
-// mapRoleExpr maps a normal field expression to an artist role expression.
-func mapRoleExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
- return mapExpr(expr, negate, roleExpr)
-}
-
-func isTagExpr(expr map[string]any) bool {
- for f := range expr {
- if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isTag {
- return true
- }
- }
- return false
-}
-
-func isRoleExpr(expr map[string]any) bool {
- for f := range expr {
- if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isRole {
- return true
- }
- }
- return false
-}
-
-func tagExpr(tag string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
- return tagCond{tag: tag, cond: cond, not: negate}
-}
-
-type tagCond struct {
- tag string
- cond squirrel.Sqlizer
- not bool
-}
-
-func (e tagCond) ToSql() (string, []any, error) {
- cond, args, err := e.cond.ToSql()
-
- // Check if this tag is marked as numeric in the fieldMap
- if fm, ok := fieldMap[e.tag]; ok && fm.numeric {
- cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
- }
-
- cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
- e.tag, cond)
- if e.not {
- cond = "not " + cond
- }
- return cond, args, err
-}
-
-func roleExpr(role string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
- return roleCond{role: role, cond: cond, not: negate}
-}
-
-type roleCond struct {
- role string
- cond squirrel.Sqlizer
- not bool
-}
-
-func (e roleCond) ToSql() (string, []any, error) {
- cond, args, err := e.cond.ToSql()
- cond = fmt.Sprintf(`exists (select 1 from json_tree(participants, '$.%s') where key='name' and %s)`,
- e.role, cond)
- if e.not {
- cond = "not " + cond
- }
- return cond, args, err
+ return f, ok
}
// AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in
-// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent.
+// smart playlists.
func AddRoles(roles []string) {
for _, role := range roles {
name := strings.ToLower(role)
if _, ok := fieldMap[name]; ok {
continue
}
- fieldMap[name] = &mappedField{field: name, isRole: true}
+ fieldMap[name] = FieldInfo{IsRole: true}
}
}
// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml`
-// file to the field map, so they can be used in smart playlists.
-// If a tag name already exists in the field map, it is ignored, so calls to this function are idempotent.
+// configuration file.
func AddTagNames(tagNames []string) {
- for _, name := range tagNames {
- name := strings.ToLower(name)
+ for _, tagName := range tagNames {
+ name := strings.ToLower(tagName)
if _, ok := fieldMap[name]; ok {
continue
}
- for _, fm := range fieldMap {
- if fm.alias == name {
+ for key, fm := range fieldMap {
+ if fm.tagAlias == name {
+ fm.Alias = key
+ fm.tagAlias = ""
fieldMap[name] = fm
break
}
}
if _, ok := fieldMap[name]; !ok {
- fieldMap[name] = &mappedField{field: name, isTag: true}
+ fieldMap[name] = FieldInfo{IsTag: true}
}
}
}
-// AddNumericTags marks the given tag names as numeric so they can be cast
-// when used in comparisons or sorting.
+// AddNumericTags adds tags that should be treated as numbers.
func AddNumericTags(tagNames []string) {
- for _, name := range tagNames {
- name := strings.ToLower(name)
+ for _, tagName := range tagNames {
+ name := strings.ToLower(tagName)
if fm, ok := fieldMap[name]; ok {
- fm.numeric = true
+ fm.Numeric = true
+ fieldMap[name] = fm
} else {
- fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true}
+ fieldMap[name] = FieldInfo{IsTag: true, Numeric: true}
}
}
}
diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go
index accdebd3d..5b6f53341 100644
--- a/model/criteria/fields_test.go
+++ b/model/criteria/fields_test.go
@@ -6,11 +6,52 @@ import (
)
var _ = Describe("fields", func() {
- Describe("mapFields", func() {
- It("ignores random fields", func() {
- m := map[string]any{"random": "123"}
- m = mapFields(m)
- gomega.Expect(m).To(gomega.BeEmpty())
+ Describe("LookupField", func() {
+ It("finds built-in fields case-insensitively", func() {
+ field, ok := LookupField("Title")
+
+ gomega.Expect(ok).To(gomega.BeTrue())
+ gomega.Expect(field.Name()).To(gomega.Equal("title"))
})
+
+ It("resolves aliases to their canonical field name", func() {
+ field, ok := LookupField("albumtype")
+
+ gomega.Expect(ok).To(gomega.BeTrue())
+ gomega.Expect(field.Name()).To(gomega.Equal("releasetype"))
+ gomega.Expect(field.IsTag).To(gomega.BeTrue())
+ })
+
+ It("finds registered tag names", func() {
+ AddTagNames([]string{"task3_mood"})
+
+ field, ok := LookupField("task3_mood")
+
+ gomega.Expect(ok).To(gomega.BeTrue())
+ gomega.Expect(field.Name()).To(gomega.Equal("task3_mood"))
+ gomega.Expect(field.IsTag).To(gomega.BeTrue())
+ })
+
+ It("marks registered numeric tags", func() {
+ AddTagNames([]string{"task3_score"})
+ AddNumericTags([]string{"task3_score"})
+
+ field, ok := LookupField("task3_score")
+
+ gomega.Expect(ok).To(gomega.BeTrue())
+ gomega.Expect(field.IsTag).To(gomega.BeTrue())
+ gomega.Expect(field.Numeric).To(gomega.BeTrue())
+ })
+
+ It("finds registered roles", func() {
+ AddRoles([]string{"task3_producer"})
+
+ field, ok := LookupField("task3_producer")
+
+ gomega.Expect(ok).To(gomega.BeTrue())
+ gomega.Expect(field.Name()).To(gomega.Equal("task3_producer"))
+ gomega.Expect(field.IsRole).To(gomega.BeTrue())
+ })
+
})
})
diff --git a/model/criteria/json.go b/model/criteria/json.go
index f6ab56eda..ca47ceb95 100644
--- a/model/criteria/json.go
+++ b/model/criteria/json.go
@@ -3,6 +3,7 @@ package criteria
import (
"encoding/json"
"fmt"
+ "strconv"
"strings"
)
@@ -38,6 +39,7 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
if err != nil {
return nil
}
+ normalizeBoolFields(m)
switch opName {
case "is":
return Is(m)
@@ -69,10 +71,48 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
return InPlaylist(m)
case "notinplaylist":
return NotInPlaylist(m)
+ case "ismissing":
+ normalizeAllBoolFields(m)
+ return IsMissing(m)
+ case "ispresent":
+ normalizeAllBoolFields(m)
+ return IsPresent(m)
}
return nil
}
+func normalizeAllBoolFields(m map[string]any) {
+ for k, v := range m {
+ m[k] = normalizeBoolValue(v)
+ }
+}
+
+func normalizeBoolFields(m map[string]any) {
+ for field, value := range m {
+ info, ok := LookupField(field)
+ if ok && info.Boolean {
+ m[field] = normalizeBoolValue(value)
+ }
+ }
+}
+
+func normalizeBoolValue(v any) any {
+ switch val := v.(type) {
+ case string:
+ if b, err := strconv.ParseBool(val); err == nil {
+ return b
+ }
+ case float64:
+ if val == 1 {
+ return true
+ }
+ if val == 0 {
+ return false
+ }
+ }
+ return v
+}
+
func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression {
var items unmarshalConjunctionType
err := json.Unmarshal(rawValue, &items)
diff --git a/model/criteria/operators.go b/model/criteria/operators.go
index 336f914de..14a02ff4b 100644
--- a/model/criteria/operators.go
+++ b/model/criteria/operators.go
@@ -1,23 +1,16 @@
package criteria
-import (
- "errors"
- "fmt"
- "reflect"
- "strconv"
- "time"
-
- "github.com/Masterminds/squirrel"
-)
+// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively
+type conjunction interface {
+ ChildPlaylistIds() []string
+}
type (
- All squirrel.And
+ All []Expression
And = All
)
-func (all All) ToSql() (sql string, args []any, err error) {
- return squirrel.And(all).ToSql()
-}
+func (All) fields() map[string]any { return nil }
func (all All) MarshalJSON() ([]byte, error) {
return marshalConjunction("all", all)
@@ -28,13 +21,11 @@ func (all All) ChildPlaylistIds() (ids []string) {
}
type (
- Any squirrel.Or
+ Any []Expression
Or = Any
)
-func (any Any) ToSql() (sql string, args []any, err error) {
- return squirrel.Or(any).ToSql()
-}
+func (Any) fields() map[string]any { return nil }
func (any Any) MarshalJSON() ([]byte, error) {
return marshalConjunction("any", any)
@@ -44,288 +35,143 @@ func (any Any) ChildPlaylistIds() (ids []string) {
return extractPlaylistIds(any)
}
-type Is squirrel.Eq
+type Is map[string]any
type Eq = Is
-func (is Is) ToSql() (sql string, args []any, err error) {
- if isRoleExpr(is) {
- return mapRoleExpr(is, false).ToSql()
- }
- if isTagExpr(is) {
- return mapTagExpr(is, false).ToSql()
- }
- return squirrel.Eq(mapFields(is)).ToSql()
-}
-
func (is Is) MarshalJSON() ([]byte, error) {
return marshalExpression("is", is)
}
-type IsNot squirrel.NotEq
+func (is Is) fields() map[string]any { return is }
-func (in IsNot) ToSql() (sql string, args []any, err error) {
- if isRoleExpr(in) {
- return mapRoleExpr(squirrel.Eq(in), true).ToSql()
- }
- if isTagExpr(in) {
- return mapTagExpr(squirrel.Eq(in), true).ToSql()
- }
- return squirrel.NotEq(mapFields(in)).ToSql()
+type IsNot map[string]any
+
+func (isn IsNot) MarshalJSON() ([]byte, error) {
+ return marshalExpression("isNot", isn)
}
-func (in IsNot) MarshalJSON() ([]byte, error) {
- return marshalExpression("isNot", in)
-}
+func (isn IsNot) fields() map[string]any { return isn }
-type Gt squirrel.Gt
-
-func (gt Gt) ToSql() (sql string, args []any, err error) {
- if isTagExpr(gt) {
- return mapTagExpr(gt, false).ToSql()
- }
- return squirrel.Gt(mapFields(gt)).ToSql()
-}
+type Gt map[string]any
func (gt Gt) MarshalJSON() ([]byte, error) {
return marshalExpression("gt", gt)
}
-type Lt squirrel.Lt
+func (gt Gt) fields() map[string]any { return gt }
-func (lt Lt) ToSql() (sql string, args []any, err error) {
- if isTagExpr(lt) {
- return mapTagExpr(squirrel.Lt(lt), false).ToSql()
- }
- return squirrel.Lt(mapFields(lt)).ToSql()
-}
+type Lt map[string]any
func (lt Lt) MarshalJSON() ([]byte, error) {
return marshalExpression("lt", lt)
}
-type Before squirrel.Lt
+func (lt Lt) fields() map[string]any { return lt }
-func (bf Before) ToSql() (sql string, args []any, err error) {
- return Lt(bf).ToSql()
-}
+type Before map[string]any
func (bf Before) MarshalJSON() ([]byte, error) {
return marshalExpression("before", bf)
}
-type After Gt
+func (bf Before) fields() map[string]any { return bf }
-func (af After) ToSql() (sql string, args []any, err error) {
- return Gt(af).ToSql()
-}
+type After Gt
func (af After) MarshalJSON() ([]byte, error) {
return marshalExpression("after", af)
}
-type Contains map[string]any
+func (af After) fields() map[string]any { return af }
-func (ct Contains) ToSql() (sql string, args []any, err error) {
- lk := squirrel.Like{}
- for f, v := range mapFields(ct) {
- lk[f] = fmt.Sprintf("%%%s%%", v)
- }
- if isRoleExpr(ct) {
- return mapRoleExpr(lk, false).ToSql()
- }
- if isTagExpr(ct) {
- return mapTagExpr(lk, false).ToSql()
- }
- return lk.ToSql()
-}
+type Contains map[string]any
func (ct Contains) MarshalJSON() ([]byte, error) {
return marshalExpression("contains", ct)
}
-type NotContains map[string]any
+func (ct Contains) fields() map[string]any { return ct }
-func (nct NotContains) ToSql() (sql string, args []any, err error) {
- lk := squirrel.NotLike{}
- for f, v := range mapFields(nct) {
- lk[f] = fmt.Sprintf("%%%s%%", v)
- }
- if isRoleExpr(nct) {
- return mapRoleExpr(squirrel.Like(lk), true).ToSql()
- }
- if isTagExpr(nct) {
- return mapTagExpr(squirrel.Like(lk), true).ToSql()
- }
- return lk.ToSql()
-}
+type NotContains map[string]any
func (nct NotContains) MarshalJSON() ([]byte, error) {
return marshalExpression("notContains", nct)
}
-type StartsWith map[string]any
+func (nct NotContains) fields() map[string]any { return nct }
-func (sw StartsWith) ToSql() (sql string, args []any, err error) {
- lk := squirrel.Like{}
- for f, v := range mapFields(sw) {
- lk[f] = fmt.Sprintf("%s%%", v)
- }
- if isRoleExpr(sw) {
- return mapRoleExpr(lk, false).ToSql()
- }
- if isTagExpr(sw) {
- return mapTagExpr(lk, false).ToSql()
- }
- return lk.ToSql()
-}
+type StartsWith map[string]any
func (sw StartsWith) MarshalJSON() ([]byte, error) {
return marshalExpression("startsWith", sw)
}
+func (sw StartsWith) fields() map[string]any { return sw }
+
type EndsWith map[string]any
-func (sw EndsWith) ToSql() (sql string, args []any, err error) {
- lk := squirrel.Like{}
- for f, v := range mapFields(sw) {
- lk[f] = fmt.Sprintf("%%%s", v)
- }
- if isRoleExpr(sw) {
- return mapRoleExpr(lk, false).ToSql()
- }
- if isTagExpr(sw) {
- return mapTagExpr(lk, false).ToSql()
- }
- return lk.ToSql()
+func (ew EndsWith) MarshalJSON() ([]byte, error) {
+ return marshalExpression("endsWith", ew)
}
-func (sw EndsWith) MarshalJSON() ([]byte, error) {
- return marshalExpression("endsWith", sw)
-}
+func (ew EndsWith) fields() map[string]any { return ew }
type InTheRange map[string]any
-func (itr InTheRange) ToSql() (sql string, args []any, err error) {
- and := squirrel.And{}
- for f, v := range mapFields(itr) {
- s := reflect.ValueOf(v)
- if s.Kind() != reflect.Slice || s.Len() != 2 {
- return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", v)
- }
- and = append(and,
- squirrel.GtOrEq{f: s.Index(0).Interface()},
- squirrel.LtOrEq{f: s.Index(1).Interface()},
- )
- }
- return and.ToSql()
-}
-
func (itr InTheRange) MarshalJSON() ([]byte, error) {
return marshalExpression("inTheRange", itr)
}
-type InTheLast map[string]any
+func (itr InTheRange) fields() map[string]any { return itr }
-func (itl InTheLast) ToSql() (sql string, args []any, err error) {
- exp, err := inPeriod(itl, false)
- if err != nil {
- return "", nil, err
- }
- return exp.ToSql()
-}
+type InTheLast map[string]any
func (itl InTheLast) MarshalJSON() ([]byte, error) {
return marshalExpression("inTheLast", itl)
}
-type NotInTheLast map[string]any
+func (itl InTheLast) fields() map[string]any { return itl }
-func (nitl NotInTheLast) ToSql() (sql string, args []any, err error) {
- exp, err := inPeriod(nitl, true)
- if err != nil {
- return "", nil, err
- }
- return exp.ToSql()
-}
+type NotInTheLast map[string]any
func (nitl NotInTheLast) MarshalJSON() ([]byte, error) {
return marshalExpression("notInTheLast", nitl)
}
-func inPeriod(m map[string]any, negate bool) (Expression, error) {
- var field string
- var value any
- for f, v := range mapFields(m) {
- field, value = f, v
- break
- }
- str := fmt.Sprintf("%v", value)
- v, err := strconv.ParseInt(str, 10, 64)
- if err != nil {
- return nil, err
- }
- firstDate := startOfPeriod(v, time.Now())
-
- if negate {
- return Or{
- squirrel.Lt{field: firstDate},
- squirrel.Eq{field: nil},
- }, nil
- }
- return squirrel.Gt{field: firstDate}, nil
-}
-
-func startOfPeriod(numDays int64, from time.Time) string {
- return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
-}
+func (nitl NotInTheLast) fields() map[string]any { return nitl }
type InPlaylist map[string]any
-func (ipl InPlaylist) ToSql() (sql string, args []any, err error) {
- return inList(ipl, false)
-}
-
func (ipl InPlaylist) MarshalJSON() ([]byte, error) {
return marshalExpression("inPlaylist", ipl)
}
+func (ipl InPlaylist) fields() map[string]any { return ipl }
+
type NotInPlaylist map[string]any
-func (ipl NotInPlaylist) ToSql() (sql string, args []any, err error) {
- return inList(ipl, true)
+func (nipl NotInPlaylist) MarshalJSON() ([]byte, error) {
+ return marshalExpression("notInPlaylist", nipl)
}
-func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) {
- return marshalExpression("notInPlaylist", ipl)
+func (nipl NotInPlaylist) fields() map[string]any { return nipl }
+
+type IsMissing map[string]any
+
+func (im IsMissing) MarshalJSON() ([]byte, error) {
+ return marshalExpression("isMissing", im)
}
-func inList(m map[string]any, negate bool) (sql string, args []any, err error) {
- var playlistid string
- var ok bool
- if playlistid, ok = m["id"].(string); !ok {
- return "", nil, errors.New("playlist id not given")
- }
+func (im IsMissing) fields() map[string]any { return im }
- // Subquery to fetch all media files that are contained in given playlist
- // Only evaluate playlist if it is public
- subQuery := squirrel.Select("media_file_id").
- From("playlist_tracks pl").
- LeftJoin("playlist on pl.playlist_id = playlist.id").
- Where(squirrel.And{
- squirrel.Eq{"pl.playlist_id": playlistid},
- squirrel.Eq{"playlist.public": 1}})
- subQText, subQArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
+type IsPresent map[string]any
- if err != nil {
- return "", nil, err
- }
- if negate {
- return "media_file.id NOT IN (" + subQText + ")", subQArgs, nil
- } else {
- return "media_file.id IN (" + subQText + ")", subQArgs, nil
- }
+func (ip IsPresent) MarshalJSON() ([]byte, error) {
+ return marshalExpression("isPresent", ip)
}
+func (ip IsPresent) fields() map[string]any { return ip }
+
func extractPlaylistIds(inputRule any) (ids []string) {
var id string
var ok bool
diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go
index 95f9fc5f4..17c4272ba 100644
--- a/model/criteria/operators_test.go
+++ b/model/criteria/operators_test.go
@@ -3,7 +3,6 @@ package criteria_test
import (
"encoding/json"
"fmt"
- "time"
. "github.com/navidrome/navidrome/model/criteria"
. "github.com/onsi/ginkgo/v2"
@@ -17,109 +16,6 @@ var _ = BeforeSuite(func() {
})
var _ = Describe("Operators", func() {
- rangeStart := time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local)
- rangeEnd := time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local)
-
- DescribeTable("ToSQL",
- func(op Expression, expectedSql string, expectedArgs ...any) {
- sql, args, err := op.ToSql()
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(sql).To(gomega.Equal(expectedSql))
- gomega.Expect(args).To(gomega.HaveExactElements(expectedArgs...))
- },
- Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
- Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
- Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
- Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10),
- Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10),
- Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
- Entry("notContains", NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"),
- Entry("startsWith", StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
- Entry("endsWith", EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
- Entry("inTheRange [number]", InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
- Entry("inTheRange [date]", InTheRange{"lastPlayed": []time.Time{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd),
- Entry("before", Before{"lastPlayed": rangeStart}, "annotation.play_date < ?", rangeStart),
- Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart),
-
- // InPlaylist and NotInPlaylist are special cases
- Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN "+
- "(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
- Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN "+
- "(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
-
- Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())),
- Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
-
- // Tag tests
- Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
- Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
- Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value > ?)", "A"),
- Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value < ?)", "Z"),
- Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
- Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
- Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"),
- Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"),
-
- // Artist roles tests
- Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
- Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
- Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
- Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
- Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "John%"),
- Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
- )
-
- // TODO Validate operators that are not valid for each field type.
- XDescribeTable("ToSQL - Invalid Operators",
- func(op Expression, expectedError string) {
- _, _, err := op.ToSql()
- gomega.Expect(err).To(gomega.MatchError(expectedError))
- },
- Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"),
- )
-
- Describe("Custom Tags", func() {
- It("generates valid SQL", func() {
- AddTagNames([]string{"mood"})
- op := EndsWith{"mood": "Soft"}
- sql, args, err := op.ToSql()
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)"))
- gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
- })
- It("casts numeric comparisons", func() {
- AddNumericTags([]string{"rate"})
- op := Lt{"rate": 6}
- sql, args, err := op.ToSql()
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
- gomega.Expect(args).To(gomega.HaveExactElements(6))
- })
- It("skips unknown tag names", func() {
- op := EndsWith{"unknown": "value"}
- sql, args, _ := op.ToSql()
- gomega.Expect(sql).To(gomega.BeEmpty())
- gomega.Expect(args).To(gomega.BeEmpty())
- })
- })
-
- Describe("Custom Roles", func() {
- It("generates valid SQL", func() {
- AddRoles([]string{"producer"})
- op := EndsWith{"producer": "Eno"}
- sql, args, err := op.ToSql()
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(participants, '$.producer') where key='name' and value LIKE ?)"))
- gomega.Expect(args).To(gomega.HaveExactElements("%Eno"))
- })
- It("skips unknown roles", func() {
- op := Contains{"groupie": "Penny Lane"}
- sql, args, _ := op.ToSql()
- gomega.Expect(sql).To(gomega.BeEmpty())
- gomega.Expect(args).To(gomega.BeEmpty())
- })
- })
-
DescribeTable("JSON Marshaling",
func(op Expression, jsonString string) {
obj := And{op}
@@ -135,6 +31,7 @@ var _ = Describe("Operators", func() {
},
Entry("is [string]", Is{"title": "Low Rider"}, `{"is":{"title":"Low Rider"}}`),
Entry("is [bool]", Is{"loved": false}, `{"is":{"loved":false}}`),
+ Entry("is [string does not coerce non-boolean field]", Is{"title": "true"}, `{"is":{"title":"true"}}`),
Entry("isNot", IsNot{"title": "Low Rider"}, `{"isNot":{"title":"Low Rider"}}`),
Entry("gt", Gt{"playCount": 10.0}, `{"gt":{"playCount":10}}`),
Entry("lt", Lt{"playCount": 10.0}, `{"lt":{"playCount":10}}`),
@@ -150,5 +47,83 @@ var _ = Describe("Operators", func() {
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30.0}, `{"notInTheLast":{"lastPlayed":30}}`),
Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, `{"inPlaylist":{"id":"deadbeef-dead-beef"}}`),
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, `{"notInPlaylist":{"id":"deadbeef-dead-beef"}}`),
+ Entry("isMissing [true]", IsMissing{"genre": true}, `{"isMissing":{"genre":true}}`),
+ Entry("isMissing [false]", IsMissing{"genre": false}, `{"isMissing":{"genre":false}}`),
+ Entry("isPresent [true]", IsPresent{"genre": true}, `{"isPresent":{"genre":true}}`),
+ Entry("isPresent [false]", IsPresent{"genre": false}, `{"isPresent":{"genre":false}}`),
)
+
+ Describe("Boolean string coercion at unmarshal time (issue #4826)", func() {
+ It("coerces string 'true' to bool for boolean fields", func() {
+ var obj UnmarshalConjunctionType
+ err := json.Unmarshal([]byte(`[{"is":{"loved":"true"}}]`), &obj)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true}))
+ })
+
+ It("coerces string 'false' to bool for boolean fields", func() {
+ var obj UnmarshalConjunctionType
+ err := json.Unmarshal([]byte(`[{"is":{"loved":"false"}}]`), &obj)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false}))
+ })
+
+ It("does not coerce string values for non-boolean fields", func() {
+ var obj UnmarshalConjunctionType
+ err := json.Unmarshal([]byte(`[{"is":{"title":"true"}}]`), &obj)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(obj[0]).To(gomega.Equal(Is{"title": "true"}))
+ })
+
+ It("coerces numeric 1 to bool true for boolean fields", func() {
+ var obj UnmarshalConjunctionType
+ err := json.Unmarshal([]byte(`[{"is":{"loved":1}}]`), &obj)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true}))
+ })
+
+ It("coerces numeric 0 to bool false for boolean fields", func() {
+ var obj UnmarshalConjunctionType
+ err := json.Unmarshal([]byte(`[{"is":{"loved":0}}]`), &obj)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false}))
+ })
+
+ It("coerces in nested any/all groups", func() {
+ var c Criteria
+ err := json.Unmarshal([]byte(`{"all":[{"contains":{"title":"love"}},{"any":[{"is":{"loved":"true"}}]}]}`), &c)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ all := c.Expression.(All)
+ nested := all[1].(Any)
+ gomega.Expect(nested[0]).To(gomega.Equal(Is{"loved": true}))
+ })
+
+ It("coerces isMissing string 'true' to bool", func() {
+ var obj UnmarshalConjunctionType
+ err := json.Unmarshal([]byte(`[{"isMissing":{"genre":"true"}}]`), &obj)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": true}))
+ })
+
+ It("coerces isMissing numeric 0 to bool false", func() {
+ var obj UnmarshalConjunctionType
+ err := json.Unmarshal([]byte(`[{"isMissing":{"genre":0}}]`), &obj)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": false}))
+ })
+
+ It("coerces isPresent string 'false' to bool", func() {
+ var obj UnmarshalConjunctionType
+ err := json.Unmarshal([]byte(`[{"isPresent":{"genre":"false"}}]`), &obj)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": false}))
+ })
+
+ It("coerces isPresent numeric 1 to bool true", func() {
+ var obj UnmarshalConjunctionType
+ err := json.Unmarshal([]byte(`[{"isPresent":{"genre":1}}]`), &obj)
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": true}))
+ })
+ })
})
diff --git a/model/criteria/sort.go b/model/criteria/sort.go
new file mode 100644
index 000000000..e38fe0551
--- /dev/null
+++ b/model/criteria/sort.go
@@ -0,0 +1,62 @@
+package criteria
+
+import (
+ "strings"
+
+ "github.com/navidrome/navidrome/log"
+)
+
+type SortField struct {
+ Field string
+ Desc bool
+}
+
+func (c Criteria) OrderByFields() []SortField {
+ sortValue := c.Sort
+ if sortValue == "" {
+ sortValue = "title"
+ }
+
+ order := strings.ToLower(strings.TrimSpace(c.Order))
+ if order != "" && order != "asc" && order != "desc" {
+ log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order)
+ order = ""
+ }
+
+ parts := strings.Split(sortValue, ",")
+ fields := make([]SortField, 0, len(parts))
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ desc := false
+ if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") {
+ desc = strings.HasPrefix(part, "-")
+ part = strings.TrimSpace(part[1:])
+ }
+ info, ok := LookupField(part)
+ if !ok {
+ log.Error("Invalid field in 'sort' field", "sort", part)
+ continue
+ }
+ if order == "desc" {
+ desc = !desc
+ }
+ fields = append(fields, SortField{Field: info.Name(), Desc: desc})
+ }
+ if len(fields) == 0 {
+ log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue)
+ return []SortField{{Field: "title", Desc: false}}
+ }
+ return fields
+}
+
+func (c Criteria) SortFieldNames() []string {
+ sortFields := c.OrderByFields()
+ names := make([]string, len(sortFields))
+ for i, sf := range sortFields {
+ names[i] = sf.Field
+ }
+ return names
+}
diff --git a/model/criteria/sort_test.go b/model/criteria/sort_test.go
new file mode 100644
index 000000000..35db88549
--- /dev/null
+++ b/model/criteria/sort_test.go
@@ -0,0 +1,103 @@
+package criteria
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ "github.com/onsi/gomega"
+)
+
+var _ = Describe("OrderByFields", func() {
+ It("defaults to title ascending when Sort is empty", func() {
+ c := Criteria{}
+ gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
+ })
+
+ It("parses a single field", func() {
+ c := Criteria{Sort: "title"}
+ gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
+ })
+
+ It("parses descending prefix", func() {
+ c := Criteria{Sort: "-rating"}
+ gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "rating", Desc: true}}))
+ })
+
+ It("parses ascending prefix", func() {
+ c := Criteria{Sort: "+title"}
+ gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
+ })
+
+ It("parses multiple comma-separated fields", func() {
+ c := Criteria{Sort: "title,-rating"}
+ gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
+ {Field: "title", Desc: false},
+ {Field: "rating", Desc: true},
+ }))
+ })
+
+ It("inverts directions when Order is desc", func() {
+ c := Criteria{Sort: "-date,title", Order: "desc"}
+ gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
+ {Field: "date", Desc: false},
+ {Field: "title", Desc: true},
+ }))
+ })
+
+ It("skips invalid fields", func() {
+ c := Criteria{Sort: "bogus,title"}
+ gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
+ })
+
+ It("falls back to title when all fields are invalid", func() {
+ c := Criteria{Sort: "bogus,invalid"}
+ gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
+ })
+
+ It("resolves tag aliases (albumtype -> releasetype)", func() {
+ c := Criteria{Sort: "albumtype"}
+ gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "releasetype", Desc: false}}))
+ })
+
+ It("resolves field aliases (recordingdate -> date)", func() {
+ AddTagNames([]string{"recordingdate"})
+ c := Criteria{Sort: "recordingdate"}
+ gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "date", Desc: false}}))
+ })
+
+ It("handles the random field", func() {
+ c := Criteria{Sort: "random"}
+ gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "random", Desc: false}}))
+ })
+
+ It("ignores invalid Order value", func() {
+ c := Criteria{Sort: "-title", Order: "invalid"}
+ gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: true}}))
+ })
+
+ It("handles whitespace in fields", func() {
+ c := Criteria{Sort: " title , -rating "}
+ gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
+ {Field: "title", Desc: false},
+ {Field: "rating", Desc: true},
+ }))
+ })
+
+ It("skips empty parts from trailing commas", func() {
+ c := Criteria{Sort: "title,,rating,"}
+ gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
+ {Field: "title", Desc: false},
+ {Field: "rating", Desc: false},
+ }))
+ })
+})
+
+var _ = Describe("SortFieldNames", func() {
+ It("returns canonical field names", func() {
+ c := Criteria{Sort: "title,-rating,albumtype"}
+ gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title", "rating", "releasetype"}))
+ })
+
+ It("defaults to title when Sort is empty", func() {
+ c := Criteria{}
+ gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title"}))
+ })
+})
diff --git a/model/criteria/walk.go b/model/criteria/walk.go
new file mode 100644
index 000000000..7445c2aef
--- /dev/null
+++ b/model/criteria/walk.go
@@ -0,0 +1,37 @@
+package criteria
+
+import "fmt"
+
+type Visitor func(Expression) error
+
+func Walk(expr Expression, visit Visitor) error {
+ if expr == nil {
+ return nil
+ }
+ if err := visit(expr); err != nil {
+ return err
+ }
+ switch e := expr.(type) {
+ case All:
+ for _, child := range e {
+ if err := Walk(child, visit); err != nil {
+ return err
+ }
+ }
+ case Any:
+ for _, child := range e {
+ if err := Walk(child, visit); err != nil {
+ return err
+ }
+ }
+ case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist, IsMissing, IsPresent:
+ return nil
+ default:
+ return fmt.Errorf("unknown criteria expression type %T", expr)
+ }
+ return nil
+}
+
+func Fields(expr Expression) map[string]any {
+ return expr.fields()
+}
diff --git a/model/criteria/walk_test.go b/model/criteria/walk_test.go
new file mode 100644
index 000000000..2e0f12f8d
--- /dev/null
+++ b/model/criteria/walk_test.go
@@ -0,0 +1,64 @@
+package criteria
+
+import (
+ "fmt"
+
+ . "github.com/onsi/ginkgo/v2"
+ "github.com/onsi/gomega"
+)
+
+type unknownExpression struct{}
+
+func (unknownExpression) fields() map[string]any { return nil }
+
+var _ = Describe("Walk", func() {
+ It("visits the expression tree depth-first", func() {
+ expr := All{
+ Contains{"title": "love"},
+ Any{
+ Is{"album": "best of"},
+ Gt{"rating": 3},
+ },
+ }
+
+ var visited []string
+ err := Walk(expr, func(expr Expression) error {
+ visited = append(visited, fmt.Sprintf("%T", expr))
+ return nil
+ })
+
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(visited).To(gomega.Equal([]string{
+ "criteria.All",
+ "criteria.Contains",
+ "criteria.Any",
+ "criteria.Is",
+ "criteria.Gt",
+ }))
+ })
+
+ It("stops when the visitor returns an error", func() {
+ expectedErr := fmt.Errorf("stop")
+
+ err := Walk(All{Contains{"title": "love"}}, func(Expression) error {
+ return expectedErr
+ })
+
+ gomega.Expect(err).To(gomega.MatchError(expectedErr))
+ })
+
+ It("returns fields for leaf expressions", func() {
+ gomega.Expect(Fields(Contains{"title": "love"})).To(gomega.Equal(map[string]any{"title": "love"}))
+ gomega.Expect(Fields(After{"date": "2020-01-01"})).To(gomega.Equal(map[string]any{"date": "2020-01-01"}))
+ })
+
+ It("returns nil fields for group expressions", func() {
+ gomega.Expect(Fields(All{Contains{"title": "love"}})).To(gomega.BeNil())
+ })
+
+ It("returns an error for unknown expression types", func() {
+ err := Walk(unknownExpression{}, func(Expression) error { return nil })
+
+ gomega.Expect(err).To(gomega.MatchError("unknown criteria expression type criteria.unknownExpression"))
+ })
+})
diff --git a/model/datastore.go b/model/datastore.go
index 4290e2134..94c3c3622 100644
--- a/model/datastore.go
+++ b/model/datastore.go
@@ -38,10 +38,12 @@ type DataStore interface {
User(ctx context.Context) UserRepository
UserProps(ctx context.Context) UserPropsRepository
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
+ Scrobble(ctx context.Context) ScrobbleRepository
+ Plugin(ctx context.Context) PluginRepository
- Resource(ctx context.Context, model interface{}) ResourceRepository
+ Resource(ctx context.Context, model any) ResourceRepository
WithTx(block func(tx DataStore) error, scope ...string) error
WithTxImmediate(block func(tx DataStore) error, scope ...string) error
- GC(ctx context.Context) error
+ GC(ctx context.Context, libraryIDs ...int) error
}
diff --git a/model/errors.go b/model/errors.go
index ff4be5723..41029d316 100644
--- a/model/errors.go
+++ b/model/errors.go
@@ -8,4 +8,5 @@ var (
ErrNotAuthorized = errors.New("not authorized")
ErrExpired = errors.New("access expired")
ErrNotAvailable = errors.New("functionality not available")
+ ErrValidation = errors.New("validation error")
)
diff --git a/model/folder.go b/model/folder.go
index 3d14e7c53..7a769735e 100644
--- a/model/folder.go
+++ b/model/folder.go
@@ -17,7 +17,7 @@ import (
type Folder struct {
ID string `structs:"id"`
LibraryID int `structs:"library_id"`
- LibraryPath string `structs:"-" json:"-" hash:"-"`
+ LibraryPath string `structs:"-" json:"-" hash:"ignore"`
Path string `structs:"path"`
Name string `structs:"name"`
ParentID string `structs:"parent_id"`
@@ -25,6 +25,7 @@ type Folder struct {
NumPlaylists int `structs:"num_playlists"`
ImageFiles []string `structs:"image_files"`
ImagesUpdatedAt time.Time `structs:"images_updated_at"`
+ Hash string `structs:"hash"`
Missing bool `structs:"missing"`
UpdateAt time.Time `structs:"updated_at"`
CreatedAt time.Time `structs:"created_at"`
@@ -74,12 +75,17 @@ func NewFolder(lib Library, folderPath string) *Folder {
type FolderCursor iter.Seq2[Folder, error]
+type FolderUpdateInfo struct {
+ UpdatedAt time.Time
+ Hash string
+}
+
type FolderRepository interface {
Get(id string) (*Folder, error)
GetByPath(lib Library, path string) (*Folder, error)
GetAll(...QueryOptions) ([]Folder, error)
CountAll(...QueryOptions) (int64, error)
- GetLastUpdates(lib Library) (map[string]time.Time, error)
+ GetFolderUpdateInfo(lib Library, targetPaths ...string) (map[string]FolderUpdateInfo, error)
Put(*Folder) error
MarkMissing(missing bool, ids ...string) error
GetTouchedWithPlaylists() (FolderCursor, error)
diff --git a/model/folder_test.go b/model/folder_test.go
index 0535f6987..4c1b4c2b7 100644
--- a/model/folder_test.go
+++ b/model/folder_test.go
@@ -7,6 +7,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
+ "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -66,6 +67,7 @@ var _ = Describe("Folder", func() {
When("the folder has multiple subdirs", func() {
It("should return the correct folder ID", func() {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
folderPath := filepath.FromSlash("/music/rock/metal")
expectedID := id.NewHash("1:rock/metal")
Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID))
@@ -75,6 +77,7 @@ var _ = Describe("Folder", func() {
Describe("NewFolder", func() {
It("should create a new SubFolder with the correct attributes", func() {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
folderPath := filepath.FromSlash("rock/metal")
folder := model.NewFolder(lib, folderPath)
diff --git a/model/get_entity.go b/model/get_entity.go
index f51d8c36a..60972b2e9 100644
--- a/model/get_entity.go
+++ b/model/get_entity.go
@@ -5,7 +5,7 @@ import (
)
// TODO: Should the type be encoded in the ID?
-func GetEntityByID(ctx context.Context, ds DataStore, id string) (interface{}, error) {
+func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
ar, err := ds.Artist(ctx).Get(id)
if err == nil {
return ar, nil
@@ -22,5 +22,9 @@ func GetEntityByID(ctx context.Context, ds DataStore, id string) (interface{}, e
if err == nil {
return mf, nil
}
+ r, err := ds.Radio(ctx).Get(id)
+ if err == nil {
+ return r, nil
+ }
return nil, err
}
diff --git a/model/id/id.go b/model/id/id.go
index 930875260..b54542898 100644
--- a/model/id/id.go
+++ b/model/id/id.go
@@ -6,12 +6,12 @@ import (
"math/big"
"strings"
- gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/utils/nanoid"
)
func NewRandom() string {
- id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 22)
+ id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 22)
if err != nil {
log.Error("Could not generate new ID", err)
}
diff --git a/model/image.go b/model/image.go
new file mode 100644
index 000000000..30307fcea
--- /dev/null
+++ b/model/image.go
@@ -0,0 +1,17 @@
+package model
+
+import (
+ "path/filepath"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/consts"
+)
+
+// UploadedImagePath returns the absolute filesystem path for a manually uploaded
+// entity cover image. Returns empty string if filename is empty.
+func UploadedImagePath(entityType, filename string) string {
+ if filename == "" {
+ return ""
+ }
+ return filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, entityType, filename)
+}
diff --git a/model/library.go b/model/library.go
index a29f1c1d6..bcb2864c8 100644
--- a/model/library.go
+++ b/model/library.go
@@ -2,34 +2,60 @@ package model
import (
"time"
+
+ "github.com/navidrome/navidrome/utils/slice"
)
type Library struct {
- ID int
- Name string
- Path string
- RemotePath string
- LastScanAt time.Time
- LastScanStartedAt time.Time
- FullScanInProgress bool
- UpdatedAt time.Time
- CreatedAt time.Time
+ ID int `json:"id" db:"id"`
+ Name string `json:"name" db:"name"`
+ Path string `json:"path" db:"path"`
+ RemotePath string `json:"remotePath" db:"remote_path"`
+ LastScanAt time.Time `json:"lastScanAt" db:"last_scan_at"`
+ LastScanStartedAt time.Time `json:"lastScanStartedAt" db:"last_scan_started_at"`
+ FullScanInProgress bool `json:"fullScanInProgress" db:"full_scan_in_progress"`
+ UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
+ CreatedAt time.Time `json:"createdAt" db:"created_at"`
+ TotalSongs int `json:"totalSongs" db:"total_songs"`
+ TotalAlbums int `json:"totalAlbums" db:"total_albums"`
+ TotalArtists int `json:"totalArtists" db:"total_artists"`
+ TotalFolders int `json:"totalFolders" db:"total_folders"`
+ TotalFiles int `json:"totalFiles" db:"total_files"`
+ TotalMissingFiles int `json:"totalMissingFiles" db:"total_missing_files"`
+ TotalSize int64 `json:"totalSize" db:"total_size"`
+ TotalDuration float64 `json:"totalDuration" db:"total_duration"`
+ DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"`
}
+const (
+ DefaultLibraryID = 1
+ DefaultLibraryName = "Music Library"
+)
+
type Libraries []Library
+func (l Libraries) IDs() []int {
+ return slice.Map(l, func(lib Library) int { return lib.ID })
+}
+
type LibraryRepository interface {
Get(id int) (*Library, error)
// GetPath returns the path of the library with the given ID.
// Its implementation must be optimized to avoid unnecessary queries.
GetPath(id int) (string, error)
GetAll(...QueryOptions) (Libraries, error)
+ CountAll(...QueryOptions) (int64, error)
Put(*Library) error
+ Delete(id int) error
StoreMusicFolder() error
AddArtist(id int, artistID string) error
+ // User-library association methods
+ GetUsersWithLibraryAccess(libraryID int) (Users, error)
+
// TODO These methods should be moved to a core service
ScanBegin(id int, fullScan bool) error
ScanEnd(id int) error
ScanInProgress() (bool, error)
+ RefreshStats(id int) error
}
diff --git a/model/lyrics_test.go b/model/lyrics_test.go
index 382976872..644b85ad2 100644
--- a/model/lyrics_test.go
+++ b/model/lyrics_test.go
@@ -8,14 +8,13 @@ import (
var _ = Describe("ToLyrics", func() {
It("should parse tags with spaces", func() {
- num := int64(1551)
lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Lang).To(Equal("eng"))
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.DisplayArtist).To(Equal("An artist"))
Expect(lyrics.DisplayTitle).To(Equal("A title"))
- Expect(lyrics.Offset).To(Equal(&num))
+ Expect(lyrics.Offset).To(Equal(new(int64(1551))))
})
It("Should ignore bad offset", func() {
@@ -25,39 +24,36 @@ var _ = Describe("ToLyrics", func() {
})
It("should accept lines with no text and weird times", func() {
- a, b, c, d := int64(0), int64(10040), int64(40000), int64(1000*60*60)
lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
- {Start: &a, Value: "Hi there"},
- {Start: &b, Value: ""},
- {Start: &c, Value: "Test"},
- {Start: &d, Value: "late"},
+ {Start: new(int64(0)), Value: "Hi there"},
+ {Start: new(int64(10040)), Value: ""},
+ {Start: new(int64(40000)), Value: "Test"},
+ {Start: new(int64(1000 * 60 * 60)), Value: "late"},
}))
})
It("Should support multiple timestamps per line", func() {
- a, b, c, d := int64(0), int64(10000), int64(13*60*1000), int64(1000*60*60*51)
lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
- {Start: &a, Value: "Repeated"},
- {Start: &b, Value: "Repeated"},
- {Start: &c, Value: ""},
- {Start: &d, Value: ""},
+ {Start: new(int64(0)), Value: "Repeated"},
+ {Start: new(int64(10000)), Value: "Repeated"},
+ {Start: new(int64(13 * 60 * 1000)), Value: ""},
+ {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""},
}))
})
It("Should support parsing multiline string", func() {
- a, b := int64(0), int64(10*60*1000+1)
lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
- {Start: &a, Value: "This is\na multiline\n\n[:0] string"},
- {Start: &b, Value: "This is\nalso one"},
+ {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"},
+ {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"},
}))
})
@@ -71,49 +67,45 @@ var _ = Describe("ToLyrics", func() {
})
It("Allows timestamp in middle of line if also at beginning", func() {
- a, b := int64(0), int64(1000)
lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
- {Start: &a, Value: "This is [00:00:00] be a synced file"},
- {Start: &b, Value: "Line 2"},
+ {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"},
+ {Start: new(int64(1000)), Value: "Line 2"},
}))
})
It("Ignores lines in synchronized lyric prior to first timestamp", func() {
- a := int64(0)
lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
- {Start: &a, Value: "Text"},
+ {Start: new(int64(0)), Value: "Text"},
}))
})
It("Handles all possible ms cases", func() {
- a, b, c := int64(1), int64(10), int64(100)
lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
- {Start: &a, Value: "a"},
- {Start: &b, Value: "b"},
- {Start: &c, Value: "c"},
+ {Start: new(int64(1)), Value: "a"},
+ {Start: new(int64(10)), Value: "b"},
+ {Start: new(int64(100)), Value: "c"},
}))
})
It("Properly sorts repeated lyrics out of order", func() {
- a, b, c, d, e := int64(0), int64(10000), int64(40000), int64(13*60*1000), int64(1000*60*60*51)
lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
- {Start: &a, Value: "Repeated"},
- {Start: &b, Value: "Test"},
- {Start: &c, Value: "Not repeated"},
- {Start: &d, Value: "Repeated"},
- {Start: &e, Value: "Test"},
+ {Start: new(int64(0)), Value: "Repeated"},
+ {Start: new(int64(10000)), Value: "Test"},
+ {Start: new(int64(40000)), Value: "Not repeated"},
+ {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"},
+ {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"},
}))
})
})
diff --git a/model/mediafile.go b/model/mediafile.go
index cdb001c85..6be8402ae 100644
--- a/model/mediafile.go
+++ b/model/mediafile.go
@@ -9,6 +9,7 @@ import (
"mime"
"path/filepath"
"slices"
+ "strings"
"time"
"github.com/gohugoio/hashstructure"
@@ -25,7 +26,8 @@ type MediaFile struct {
ID string `structs:"id" json:"id" hash:"ignore"`
PID string `structs:"pid" json:"-" hash:"ignore"`
LibraryID int `structs:"library_id" json:"libraryId" hash:"ignore"`
- LibraryPath string `structs:"-" json:"libraryPath" hash:"-"`
+ LibraryPath string `structs:"-" json:"libraryPath" hash:"ignore"`
+ LibraryName string `structs:"-" json:"libraryName" hash:"ignore"`
FolderID string `structs:"folder_id" json:"folderId" hash:"ignore"`
Path string `structs:"path" json:"path" hash:"ignore"`
Title string `structs:"title" json:"title"`
@@ -35,53 +37,55 @@ type MediaFile struct {
Artist string `structs:"artist" json:"artist"`
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead
// AlbumArtist is the display name used for the album artist.
- AlbumArtist string `structs:"album_artist" json:"albumArtist"`
- AlbumID string `structs:"album_id" json:"albumId"`
- HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
- TrackNumber int `structs:"track_number" json:"trackNumber"`
- DiscNumber int `structs:"disc_number" json:"discNumber"`
- DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"`
- Year int `structs:"year" json:"year"`
- Date string `structs:"date" json:"date,omitempty"`
- OriginalYear int `structs:"original_year" json:"originalYear"`
- OriginalDate string `structs:"original_date" json:"originalDate,omitempty"`
- ReleaseYear int `structs:"release_year" json:"releaseYear"`
- ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"`
- Size int64 `structs:"size" json:"size"`
- Suffix string `structs:"suffix" json:"suffix"`
- Duration float32 `structs:"duration" json:"duration"`
- BitRate int `structs:"bit_rate" json:"bitRate"`
- SampleRate int `structs:"sample_rate" json:"sampleRate"`
- BitDepth int `structs:"bit_depth" json:"bitDepth"`
- Channels int `structs:"channels" json:"channels"`
- Genre string `structs:"genre" json:"genre"`
- Genres Genres `structs:"-" json:"genres,omitempty"`
- SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
- SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
- SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` // Deprecated: Use Participants instead
- SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` // Deprecated: Use Participants instead
- OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"`
- OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
- OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"` // Deprecated: Use Participants instead
- OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` // Deprecated: Use Participants instead
- Compilation bool `structs:"compilation" json:"compilation"`
- Comment string `structs:"comment" json:"comment,omitempty"`
- Lyrics string `structs:"lyrics" json:"lyrics"`
- BPM int `structs:"bpm" json:"bpm,omitempty"`
- ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"`
- CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
- MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,omitempty"`
- MbzReleaseTrackID string `structs:"mbz_release_track_id" json:"mbzReleaseTrackId,omitempty"`
- MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"`
- MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"`
- MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty"` // Deprecated: Use Participants instead
- MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"` // Deprecated: Use Participants instead
- MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"`
- MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"`
- RGAlbumGain float64 `structs:"rg_album_gain" json:"rgAlbumGain"`
- RGAlbumPeak float64 `structs:"rg_album_peak" json:"rgAlbumPeak"`
- RGTrackGain float64 `structs:"rg_track_gain" json:"rgTrackGain"`
- RGTrackPeak float64 `structs:"rg_track_peak" json:"rgTrackPeak"`
+ AlbumArtist string `structs:"album_artist" json:"albumArtist"`
+ AlbumID string `structs:"album_id" json:"albumId" hash:"ignore"`
+ HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
+ TrackNumber int `structs:"track_number" json:"trackNumber"`
+ DiscNumber int `structs:"disc_number" json:"discNumber"`
+ DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"`
+ Year int `structs:"year" json:"year"`
+ Date string `structs:"date" json:"date,omitempty"`
+ OriginalYear int `structs:"original_year" json:"originalYear"`
+ OriginalDate string `structs:"original_date" json:"originalDate,omitempty"`
+ ReleaseYear int `structs:"release_year" json:"releaseYear"`
+ ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"`
+ Size int64 `structs:"size" json:"size"`
+ Suffix string `structs:"suffix" json:"suffix"`
+ Duration float32 `structs:"duration" json:"duration"`
+ BitRate int `structs:"bit_rate" json:"bitRate"`
+ SampleRate int `structs:"sample_rate" json:"sampleRate"`
+ BitDepth int `structs:"bit_depth" json:"bitDepth"`
+ Channels int `structs:"channels" json:"channels"`
+ Codec string `structs:"codec" json:"codec"`
+ ProbeData string `structs:"probe_data" json:"-" hash:"ignore"`
+ Genre string `structs:"genre" json:"genre"`
+ Genres Genres `structs:"-" json:"genres,omitempty"`
+ SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
+ SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
+ SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` // Deprecated: Use Participants instead
+ SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` // Deprecated: Use Participants instead
+ OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"`
+ OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
+ OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"` // Deprecated: Use Participants instead
+ OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` // Deprecated: Use Participants instead
+ Compilation bool `structs:"compilation" json:"compilation"`
+ Comment string `structs:"comment" json:"comment,omitempty"`
+ Lyrics string `structs:"lyrics" json:"lyrics"`
+ BPM int `structs:"bpm" json:"bpm,omitempty"`
+ ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"`
+ CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
+ MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,omitempty"`
+ MbzReleaseTrackID string `structs:"mbz_release_track_id" json:"mbzReleaseTrackId,omitempty"`
+ MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"`
+ MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"`
+ MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty"` // Deprecated: Use Participants instead
+ MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"` // Deprecated: Use Participants instead
+ MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"`
+ MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"`
+ RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"`
+ RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"`
+ RGTrackGain *float64 `structs:"rg_track_gain" json:"rgTrackGain"`
+ RGTrackPeak *float64 `structs:"rg_track_peak" json:"rgTrackPeak"`
Tags Tags `structs:"tags" json:"tags,omitempty" hash:"ignore"` // All imported tags from the original file
Participants Participants `structs:"participants" json:"participants" hash:"ignore"` // All artists that participated in this track
@@ -93,12 +97,19 @@ type MediaFile struct {
}
func (mf MediaFile) FullTitle() string {
- if conf.Server.Subsonic.AppendSubtitle && mf.Tags[TagSubtitle] != nil {
+ if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 {
return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0])
}
return mf.Title
}
+func (mf MediaFile) FullAlbumName() string {
+ if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 {
+ return fmt.Sprintf("%s (%s)", mf.Album, mf.Tags[TagAlbumVersion][0])
+ }
+ return mf.Album
+}
+
func (mf MediaFile) ContentType() string {
return mime.TypeByExtension("." + mf.Suffix)
}
@@ -108,7 +119,16 @@ func (mf MediaFile) CoverArtID() ArtworkID {
if mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt {
return artworkIDFromMediaFile(mf)
}
- // if it does not have a coverArt, fallback to the album cover
+ // Otherwise fallback to disc (if available) or album cover
+ return mf.DiscCoverArtID()
+}
+
+// DiscCoverArtID returns the disc artwork ID when the media file has a disc number,
+// otherwise it returns the album artwork ID.
+func (mf MediaFile) DiscCoverArtID() ArtworkID {
+ if mf.DiscNumber > 0 {
+ return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil)
+ }
return mf.AlbumCoverArtID()
}
@@ -138,7 +158,7 @@ func (mf MediaFile) Hash() string {
}
hash, _ := hashstructure.Hash(mf, opts)
sum := md5.New()
- sum.Write([]byte(fmt.Sprintf("%d", hash)))
+ sum.Write(fmt.Appendf(nil, "%d", hash))
sum.Write(mf.Tags.Hash())
sum.Write(mf.Participants.Hash())
return fmt.Sprintf("%x", sum.Sum(nil))
@@ -159,6 +179,63 @@ func (mf MediaFile) AbsolutePath() string {
return filepath.Join(mf.LibraryPath, mf.Path)
}
+// AudioCodec returns the audio codec for this file.
+// Uses the stored Codec field if available, otherwise infers from Suffix and audio properties.
+func (mf MediaFile) AudioCodec() string {
+ // If we have a stored codec from scanning, normalize and return it
+ if mf.Codec != "" {
+ return strings.ToLower(mf.Codec)
+ }
+ // Fallback: infer from Suffix + BitDepth
+ return mf.inferCodecFromSuffix()
+}
+
+// inferCodecFromSuffix infers the codec from the file extension when Codec field is empty.
+func (mf MediaFile) inferCodecFromSuffix() string {
+ switch strings.ToLower(mf.Suffix) {
+ case "mp3", "mpga":
+ return "mp3"
+ case "mp2":
+ return "mp2"
+ case "ogg", "oga":
+ return "vorbis"
+ case "opus":
+ return "opus"
+ case "mpc":
+ return "mpc"
+ case "wma":
+ return "wma"
+ case "flac":
+ return "flac"
+ case "wav":
+ return "pcm"
+ case "aif", "aiff", "aifc":
+ return "pcm"
+ case "ape":
+ return "ape"
+ case "wv", "wvp":
+ return "wv"
+ case "tta":
+ return "tta"
+ case "tak":
+ return "tak"
+ case "shn":
+ return "shn"
+ case "dsf", "dff":
+ return "dsd"
+ case "m4a":
+ // AAC if BitDepth==0, ALAC if BitDepth>0
+ if mf.BitDepth > 0 {
+ return "alac"
+ }
+ return "aac"
+ case "m4b", "m4p", "m4r":
+ return "aac"
+ default:
+ return ""
+ }
+}
+
type MediaFiles []MediaFile
// ToAlbum creates an Album object based on the attributes of this MediaFiles collection.
@@ -284,6 +361,9 @@ func older(t1, t2 time.Time) time.Time {
if t1.IsZero() {
return t2
}
+ if t2.IsZero() {
+ return t1
+ }
if t1.After(t2) {
return t2
}
@@ -330,15 +410,35 @@ func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int
return currentPath, currentDisc
}
+// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
+// https://docs.fileformat.com/audio/m3u/#extended-m3u
+func (mfs MediaFiles) ToM3U8(title string, absolutePaths bool) string {
+ buf := strings.Builder{}
+ buf.WriteString("#EXTM3U\n")
+ buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", title))
+ for _, t := range mfs {
+ buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
+ if absolutePaths {
+ buf.WriteString(t.AbsolutePath() + "\n")
+ } else {
+ buf.WriteString(t.Path + "\n")
+ }
+ }
+ return buf.String()
+}
+
type MediaFileCursor iter.Seq2[MediaFile, error]
type MediaFileRepository interface {
CountAll(options ...QueryOptions) (int64, error)
+ CountBySuffix(options ...QueryOptions) (map[string]int64, error)
Exists(id string) (bool, error)
Put(m *MediaFile) error
+ UpdateProbeData(id string, data string) error
Get(id string) (*MediaFile, error)
GetWithParticipants(id string) (*MediaFile, error)
GetAll(options ...QueryOptions) (MediaFiles, error)
+ GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error)
GetCursor(options ...QueryOptions) (MediaFileCursor, error)
Delete(id string) error
DeleteMissing(ids []string) error
@@ -349,6 +449,8 @@ type MediaFileRepository interface {
MarkMissing(bool, ...*MediaFile) error
MarkMissingByFolder(missing bool, folderIDs ...string) error
GetMissingAndMatching(libId int) (MediaFileCursor, error)
+ FindRecentFilesByMBZTrackID(missing MediaFile, since time.Time) (MediaFiles, error)
+ FindRecentFilesByProperties(missing MediaFile, since time.Time) (MediaFiles, error)
AnnotatedRepository
BookmarkableRepository
diff --git a/model/mediafile_test.go b/model/mediafile_test.go
index 7f583cf75..3547ec4ef 100644
--- a/model/mediafile_test.go
+++ b/model/mediafile_test.go
@@ -6,6 +6,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -22,7 +23,7 @@ var _ = Describe("MediaFiles", func() {
SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
OrderAlbumName: "OrderAlbumName", OrderAlbumArtistName: "OrderAlbumArtistName",
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
- MbzReleaseGroupID: "MbzReleaseGroupID", Compilation: false, CatalogNum: "", Path: "/music1/file1.mp3", FolderID: "Folder1",
+ MbzReleaseGroupID: "MbzReleaseGroupID", Compilation: false, CatalogNum: "", Path: "music1/file1.mp3", FolderID: "Folder1",
},
{
ID: "2", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", AlbumID: "AlbumID",
@@ -30,7 +31,7 @@ var _ = Describe("MediaFiles", func() {
OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName",
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
MbzReleaseGroupID: "MbzReleaseGroupID",
- Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "/music2/file2.mp3", FolderID: "Folder2",
+ Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "music2/file2.mp3", FolderID: "Folder2",
},
}
})
@@ -51,7 +52,7 @@ var _ = Describe("MediaFiles", func() {
Expect(album.MbzReleaseGroupID).To(Equal("MbzReleaseGroupID"))
Expect(album.CatalogNum).To(Equal("CatalogNum"))
Expect(album.Compilation).To(BeTrue())
- Expect(album.EmbedArtPath).To(Equal("/music2/file2.mp3"))
+ Expect(album.EmbedArtPath).To(Equal("music2/file2.mp3"))
Expect(album.FolderIDs).To(ConsistOf("Folder1", "Folder2"))
})
})
@@ -119,6 +120,20 @@ var _ = Describe("MediaFiles", func() {
Expect(a.MinYear).To(Equal(1999))
})
})
+ Context("CreatedAt aggregation", func() {
+ It("ignores zero BirthTime values when computing the oldest", func() {
+ mfs = MediaFiles{
+ {BirthTime: t("2022-12-19 08:30")},
+ {BirthTime: time.Time{}},
+ {BirthTime: t("2022-12-18 10:00")},
+ }
+ Expect(mfs.ToAlbum().CreatedAt).To(Equal(t("2022-12-18 10:00")))
+ })
+ It("returns zero when all BirthTime values are zero", func() {
+ mfs = MediaFiles{{BirthTime: time.Time{}}, {BirthTime: time.Time{}}}
+ Expect(mfs.ToAlbum().CreatedAt).To(BeZero())
+ })
+ })
})
When("we have multiple songs with same dates", func() {
BeforeEach(func() {
@@ -402,6 +417,76 @@ var _ = Describe("MediaFiles", func() {
})
})
})
+
+ Describe("ToM3U8", func() {
+ It("returns header only for empty MediaFiles", func() {
+ mfs = MediaFiles{}
+ result := mfs.ToM3U8("My Playlist", false)
+ Expect(result).To(Equal("#EXTM3U\n#PLAYLIST:My Playlist\n"))
+ })
+
+ DescribeTable("duration formatting",
+ func(duration float32, expected string) {
+ mfs = MediaFiles{{Title: "Song", Artist: "Artist", Duration: duration, Path: "song.mp3"}}
+ result := mfs.ToM3U8("Test", false)
+ Expect(result).To(ContainSubstring(expected))
+ },
+ Entry("zero duration", float32(0.0), "#EXTINF:0,"),
+ Entry("whole number", float32(120.0), "#EXTINF:120,"),
+ Entry("rounds 0.5 down", float32(180.5), "#EXTINF:180,"),
+ Entry("rounds 0.6 up", float32(240.6), "#EXTINF:241,"),
+ )
+
+ Context("multiple tracks", func() {
+ BeforeEach(func() {
+ mfs = MediaFiles{
+ {Title: "Song One", Artist: "Artist A", Duration: 120, Path: "a/song1.mp3", LibraryPath: "/music"},
+ {Title: "Song Two", Artist: "Artist B", Duration: 241, Path: "b/song2.mp3", LibraryPath: "/music"},
+ {Title: "Song with \"quotes\" & ampersands", Artist: "Artist with Ümläuts", Duration: 90, Path: "special/file.mp3", LibraryPath: "/música"},
+ }
+ })
+
+ DescribeTable("generates correct output",
+ func(absolutePaths bool, expectedContent string) {
+ if absolutePaths {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
+ }
+ result := mfs.ToM3U8("Multi Track", absolutePaths)
+ Expect(result).To(Equal(expectedContent))
+ },
+ Entry("relative paths",
+ false,
+ "#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n",
+ ),
+ Entry("absolute paths",
+ true,
+ "#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\n/music/a/song1.mp3\n#EXTINF:241,Artist B - Song Two\n/music/b/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\n/música/special/file.mp3\n",
+ ),
+ Entry("special characters",
+ false,
+ "#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n",
+ ),
+ )
+ })
+
+ Context("path variations", func() {
+ It("handles different path structures", func() {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
+ mfs = MediaFiles{
+ {Title: "Root", Artist: "Artist", Duration: 60, Path: "song.mp3", LibraryPath: "/lib"},
+ {Title: "Nested", Artist: "Artist", Duration: 60, Path: "deep/nested/song.mp3", LibraryPath: "/lib"},
+ }
+
+ relativeResult := mfs.ToM3U8("Test", false)
+ Expect(relativeResult).To(ContainSubstring("song.mp3\n"))
+ Expect(relativeResult).To(ContainSubstring("deep/nested/song.mp3\n"))
+
+ absoluteResult := mfs.ToM3U8("Test", true)
+ Expect(absoluteResult).To(ContainSubstring("/lib/song.mp3\n"))
+ Expect(absoluteResult).To(ContainSubstring("/lib/deep/nested/song.mp3\n"))
+ })
+ })
+ })
})
var _ = Describe("MediaFile", func() {
@@ -409,20 +494,55 @@ var _ = Describe("MediaFile", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableMediaFileCoverArt = true
})
- Describe(".CoverArtId()", func() {
+ DescribeTable("FullTitle",
+ func(enabled bool, tags Tags, expected string) {
+ conf.Server.Subsonic.AppendSubtitle = enabled
+ mf := MediaFile{Title: "Song", Tags: tags}
+ Expect(mf.FullTitle()).To(Equal(expected))
+ },
+ Entry("appends subtitle when enabled and tag is present", true, Tags{TagSubtitle: []string{"Live"}}, "Song (Live)"),
+ Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"),
+ Entry("returns just title when tag is absent", true, Tags{}, "Song"),
+ Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"),
+ )
+ DescribeTable("FullAlbumName",
+ func(enabled bool, tags Tags, expected string) {
+ conf.Server.Subsonic.AppendAlbumVersion = enabled
+ mf := MediaFile{Album: "Album", Tags: tags}
+ Expect(mf.FullAlbumName()).To(Equal(expected))
+ },
+ Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album (Deluxe Edition)"),
+ Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"),
+ Entry("returns just album name when tag is absent", true, Tags{}, "Album"),
+ Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
+ )
+ Describe("CoverArtId", func() {
It("returns its own id if it HasCoverArt", func() {
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
id := mf.CoverArtID()
Expect(id.Kind).To(Equal(KindMediaFileArtwork))
Expect(id.ID).To(Equal(mf.ID))
})
- It("returns its album id if HasCoverArt is false", func() {
+ It("returns disc art id if HasCoverArt is false and DiscNumber > 0", func() {
+ mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false, DiscNumber: 2}
+ id := mf.CoverArtID()
+ Expect(id.Kind).To(Equal(KindDiscArtwork))
+ Expect(id.ID).To(Equal("1:2"))
+ })
+ It("returns its album id if HasCoverArt is false and DiscNumber is 0", func() {
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false}
id := mf.CoverArtID()
Expect(id.Kind).To(Equal(KindAlbumArtwork))
Expect(id.ID).To(Equal(mf.AlbumID))
})
- It("returns its album id if EnableMediaFileCoverArt is disabled", func() {
+ It("returns disc art id if EnableMediaFileCoverArt is disabled and DiscNumber > 0", func() {
+ conf.Server.EnableMediaFileCoverArt = false
+ mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, DiscNumber: 3}
+ id := mf.CoverArtID()
+ Expect(id.Kind).To(Equal(KindDiscArtwork))
+ Expect(id.ID).To(Equal("1:3"))
+ })
+ It("returns its album id if EnableMediaFileCoverArt is disabled and DiscNumber is 0", func() {
conf.Server.EnableMediaFileCoverArt = false
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
id := mf.CoverArtID()
@@ -430,6 +550,58 @@ var _ = Describe("MediaFile", func() {
Expect(id.ID).To(Equal(mf.AlbumID))
})
})
+
+ Describe("AudioCodec", func() {
+ It("returns normalized stored codec when available", func() {
+ mf := MediaFile{Codec: "AAC", Suffix: "m4a"}
+ Expect(mf.AudioCodec()).To(Equal("aac"))
+ })
+
+ It("returns stored codec lowercased", func() {
+ mf := MediaFile{Codec: "ALAC", Suffix: "m4a"}
+ Expect(mf.AudioCodec()).To(Equal("alac"))
+ })
+
+ DescribeTable("infers codec from suffix when Codec field is empty",
+ func(suffix string, bitDepth int, expected string) {
+ mf := MediaFile{Suffix: suffix, BitDepth: bitDepth}
+ Expect(mf.AudioCodec()).To(Equal(expected))
+ },
+ Entry("mp3", "mp3", 0, "mp3"),
+ Entry("mpga", "mpga", 0, "mp3"),
+ Entry("mp2", "mp2", 0, "mp2"),
+ Entry("ogg", "ogg", 0, "vorbis"),
+ Entry("oga", "oga", 0, "vorbis"),
+ Entry("opus", "opus", 0, "opus"),
+ Entry("mpc", "mpc", 0, "mpc"),
+ Entry("wma", "wma", 0, "wma"),
+ Entry("flac", "flac", 0, "flac"),
+ Entry("wav", "wav", 0, "pcm"),
+ Entry("aif", "aif", 0, "pcm"),
+ Entry("aiff", "aiff", 0, "pcm"),
+ Entry("aifc", "aifc", 0, "pcm"),
+ Entry("ape", "ape", 0, "ape"),
+ Entry("wv", "wv", 0, "wv"),
+ Entry("wvp", "wvp", 0, "wv"),
+ Entry("tta", "tta", 0, "tta"),
+ Entry("tak", "tak", 0, "tak"),
+ Entry("shn", "shn", 0, "shn"),
+ Entry("dsf", "dsf", 0, "dsd"),
+ Entry("dff", "dff", 0, "dsd"),
+ Entry("m4a with BitDepth=0 (AAC)", "m4a", 0, "aac"),
+ Entry("m4a with BitDepth>0 (ALAC)", "m4a", 16, "alac"),
+ Entry("m4b", "m4b", 0, "aac"),
+ Entry("m4p", "m4p", 0, "aac"),
+ Entry("m4r", "m4r", 0, "aac"),
+ Entry("unknown suffix", "xyz", 0, ""),
+ )
+
+ It("prefers stored codec over suffix inference", func() {
+ mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0}
+ Expect(mf.AudioCodec()).To(Equal("alac"))
+ })
+ })
+
})
func t(v string) time.Time {
diff --git a/model/metadata/legacy_ids.go b/model/metadata/legacy_ids.go
index 25025ea19..18a273550 100644
--- a/model/metadata/legacy_ids.go
+++ b/model/metadata/legacy_ids.go
@@ -14,18 +14,25 @@ import (
// These are the legacy ID functions that were used in the original Navidrome ID generation.
// They are kept here for backwards compatibility with existing databases.
-func legacyTrackID(mf model.MediaFile) string {
- return fmt.Sprintf("%x", md5.Sum([]byte(mf.Path)))
+func legacyTrackID(mf model.MediaFile, prependLibId bool) string {
+ id := mf.Path
+ if prependLibId && mf.LibraryID != model.DefaultLibraryID {
+ id = fmt.Sprintf("%d\\%s", mf.LibraryID, id)
+ }
+ return fmt.Sprintf("%x", md5.Sum([]byte(id)))
}
-func legacyAlbumID(md Metadata) string {
- releaseDate := legacyReleaseDate(md)
+func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string {
+ _, _, releaseDate := md.mapDates()
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", legacyMapAlbumArtistName(md), legacyMapAlbumName(md)))
if !conf.Server.Scanner.GroupAlbumReleases {
if len(releaseDate) != 0 {
albumPath = fmt.Sprintf("%s\\%s", albumPath, releaseDate)
}
}
+ if prependLibId && mf.LibraryID != model.DefaultLibraryID {
+ albumPath = fmt.Sprintf("%d\\%s", mf.LibraryID, albumPath)
+ }
return fmt.Sprintf("%x", md5.Sum([]byte(albumPath)))
}
@@ -48,9 +55,3 @@ func legacyMapAlbumName(md Metadata) string {
consts.UnknownAlbum,
)
}
-
-// Keep the TaggedLikePicard logic for backwards compatibility
-func legacyReleaseDate(md Metadata) string {
- _, _, releaseDate := md.mapDates()
- return string(releaseDate)
-}
diff --git a/model/metadata/legacy_ids_test.go b/model/metadata/legacy_ids_test.go
deleted file mode 100644
index b6d096763..000000000
--- a/model/metadata/legacy_ids_test.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package metadata
-
-import (
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("legacyReleaseDate", func() {
-
- DescribeTable("legacyReleaseDate",
- func(recordingDate, originalDate, releaseDate, expected string) {
- md := New("", Info{
- Tags: map[string][]string{
- "DATE": {recordingDate},
- "ORIGINALDATE": {originalDate},
- "RELEASEDATE": {releaseDate},
- },
- })
-
- result := legacyReleaseDate(md)
- Expect(result).To(Equal(expected))
- },
- Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"),
- Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"),
- )
-})
diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go
index b4857df85..824cad7c2 100644
--- a/model/metadata/map_mediafile.go
+++ b/model/metadata/map_mediafile.go
@@ -7,9 +7,9 @@ import (
"math"
"strconv"
+ "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils/str"
)
@@ -53,9 +53,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf.MbzAlbumType = md.String(model.TagReleaseType)
// ReplayGain
- mf.RGAlbumPeak = md.Float(model.TagReplayGainAlbumPeak, 1)
+ mf.RGAlbumPeak = md.NullableFloat(model.TagReplayGainAlbumPeak)
mf.RGAlbumGain = md.mapGain(model.TagReplayGainAlbumGain, model.TagR128AlbumGain)
- mf.RGTrackPeak = md.Float(model.TagReplayGainTrackPeak, 1)
+ mf.RGTrackPeak = md.NullableFloat(model.TagReplayGainTrackPeak)
mf.RGTrackGain = md.mapGain(model.TagReplayGainTrackGain, model.TagR128TrackGain)
// General properties
@@ -65,6 +65,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf.SampleRate = md.AudioProperties().SampleRate
mf.BitDepth = md.AudioProperties().BitDepth
mf.Channels = md.AudioProperties().Channels
+ mf.Codec = md.AudioProperties().Codec
mf.Path = md.FilePath()
mf.Suffix = md.Suffix()
mf.Size = md.Size()
@@ -77,7 +78,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
// Persistent IDs
mf.PID = md.trackPID(mf)
- mf.AlbumID = md.albumID(mf)
+ mf.AlbumID = md.albumID(mf, conf.Server.PID.Album)
// BFR These IDs will go away once the UI handle multiple participants.
// BFR For Legacy Subsonic compatibility, we will set them in the API handlers
@@ -104,27 +105,27 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
}
func (md Metadata) AlbumID(mf model.MediaFile, pidConf string) string {
- getPID := createGetPID(id.NewHash)
- return getPID(mf, md, pidConf)
+ return md.albumID(mf, pidConf)
}
-func (md Metadata) mapGain(rg, r128 model.TagName) float64 {
+func (md Metadata) mapGain(rg, r128 model.TagName) *float64 {
v := md.Gain(rg)
- if v != 0 {
+ if v != nil {
return v
}
r128value := md.String(r128)
if r128value != "" {
var v, err = strconv.Atoi(r128value)
if err != nil {
- return 0
+ return nil
}
// Convert Q7.8 to float
- var value = float64(v) / 256.0
+ value := float64(v) / 256.0
// Adding 5 dB to normalize with ReplayGain level
- return value + 5
+ value += 5
+ return &value
}
- return 0
+ return nil
}
func (md Metadata) mapLyrics() string {
diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go
index ddda39bc2..16142f526 100644
--- a/model/metadata/map_mediafile_test.go
+++ b/model/metadata/map_mediafile_test.go
@@ -8,7 +8,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
- . "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -75,6 +74,23 @@ var _ = Describe("ToMediaFile", func() {
Expect(mf.OriginalYear).To(Equal(1966))
Expect(mf.ReleaseYear).To(Equal(2014))
})
+ DescribeTable("legacyReleaseDate (TaggedLikePicard old behavior)",
+ func(recordingDate, originalDate, releaseDate, expected string) {
+ mf := toMediaFile(model.RawTags{
+ "DATE": {recordingDate},
+ "ORIGINALDATE": {originalDate},
+ "RELEASEDATE": {releaseDate},
+ })
+
+ Expect(mf.ReleaseDate).To(Equal(expected))
+ },
+ Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"),
+ Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"),
+ )
})
Describe("Lyrics", func() {
@@ -91,8 +107,8 @@ var _ = Describe("ToMediaFile", func() {
expected := model.LyricList{
{Lang: "eng", Line: []model.Line{
- {Value: "This is", Start: P(int64(0))},
- {Value: "English SYLT", Start: P(int64(2500))},
+ {Value: "This is", Start: new(int64(0))},
+ {Value: "English SYLT", Start: new(int64(2500))},
}, Synced: true},
{Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false},
}
diff --git a/model/metadata/map_participants_test.go b/model/metadata/map_participants_test.go
index 71cb9c1f2..5ee802ced 100644
--- a/model/metadata/map_participants_test.go
+++ b/model/metadata/map_participants_test.go
@@ -684,6 +684,26 @@ var _ = Describe("Participants", func() {
Expect(composers[2].Name).To(Equal("The Album Artist"))
})
})
+
+ // Sibling fix to https://github.com/navidrome/navidrome/issues/5065: when
+ // multiple frames map to the same role tag (e.g. TIPL producer entries),
+ // the configured split separator must still apply to each value.
+ When("the tag has multiple values", func() {
+ It("should split each value individually", func() {
+ mf = toMediaFile(model.RawTags{
+ "COMPOSER": {"John Doe/Jane Doe", "Someone Else"},
+ })
+
+ participants := mf.Participants
+ Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
+ composers := participants[model.RoleComposer]
+ Expect(composers).To(ConsistOf(
+ HaveField("Name", "John Doe"),
+ HaveField("Name", "Jane Doe"),
+ HaveField("Name", "Someone Else"),
+ ))
+ })
+ })
})
Describe("MBID tags", func() {
diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go
index 471c2434c..48928f989 100644
--- a/model/metadata/metadata.go
+++ b/model/metadata/metadata.go
@@ -35,6 +35,7 @@ type AudioProperties struct {
BitDepth int
SampleRate int
Channels int
+ Codec string
}
type Date string
@@ -103,9 +104,11 @@ func (md Metadata) NumAndTotal(key model.TagName) (int, int) { return md.tuple(k
func (md Metadata) Float(key model.TagName, def ...float64) float64 {
return float(md.first(key), def...)
}
-func (md Metadata) Gain(key model.TagName) float64 {
+func (md Metadata) NullableFloat(key model.TagName) *float64 { return nullableFloat(md.first(key)) }
+
+func (md Metadata) Gain(key model.TagName) *float64 {
v := strings.TrimSpace(strings.Replace(md.first(key), "dB", "", 1))
- return float(v)
+ return nullableFloat(v)
}
func (md Metadata) Pairs(key model.TagName) []Pair {
values := md.tags[key]
@@ -119,14 +122,22 @@ func (md Metadata) first(key model.TagName) string {
}
func float(value string, def ...float64) float64 {
+ v := nullableFloat(value)
+ if v != nil {
+ return *v
+ }
+ if len(def) > 0 {
+ return def[0]
+ }
+ return 0
+}
+
+func nullableFloat(value string) *float64 {
v, err := strconv.ParseFloat(value, 64)
if err != nil || v == math.Inf(-1) || math.IsInf(v, 1) || math.IsNaN(v) {
- if len(def) > 0 {
- return def[0]
- }
- return 0
+ return nil
}
- return v
+ return &v
}
// Used for tracks and discs
@@ -235,10 +246,22 @@ func processPairMapping(name model.TagName, mapping model.TagConf, lowered model
}
}
+ // always parse id3 pairs. For lyrics, Taglib appears to always provide lyrics:xxx
+ // Prefer that over format-specific tags
+ id3Base := parseID3Pairs(name, lowered)
+
if len(aliasValues) > 0 {
- return parseVorbisPairs(aliasValues)
+ // For lyrics, don't use parseVorbisPairs as parentheses in lyrics content
+ // should not be interpreted as language keys (e.g. "(intro)" is not a language)
+ if name == model.TagLyrics {
+ for _, v := range aliasValues {
+ id3Base = append(id3Base, NewPair("xxx", v))
+ }
+ } else {
+ id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
+ }
}
- return parseID3Pairs(name, lowered)
+ return id3Base
}
func parseID3Pairs(name model.TagName, lowered model.Tags) []string {
@@ -246,8 +269,8 @@ func parseID3Pairs(name model.TagName, lowered model.Tags) []string {
prefix := string(name) + ":"
for tagKey, tagValues := range lowered {
keyStr := string(tagKey)
- if strings.HasPrefix(keyStr, prefix) {
- keyPart := strings.TrimPrefix(keyStr, prefix)
+ if after, ok := strings.CutPrefix(keyStr, prefix); ok {
+ keyPart := after
if keyPart == string(name) {
keyPart = ""
}
diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go
index d7473afa7..350731b89 100644
--- a/model/metadata/metadata_test.go
+++ b/model/metadata/metadata_test.go
@@ -129,6 +129,21 @@ var _ = Describe("Metadata", func() {
Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"}))
})
+
+ // Regression test for https://github.com/navidrome/navidrome/issues/5065
+ //
+ // MP3s with both an ID3v2 TMOO frame and a TXXX:MOOD frame are surfaced by
+ // TagLib's PropertyMap as a single "mood" key with multiple values. The split
+ // configuration must still apply to each value individually.
+ It("should split values from multiple frames mapping to the same tag", func() {
+ props.Tags = model.RawTags{
+ // Same shape as the bug report: two frames, comma-separated content.
+ "mood": {"Love, Emotional, Ballad", "Love; Emotional; Ballad"},
+ }
+ md = metadata.New(filePath, props)
+
+ Expect(md.Strings(model.TagMood)).To(ConsistOf("Love", "Emotional", "Ballad"))
+ })
})
DescribeTable("Date",
@@ -245,6 +260,18 @@ var _ = Describe("Metadata", func() {
metadata.NewPair("eng", "Lyrics"),
))
})
+
+ It("should preserve lyrics starting with parentheses from alias tags", func() {
+ props.Tags = model.RawTags{
+ "LYRICS": {"(line one)\nline two\nline three"},
+ }
+ md = metadata.New(filePath, props)
+
+ Expect(md.All()).To(HaveKey(model.TagLyrics))
+ Expect(md.Strings(model.TagLyrics)).To(ContainElements(
+ metadata.NewPair("xxx", "(line one)\nline two\nline three"),
+ ))
+ })
})
Describe("ReplayGain", func() {
@@ -257,38 +284,39 @@ var _ = Describe("Metadata", func() {
}
DescribeTable("Gain",
- func(tagValue string, expected float64) {
+ func(tagValue string, expected *float64) {
mf := createMF("replaygain_track_gain", tagValue)
Expect(mf.RGTrackGain).To(Equal(expected))
},
- Entry("0", "0", 0.0),
- Entry("1.2dB", "1.2dB", 1.2),
- Entry("Infinity", "Infinity", 0.0),
- Entry("Invalid value", "INVALID VALUE", 0.0),
- Entry("NaN", "NaN", 0.0),
+ Entry("0", "0", new(0.0)),
+ Entry("1.2dB", "1.2dB", new(1.2)),
+ Entry("Infinity", "Infinity", nil),
+ Entry("Invalid value", "INVALID VALUE", nil),
+ Entry("NaN", "NaN", nil),
)
DescribeTable("Peak",
- func(tagValue string, expected float64) {
+ func(tagValue string, expected *float64) {
mf := createMF("replaygain_track_peak", tagValue)
Expect(mf.RGTrackPeak).To(Equal(expected))
},
- Entry("0", "0", 0.0),
- Entry("0.5", "0.5", 0.5),
- Entry("Invalid dB suffix", "0.7dB", 1.0),
- Entry("Infinity", "Infinity", 1.0),
- Entry("Invalid value", "INVALID VALUE", 1.0),
- Entry("NaN", "NaN", 1.0),
+ Entry("0", "0", new(0.0)),
+ Entry("1.0", "1.0", new(1.0)),
+ Entry("0.5", "0.5", new(0.5)),
+ Entry("Invalid dB suffix", "0.7dB", nil),
+ Entry("Infinity", "Infinity", nil),
+ Entry("Invalid value", "INVALID VALUE", nil),
+ Entry("NaN", "NaN", nil),
)
DescribeTable("getR128GainValue",
- func(tagValue string, expected float64) {
+ func(tagValue string, expected *float64) {
mf := createMF("r128_track_gain", tagValue)
Expect(mf.RGTrackGain).To(Equal(expected))
},
- Entry("0", "0", 5.0),
- Entry("-3776", "-3776", -9.75),
- Entry("Infinity", "Infinity", 0.0),
- Entry("Invalid value", "INVALID VALUE", 0.0),
+ Entry("0", "0", new(5.0)),
+ Entry("-3776", "-3776", new(-9.75)),
+ Entry("Infinity", "Infinity", nil),
+ Entry("Invalid value", "INVALID VALUE", nil),
)
})
diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go
index a71749e81..db315dc6b 100644
--- a/model/metadata/persistent_ids.go
+++ b/model/metadata/persistent_ids.go
@@ -2,86 +2,95 @@ package metadata
import (
"cmp"
+ "fmt"
"path/filepath"
"strings"
"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/id"
"github.com/navidrome/navidrome/utils"
- "github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
type hashFunc = func(...string) string
-// getPID returns the persistent ID for a given spec, getting the referenced values from the metadata
-// The spec is a pipe-separated list of fields, where each field is a comma-separated list of attributes
-// Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc.
-// For each field, it gets all its attributes values and concatenates them, then hashes the result.
-// If a field is empty, it is skipped and the function looks for the next field.
-func createGetPID(hash hashFunc) func(mf model.MediaFile, md Metadata, spec string) string {
- var getPID func(mf model.MediaFile, md Metadata, spec string) string
- getAttr := func(mf model.MediaFile, md Metadata, attr string) string {
- switch attr {
- case "albumid":
- return getPID(mf, md, conf.Server.PID.Album)
- case "folder":
- return filepath.Dir(mf.Path)
- case "albumartistid":
- return hash(str.Clear(strings.ToLower(mf.AlbumArtist)))
- case "title":
- return mf.Title
- case "album":
- return str.Clear(strings.ToLower(md.String(model.TagAlbum)))
- }
- return md.String(model.TagName(attr))
+// computePID calculates the persistent ID for a given spec. The spec is a
+// pipe-separated list of fields, where each field is a comma-separated list of
+// attributes. Attributes can be either tags or processed values like folder,
+// albumid, albumartistid, etc. For each field, it gets all its attribute values
+// and concatenates them, then hashes the result. If a field is empty, it is
+// skipped and the function looks for the next field.
+//
+// Taking hash as a parameter (instead of closing over it in a factory) keeps
+// mf on the stack: closing over mf would force the whole ~1KB MediaFile to the
+// heap on every call.
+func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string {
+ switch spec {
+ case "track_legacy":
+ return legacyTrackID(mf, prependLibId)
+ case "album_legacy":
+ return legacyAlbumID(mf, md, prependLibId)
}
- getPID = func(mf model.MediaFile, md Metadata, spec string) string {
- pid := ""
- fields := strings.Split(spec, "|")
- for _, field := range fields {
- attributes := strings.Split(field, ",")
- hasValue := false
- values := slice.Map(attributes, func(attr string) string {
- v := getAttr(mf, md, attr)
- if v != "" {
- hasValue = true
- }
- return v
- })
- if hasValue {
- pid += strings.Join(values, "\\")
- break
+ pid := ""
+ fields := strings.SplitSeq(spec, "|")
+ for field := range fields {
+ attributes := strings.Split(field, ",")
+ values := make([]string, len(attributes))
+ hasValue := false
+ for i, attr := range attributes {
+ v := getPIDAttr(mf, md, attr, prependLibId, spec, hash)
+ if v != "" {
+ hasValue = true
}
+ values[i] = v
+ }
+ if hasValue {
+ pid += strings.Join(values, "\\")
+ break
}
- return hash(pid)
}
+ if prependLibId {
+ pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid)
+ }
+ return hash(pid)
+}
- return func(mf model.MediaFile, md Metadata, spec string) string {
- switch spec {
- case "track_legacy":
- return legacyTrackID(mf)
- case "album_legacy":
- return legacyAlbumID(md)
+func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string {
+ attr = strings.TrimSpace(strings.ToLower(attr))
+ switch attr {
+ case "albumid":
+ if spec == conf.Server.PID.Album {
+ log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
+ return ""
}
- return getPID(mf, md, spec)
+ return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash)
+ case "folder":
+ return filepath.Dir(mf.Path)
+ case "albumartistid":
+ return hash(str.Clear(strings.ToLower(mf.AlbumArtist)))
+ case "title":
+ return mf.Title
+ case "album":
+ return str.Clear(strings.ToLower(md.String(model.TagAlbum)))
}
+ return md.String(model.TagName(attr))
}
func (md Metadata) trackPID(mf model.MediaFile) string {
- return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track)
+ return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash)
}
-func (md Metadata) albumID(mf model.MediaFile) string {
- return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Album)
+func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
+ return computePID(mf, md, pidConf, true, id.NewHash)
}
// BFR Must be configurable?
func (md Metadata) artistID(name string) string {
mf := model.MediaFile{AlbumArtist: name}
- return createGetPID(id.NewHash)(mf, md, "albumartistid")
+ return computePID(mf, md, "albumartistid", false, id.NewHash)
}
func (md Metadata) mapTrackTitle() string {
diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go
index 6903abc05..eb66d11d1 100644
--- a/model/metadata/persistent_ids_test.go
+++ b/model/metadata/persistent_ids_test.go
@@ -6,21 +6,23 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("getPID", func() {
var (
- md Metadata
- mf model.MediaFile
- sum hashFunc
- getPID func(mf model.MediaFile, md Metadata, spec string) string
+ md Metadata
+ mf model.MediaFile
+ sum hashFunc
)
+ getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
+ return computePID(mf, md, spec, prependLibId, sum)
+ }
BeforeEach(func() {
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
- getPID = createGetPID(sum)
})
Context("attributes are tags", func() {
@@ -28,7 +30,7 @@ var _ = Describe("getPID", func() {
When("no attributes were present", func() {
It("should return empty pid", func() {
md.tags = map[model.TagName][]string{}
- pid := getPID(mf, md, spec)
+ pid := getPID(mf, md, spec, false)
Expect(pid).To(Equal("()"))
})
})
@@ -40,7 +42,7 @@ var _ = Describe("getPID", func() {
"discnumber": {"1"},
"tracknumber": {"1"},
}
- Expect(getPID(mf, md, spec)).To(Equal("(mbtrackid)"))
+ Expect(getPID(mf, md, spec, false)).To(Equal("(mbtrackid)"))
})
})
When("only first field is present", func() {
@@ -48,7 +50,7 @@ var _ = Describe("getPID", func() {
md.tags = map[model.TagName][]string{
"musicbrainz_trackid": {"mbtrackid"},
}
- Expect(getPID(mf, md, spec)).To(Equal("(mbtrackid)"))
+ Expect(getPID(mf, md, spec, false)).To(Equal("(mbtrackid)"))
})
})
When("first is empty, but second field is present", func() {
@@ -57,14 +59,15 @@ var _ = Describe("getPID", func() {
"album": {"album name"},
"discnumber": {"1"},
}
- Expect(getPID(mf, md, spec)).To(Equal("(album name\\1\\)"))
+ Expect(getPID(mf, md, spec, false)).To(Equal("(album name\\1\\)"))
})
})
})
+
Context("calculated attributes", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
- conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,version,releasedate"
+ conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
})
When("field is title", func() {
It("should return the pid", func() {
@@ -72,28 +75,29 @@ var _ = Describe("getPID", func() {
md.tags = map[model.TagName][]string{"title": {"title"}}
md.filePath = "/path/to/file.mp3"
mf.Title = "Title"
- Expect(getPID(mf, md, spec)).To(Equal("(Title)"))
+ Expect(getPID(mf, md, spec, false)).To(Equal("(Title)"))
})
})
When("field is folder", func() {
It("should return the pid", func() {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-metadata)")
spec := "folder|title"
md.tags = map[model.TagName][]string{"title": {"title"}}
mf.Path = "/path/to/file.mp3"
- Expect(getPID(mf, md, spec)).To(Equal("(/path/to)"))
+ Expect(getPID(mf, md, spec, false)).To(Equal("(/path/to)"))
})
})
When("field is albumid", func() {
It("should return the pid", func() {
spec := "albumid|title"
md.tags = map[model.TagName][]string{
- "title": {"title"},
- "album": {"album name"},
- "version": {"version"},
- "releasedate": {"2021-01-01"},
+ "title": {"title"},
+ "album": {"album name"},
+ "albumversion": {"deluxe edition"},
+ "releasedate": {"2021-01-01"},
}
mf.AlbumArtist = "Album Artist"
- Expect(getPID(mf, md, spec)).To(Equal("(((album artist)\\album name\\version\\2021-01-01))"))
+ Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\deluxe edition\\2021-01-01))"))
})
})
When("field is albumartistid", func() {
@@ -103,14 +107,186 @@ var _ = Describe("getPID", func() {
"albumartist": {"Album Artist"},
}
mf.AlbumArtist = "Album Artist"
- Expect(getPID(mf, md, spec)).To(Equal("((album artist))"))
+ Expect(getPID(mf, md, spec, false)).To(Equal("((album artist))"))
})
})
When("field is album", func() {
It("should return the pid", func() {
spec := "album|title"
md.tags = map[model.TagName][]string{"album": {"Album Name"}}
- Expect(getPID(mf, md, spec)).To(Equal("(album name)"))
+ Expect(getPID(mf, md, spec, false)).To(Equal("(album name)"))
+ })
+ })
+
+ When("albumid configuration refers to albumid recursively", func() {
+ It("should avoid infinite recursion", func() {
+ // Reproduce the issue from #4920
+ conf.Server.PID.Album = "albumid,album,albumversion,releasedate"
+ spec := conf.Server.PID.Album
+ md.tags = map[model.TagName][]string{
+ "album": {"Album Name"},
+ "albumversion": {"Version"},
+ "releasedate": {"2022"},
+ }
+ // Should not panic and return a valid PID ignoring the recursive "albumid"
+ Expect(func() {
+ pid := getPID(mf, md, spec, false)
+ Expect(pid).To(Equal("(\\album name\\Version\\2022)"))
+ }).To(Not(Panic()))
+ })
+ })
+ })
+
+ Context("edge cases", func() {
+ When("the spec has spaces between groups", func() {
+ It("should return the pid", func() {
+ spec := "albumartist| Album"
+ md.tags = map[model.TagName][]string{
+ "album": {"album name"},
+ }
+ Expect(getPID(mf, md, spec, false)).To(Equal("(album name)"))
+ })
+ })
+ When("the spec has spaces", func() {
+ It("should return the pid", func() {
+ spec := "albumartist, album"
+ md.tags = map[model.TagName][]string{
+ "albumartist": {"Album Artist"},
+ "album": {"album name"},
+ }
+ Expect(getPID(mf, md, spec, false)).To(Equal("(Album Artist\\album name)"))
+ })
+ })
+ When("the spec has mixed case fields", func() {
+ It("should return the pid", func() {
+ spec := "albumartist,Album"
+ md.tags = map[model.TagName][]string{
+ "albumartist": {"Album Artist"},
+ "album": {"album name"},
+ }
+ Expect(getPID(mf, md, spec, false)).To(Equal("(Album Artist\\album name)"))
+ })
+ })
+ })
+
+ Context("prependLibId functionality", func() {
+ BeforeEach(func() {
+ mf.LibraryID = 42
+ })
+ When("prependLibId is true", func() {
+ It("should prepend library ID to the hash input", func() {
+ spec := "album"
+ md.tags = map[model.TagName][]string{"album": {"Test Album"}}
+ pid := getPID(mf, md, spec, true)
+ // The hash function should receive "42\test album" as input
+ Expect(pid).To(Equal("(42\\test album)"))
+ })
+ })
+ When("prependLibId is false", func() {
+ It("should not prepend library ID to the hash input", func() {
+ spec := "album"
+ md.tags = map[model.TagName][]string{"album": {"Test Album"}}
+ pid := getPID(mf, md, spec, false)
+ // The hash function should receive "test album" as input
+ Expect(pid).To(Equal("(test album)"))
+ })
+ })
+ When("prependLibId is true with complex spec", func() {
+ It("should prepend library ID to the final hash input", func() {
+ spec := "musicbrainz_trackid|album,tracknumber"
+ md.tags = map[model.TagName][]string{
+ "album": {"Test Album"},
+ "tracknumber": {"1"},
+ }
+ pid := getPID(mf, md, spec, true)
+ // Should use the fallback field and prepend library ID
+ Expect(pid).To(Equal("(42\\test album\\1)"))
+ })
+ })
+ When("prependLibId is true with nested albumid", func() {
+ It("should handle nested albumid calls correctly", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.PID.Album = "album"
+ spec := "albumid"
+ md.tags = map[model.TagName][]string{"album": {"Test Album"}}
+ mf.AlbumArtist = "Test Artist"
+ pid := getPID(mf, md, spec, true)
+ // The albumid call should also use prependLibId=true
+ Expect(pid).To(Equal("(42\\(42\\test album))"))
+ })
+ })
+ })
+
+ Context("legacy specs", func() {
+ Context("track_legacy", func() {
+ When("library ID is default (1)", func() {
+ It("should not prepend library ID even when prependLibId is true", func() {
+ mf.Path = "/path/to/track.mp3"
+ mf.LibraryID = 1 // Default library ID
+ // With default library, both should be the same
+ pidTrue := getPID(mf, md, "track_legacy", true)
+ pidFalse := getPID(mf, md, "track_legacy", false)
+ Expect(pidTrue).To(Equal(pidFalse))
+ Expect(pidTrue).NotTo(BeEmpty())
+ })
+ })
+ When("library ID is non-default", func() {
+ It("should prepend library ID when prependLibId is true", func() {
+ mf.Path = "/path/to/track.mp3"
+ mf.LibraryID = 2 // Non-default library ID
+ pidTrue := getPID(mf, md, "track_legacy", true)
+ pidFalse := getPID(mf, md, "track_legacy", false)
+ Expect(pidTrue).NotTo(Equal(pidFalse))
+ Expect(pidTrue).NotTo(BeEmpty())
+ Expect(pidFalse).NotTo(BeEmpty())
+ })
+ })
+ When("library ID is non-default but prependLibId is false", func() {
+ It("should not prepend library ID", func() {
+ mf.Path = "/path/to/track.mp3"
+ mf.LibraryID = 3
+ mf2 := mf
+ mf2.LibraryID = 1 // Default library
+ pidNonDefault := getPID(mf, md, "track_legacy", false)
+ pidDefault := getPID(mf2, md, "track_legacy", false)
+ // Should be the same since prependLibId=false
+ Expect(pidNonDefault).To(Equal(pidDefault))
+ })
+ })
+ })
+ Context("album_legacy", func() {
+ When("library ID is default (1)", func() {
+ It("should not prepend library ID even when prependLibId is true", func() {
+ md.tags = map[model.TagName][]string{"album": {"Test Album"}}
+ mf.LibraryID = 1 // Default library ID
+ pidTrue := getPID(mf, md, "album_legacy", true)
+ pidFalse := getPID(mf, md, "album_legacy", false)
+ Expect(pidTrue).To(Equal(pidFalse))
+ Expect(pidTrue).NotTo(BeEmpty())
+ })
+ })
+ When("library ID is non-default", func() {
+ It("should prepend library ID when prependLibId is true", func() {
+ md.tags = map[model.TagName][]string{"album": {"Test Album"}}
+ mf.LibraryID = 2 // Non-default library ID
+ pidTrue := getPID(mf, md, "album_legacy", true)
+ pidFalse := getPID(mf, md, "album_legacy", false)
+ Expect(pidTrue).NotTo(Equal(pidFalse))
+ Expect(pidTrue).NotTo(BeEmpty())
+ Expect(pidFalse).NotTo(BeEmpty())
+ })
+ })
+ When("library ID is non-default but prependLibId is false", func() {
+ It("should not prepend library ID", func() {
+ md.tags = map[model.TagName][]string{"album": {"Test Album"}}
+ mf.LibraryID = 3
+ mf2 := mf
+ mf2.LibraryID = 1 // Default library
+ pidNonDefault := getPID(mf, md, "album_legacy", false)
+ pidDefault := getPID(mf2, md, "album_legacy", false)
+ // Should be the same since prependLibId=false
+ Expect(pidNonDefault).To(Equal(pidDefault))
+ })
})
})
})
diff --git a/model/participants.go b/model/participants.go
index 5f07bf42c..afbda10de 100644
--- a/model/participants.go
+++ b/model/participants.go
@@ -25,6 +25,8 @@ var (
RoleRemixer = Role{"remixer"}
RoleDJMixer = Role{"djmixer"}
RolePerformer = Role{"performer"}
+ // RoleMainCredit is a credit where the artist is an album artist or artist
+ RoleMainCredit = Role{"maincredit"}
)
var AllRoles = map[string]Role{
@@ -41,6 +43,7 @@ var AllRoles = map[string]Role{
RoleRemixer.role: RoleRemixer,
RoleDJMixer.role: RoleDJMixer,
RolePerformer.role: RolePerformer,
+ RoleMainCredit.role: RoleMainCredit,
}
// Role represents the role of an artist in a track or album.
diff --git a/model/playlist.go b/model/playlist.go
index 521adfcd0..dc549f039 100644
--- a/model/playlist.go
+++ b/model/playlist.go
@@ -1,30 +1,31 @@
package model
import (
- "fmt"
"slices"
"strconv"
- "strings"
"time"
+ "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model/criteria"
)
type Playlist struct {
- ID string `structs:"id" json:"id"`
- Name string `structs:"name" json:"name"`
- Comment string `structs:"comment" json:"comment"`
- Duration float32 `structs:"duration" json:"duration"`
- Size int64 `structs:"size" json:"size"`
- SongCount int `structs:"song_count" json:"songCount"`
- OwnerName string `structs:"-" json:"ownerName"`
- OwnerID string `structs:"owner_id" json:"ownerId"`
- Public bool `structs:"public" json:"public"`
- Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"`
- Path string `structs:"path" json:"path"`
- Sync bool `structs:"sync" json:"sync"`
- CreatedAt time.Time `structs:"created_at" json:"createdAt"`
- UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
+ ID string `structs:"id" json:"id"`
+ Name string `structs:"name" json:"name"`
+ Comment string `structs:"comment" json:"comment"`
+ Duration float32 `structs:"duration" json:"duration"`
+ Size int64 `structs:"size" json:"size"`
+ SongCount int `structs:"song_count" json:"songCount"`
+ OwnerName string `structs:"-" json:"ownerName"`
+ OwnerID string `structs:"owner_id" json:"ownerId"`
+ Public bool `structs:"public" json:"public"`
+ Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"`
+ Path string `structs:"path" json:"path"`
+ Sync bool `structs:"sync" json:"sync"`
+ UploadedImage string `structs:"uploaded_image" json:"uploadedImage"`
+ ExternalImageURL string `structs:"external_image_url" json:"externalImageUrl,omitempty"`
+ CreatedAt time.Time `structs:"created_at" json:"createdAt"`
+ UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
// SmartPlaylist attributes
Rules *criteria.Criteria `structs:"rules" json:"rules"`
@@ -42,6 +43,21 @@ func (pls Playlist) MediaFiles() MediaFiles {
return pls.Tracks.MediaFiles()
}
+func (pls *Playlist) refreshStats() {
+ pls.SongCount = len(pls.Tracks)
+ pls.Duration = 0
+ pls.Size = 0
+ for _, t := range pls.Tracks {
+ pls.Duration += t.MediaFile.Duration
+ pls.Size += t.MediaFile.Size
+ }
+}
+
+func (pls *Playlist) SetTracks(tracks PlaylistTracks) {
+ pls.Tracks = tracks
+ pls.refreshStats()
+}
+
func (pls *Playlist) RemoveTracks(idxToRemove []int) {
var newTracks PlaylistTracks
for i, t := range pls.Tracks {
@@ -51,22 +67,15 @@ func (pls *Playlist) RemoveTracks(idxToRemove []int) {
newTracks = append(newTracks, t)
}
pls.Tracks = newTracks
+ pls.refreshStats()
}
-// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
-// https://docs.fileformat.com/audio/m3u/#extended-m3u
+// ToM3U8 exports the playlist to the Extended M3U8 format
func (pls *Playlist) ToM3U8() string {
- buf := strings.Builder{}
- buf.WriteString("#EXTM3U\n")
- buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", pls.Name))
- for _, t := range pls.Tracks {
- buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
- buf.WriteString(t.AbsolutePath() + "\n")
- }
- return buf.String()
+ return pls.MediaFiles().ToM3U8(pls.Name, true)
}
-func (pls *Playlist) AddTracks(mediaFileIds []string) {
+func (pls *Playlist) AddMediaFilesByID(mediaFileIds []string) {
pos := len(pls.Tracks)
for _, mfId := range mediaFileIds {
pos++
@@ -78,6 +87,7 @@ func (pls *Playlist) AddTracks(mediaFileIds []string) {
}
pls.Tracks = append(pls.Tracks, t)
}
+ pls.refreshStats()
}
func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
@@ -92,25 +102,35 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
}
pls.Tracks = append(pls.Tracks, t)
}
+ pls.refreshStats()
}
func (pls Playlist) CoverArtID() ArtworkID {
return artworkIDFromPlaylist(pls)
}
+// UploadedImagePath returns the absolute filesystem path for a manually uploaded
+// playlist cover image. Returns empty string if no image has been uploaded.
+// This does NOT cover sidecar images or external URLs — those are resolved
+// by the artwork reader's fallback chain.
+func (pls Playlist) UploadedImagePath() string {
+ return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage)
+}
+
type Playlists []Playlist
type PlaylistRepository interface {
ResourceRepository
CountAll(options ...QueryOptions) (int64, error)
Exists(id string) (bool, error)
- Put(pls *Playlist) error
+ Put(pls *Playlist, cols ...string) error
Get(id string) (*Playlist, error)
GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error)
GetAll(options ...QueryOptions) (Playlists, error)
FindByPath(path string) (*Playlist, error)
Delete(id string) error
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
+ GetPlaylists(mediaFileId string) (Playlists, error)
}
type PlaylistTrack struct {
diff --git a/model/playlists_test.go b/model/playlist_test.go
similarity index 68%
rename from model/playlists_test.go
rename to model/playlist_test.go
index 600e116cc..9ed24f00f 100644
--- a/model/playlists_test.go
+++ b/model/playlist_test.go
@@ -2,6 +2,7 @@ package model_test
import (
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -13,16 +14,21 @@ var _ = Describe("Playlist", func() {
pls = model.Playlist{Name: "Mellow sunset"}
pls.Tracks = model.PlaylistTracks{
{MediaFile: model.MediaFile{Artist: "Morcheeba feat. Kurt Wagner", Title: "What New York Couples Fight About",
- Duration: 377.84, Path: "/music/library/Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}},
+ Duration: 377.84,
+ LibraryPath: "/music/library", Path: "Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}},
{MediaFile: model.MediaFile{Artist: "A Tribe Called Quest", Title: "Description of a Fool (Groove Armada's Acoustic mix)",
- Duration: 374.49, Path: "/music/library/Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}},
+ Duration: 374.49,
+ LibraryPath: "/music/library", Path: "Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}},
{MediaFile: model.MediaFile{Artist: "Lou Reed", Title: "Walk on the Wild Side",
- Duration: 253.1, Path: "/music/library/Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}},
+ Duration: 253.1,
+ LibraryPath: "/music/library", Path: "Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}},
{MediaFile: model.MediaFile{Artist: "Legião Urbana", Title: "On the Way Home",
- Duration: 163.89, Path: "/music/library/Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}},
+ Duration: 163.89,
+ LibraryPath: "/music/library", Path: "Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}},
}
})
It("generates the correct M3U format", func() {
+ tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
expected := `#EXTM3U
#PLAYLIST:Mellow sunset
#EXTINF:378,Morcheeba feat. Kurt Wagner - What New York Couples Fight About
diff --git a/model/playqueue.go b/model/playqueue.go
index 52ba173d3..03b562253 100644
--- a/model/playqueue.go
+++ b/model/playqueue.go
@@ -7,7 +7,7 @@ import (
type PlayQueue struct {
ID string `structs:"id" json:"id"`
UserID string `structs:"user_id" json:"userId"`
- Current string `structs:"current" json:"current"`
+ Current int `structs:"current" json:"current"`
Position int64 `structs:"position" json:"position"`
ChangedBy string `structs:"changed_by" json:"changedBy"`
Items MediaFiles `structs:"-" json:"items,omitempty"`
@@ -18,6 +18,11 @@ type PlayQueue struct {
type PlayQueues []PlayQueue
type PlayQueueRepository interface {
- Store(queue *PlayQueue) error
+ Store(queue *PlayQueue, colNames ...string) error
+ // Retrieve returns the playqueue without loading the full MediaFiles
+ // (Items only contain IDs)
Retrieve(userId string) (*PlayQueue, error)
+ // RetrieveWithMediaFiles returns the playqueue with full MediaFiles loaded
+ RetrieveWithMediaFiles(userId string) (*PlayQueue, error)
+ Clear(userId string) error
}
diff --git a/model/plugin.go b/model/plugin.go
new file mode 100644
index 000000000..18d66e305
--- /dev/null
+++ b/model/plugin.go
@@ -0,0 +1,32 @@
+package model
+
+import "time"
+
+type Plugin struct {
+ ID string `structs:"id" json:"id"`
+ Path string `structs:"path" json:"path"`
+ Manifest string `structs:"manifest" json:"manifest"`
+ Config string `structs:"config" json:"config,omitempty"`
+ Users string `structs:"users" json:"users,omitempty"`
+ AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
+ Libraries string `structs:"libraries" json:"libraries,omitempty"`
+ AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
+ AllowWriteAccess bool `structs:"allow_write_access" json:"allowWriteAccess,omitempty"`
+ Enabled bool `structs:"enabled" json:"enabled"`
+ LastError string `structs:"last_error" json:"lastError,omitempty"`
+ SHA256 string `structs:"sha256" json:"sha256"`
+ CreatedAt time.Time `structs:"created_at" json:"createdAt"`
+ UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
+}
+
+type Plugins []Plugin
+
+type PluginRepository interface {
+ ResourceRepository
+ ClearErrors() error
+ CountAll(options ...QueryOptions) (int64, error)
+ Delete(id string) error
+ Get(id string) (*Plugin, error)
+ GetAll(options ...QueryOptions) (Plugins, error)
+ Put(p *Plugin) error
+}
diff --git a/model/radio.go b/model/radio.go
index 567d32e44..86f27c24c 100644
--- a/model/radio.go
+++ b/model/radio.go
@@ -1,14 +1,27 @@
package model
-import "time"
+import (
+ "time"
+
+ "github.com/navidrome/navidrome/consts"
+)
type Radio struct {
- ID string `structs:"id" json:"id"`
- StreamUrl string `structs:"stream_url" json:"streamUrl"`
- Name string `structs:"name" json:"name"`
- HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
- CreatedAt time.Time `structs:"created_at" json:"createdAt"`
- UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
+ ID string `structs:"id" json:"id"`
+ StreamUrl string `structs:"stream_url" json:"streamUrl"`
+ Name string `structs:"name" json:"name"`
+ HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
+ UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"`
+ CreatedAt time.Time `structs:"created_at" json:"createdAt"`
+ UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
+}
+
+func (r Radio) CoverArtID() ArtworkID {
+ return artworkIDFromRadio(r)
+}
+
+func (r Radio) UploadedImagePath() string {
+ return UploadedImagePath(consts.EntityRadio, r.UploadedImage)
}
type Radios []Radio
@@ -19,5 +32,5 @@ type RadioRepository interface {
Delete(id string) error
Get(id string) (*Radio, error)
GetAll(options ...QueryOptions) (Radios, error)
- Put(u *Radio) error
+ Put(u *Radio, colsToUpdate ...string) error
}
diff --git a/model/radio_test.go b/model/radio_test.go
new file mode 100644
index 000000000..860331f17
--- /dev/null
+++ b/model/radio_test.go
@@ -0,0 +1,42 @@
+package model_test
+
+import (
+ "path/filepath"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/model"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Radio", func() {
+ Describe("CoverArtID", func() {
+ It("returns a radio artwork ID", func() {
+ now := time.Now()
+ r := model.Radio{ID: "rd-1", UpdatedAt: now}
+ artID := r.CoverArtID()
+ Expect(artID.Kind).To(Equal(model.KindRadioArtwork))
+ Expect(artID.ID).To(Equal("rd-1"))
+ Expect(artID.LastUpdate).To(Equal(now))
+ })
+ })
+
+ Describe("UploadedImagePath", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.DataFolder = conf.NewDir("/data")
+ })
+
+ It("returns empty string when no image uploaded", func() {
+ r := model.Radio{ID: "rd-1"}
+ Expect(r.UploadedImagePath()).To(BeEmpty())
+ })
+
+ It("returns full path when image is set", func() {
+ r := model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
+ Expect(r.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "radio", "rd-1_test.jpg")))
+ })
+ })
+})
diff --git a/model/request/request.go b/model/request/request.go
index 5f2980340..8d7919298 100644
--- a/model/request/request.go
+++ b/model/request/request.go
@@ -17,6 +17,7 @@ const (
Transcoding = contextKey("transcoding")
ClientUniqueId = contextKey("clientUniqueId")
ReverseProxyIp = contextKey("reverseProxyIp")
+ InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins
)
var allKeys = []contextKey{
@@ -28,6 +29,7 @@ var allKeys = []contextKey{
Transcoding,
ClientUniqueId,
ReverseProxyIp,
+ InternalAuth,
}
func WithUser(ctx context.Context, u model.User) context.Context {
@@ -62,6 +64,10 @@ func WithReverseProxyIp(ctx context.Context, reverseProxyIp string) context.Cont
return context.WithValue(ctx, ReverseProxyIp, reverseProxyIp)
}
+func WithInternalAuth(ctx context.Context, username string) context.Context {
+ return context.WithValue(ctx, InternalAuth, username)
+}
+
func UserFrom(ctx context.Context) (model.User, bool) {
v, ok := ctx.Value(User).(model.User)
return v, ok
@@ -102,6 +108,15 @@ func ReverseProxyIpFrom(ctx context.Context) (string, bool) {
return v, ok
}
+func InternalAuthFrom(ctx context.Context) (string, bool) {
+ if v := ctx.Value(InternalAuth); v != nil {
+ if username, ok := v.(string); ok {
+ return username, true
+ }
+ }
+ return "", false
+}
+
func AddValues(ctx, requestCtx context.Context) context.Context {
for _, key := range allKeys {
if v := requestCtx.Value(key); v != nil {
diff --git a/model/scanner.go b/model/scanner.go
new file mode 100644
index 000000000..54f81037c
--- /dev/null
+++ b/model/scanner.go
@@ -0,0 +1,81 @@
+package model
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// ScanTarget represents a specific folder within a library to be scanned.
+// NOTE: This struct is used as a map key, so it should only contain comparable types.
+type ScanTarget struct {
+ LibraryID int
+ FolderPath string // Relative path within the library, or "" for entire library
+}
+
+func (st ScanTarget) String() string {
+ return fmt.Sprintf("%d:%s", st.LibraryID, st.FolderPath)
+}
+
+// ScannerStatus holds information about the current scan status
+type ScannerStatus struct {
+ Scanning bool
+ LastScan time.Time
+ Count uint32
+ FolderCount uint32
+ LastError string
+ ScanType string
+ ElapsedTime time.Duration
+}
+
+type Scanner interface {
+ // ScanAll starts a scan of all libraries. This is a blocking operation.
+ ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
+ // ScanFolders scans specific library/folder pairs, recursing into subdirectories.
+ // If targets is nil, it scans all libraries. This is a blocking operation.
+ ScanFolders(ctx context.Context, fullScan bool, targets []ScanTarget) (warnings []string, err error)
+ Status(context.Context) (*ScannerStatus, error)
+}
+
+// ParseTargets parses scan targets strings into ScanTarget structs.
+// Example: []string{"1:Music/Rock", "2:Classical"}
+func ParseTargets(libFolders []string) ([]ScanTarget, error) {
+ targets := make([]ScanTarget, 0, len(libFolders))
+
+ for _, part := range libFolders {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+
+ // Split by the first colon
+ before, after, ok := strings.Cut(part, ":")
+ if !ok {
+ return nil, fmt.Errorf("invalid target format: %q (expected libraryID:folderPath)", part)
+ }
+
+ libIDStr := before
+ folderPath := after
+
+ libID, err := strconv.Atoi(libIDStr)
+ if err != nil {
+ return nil, fmt.Errorf("invalid library ID %q: %w", libIDStr, err)
+ }
+ if libID <= 0 {
+ return nil, fmt.Errorf("invalid library ID %q", libIDStr)
+ }
+
+ targets = append(targets, ScanTarget{
+ LibraryID: libID,
+ FolderPath: folderPath,
+ })
+ }
+
+ if len(targets) == 0 {
+ return nil, fmt.Errorf("no valid targets found")
+ }
+
+ return targets, nil
+}
diff --git a/model/scanner_test.go b/model/scanner_test.go
new file mode 100644
index 000000000..8ca0c53fa
--- /dev/null
+++ b/model/scanner_test.go
@@ -0,0 +1,89 @@
+package model_test
+
+import (
+ "github.com/navidrome/navidrome/model"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("ParseTargets", func() {
+ It("parses multiple entries in slice", func() {
+ targets, err := model.ParseTargets([]string{"1:Music/Rock", "1:Music/Jazz", "2:Classical"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(3))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
+ Expect(targets[1].LibraryID).To(Equal(1))
+ Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
+ Expect(targets[2].LibraryID).To(Equal(2))
+ Expect(targets[2].FolderPath).To(Equal("Classical"))
+ })
+
+ It("handles empty folder paths", func() {
+ targets, err := model.ParseTargets([]string{"1:", "2:"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ Expect(targets[0].FolderPath).To(Equal(""))
+ Expect(targets[1].FolderPath).To(Equal(""))
+ })
+
+ It("trims whitespace from entries", func() {
+ targets, err := model.ParseTargets([]string{" 1:Music/Rock", " 2:Classical "})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
+ Expect(targets[1].LibraryID).To(Equal(2))
+ Expect(targets[1].FolderPath).To(Equal("Classical"))
+ })
+
+ It("skips empty strings", func() {
+ targets, err := model.ParseTargets([]string{"1:Music/Rock", "", "2:Classical"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ })
+
+ It("handles paths with colons", func() {
+ targets, err := model.ParseTargets([]string{"1:C:/Music/Rock", "2:/path:with:colons"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ Expect(targets[0].FolderPath).To(Equal("C:/Music/Rock"))
+ Expect(targets[1].FolderPath).To(Equal("/path:with:colons"))
+ })
+
+ It("returns error for invalid format without colon", func() {
+ _, err := model.ParseTargets([]string{"1Music/Rock"})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid target format"))
+ })
+
+ It("returns error for non-numeric library ID", func() {
+ _, err := model.ParseTargets([]string{"abc:Music/Rock"})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid library ID"))
+ })
+
+ It("returns error for negative library ID", func() {
+ _, err := model.ParseTargets([]string{"-1:Music/Rock"})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid library ID"))
+ })
+
+ It("returns error for zero library ID", func() {
+ _, err := model.ParseTargets([]string{"0:Music/Rock"})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid library ID"))
+ })
+
+ It("returns error for empty input", func() {
+ _, err := model.ParseTargets([]string{})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("no valid targets found"))
+ })
+
+ It("returns error for all empty strings", func() {
+ _, err := model.ParseTargets([]string{"", " ", ""})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("no valid targets found"))
+ })
+})
diff --git a/model/scrobble.go b/model/scrobble.go
new file mode 100644
index 000000000..e1567abc3
--- /dev/null
+++ b/model/scrobble.go
@@ -0,0 +1,13 @@
+package model
+
+import "time"
+
+type Scrobble struct {
+ MediaFileID string
+ UserID string
+ SubmissionTime time.Time
+}
+
+type ScrobbleRepository interface {
+ RecordScrobble(mediaFileID string, submissionTime time.Time) error
+}
diff --git a/model/searchable.go b/model/searchable.go
index d37299997..a64a0171c 100644
--- a/model/searchable.go
+++ b/model/searchable.go
@@ -1,5 +1,5 @@
package model
type SearchableRepository[T any] interface {
- Search(q string, offset, size int, includeMissing bool) (T, error)
+ Search(q string, options ...QueryOptions) (T, error)
}
diff --git a/model/share.go b/model/share.go
index 0f52f5323..ce0846d60 100644
--- a/model/share.go
+++ b/model/share.go
@@ -2,7 +2,6 @@ package model
import (
"cmp"
- "fmt"
"strings"
"time"
@@ -23,8 +22,8 @@ type Share struct {
Format string `structs:"format" json:"format,omitempty"`
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate,omitempty"`
VisitCount int `structs:"visit_count" json:"visitCount,omitempty"`
- CreatedAt time.Time `structs:"created_at" json:"createdAt,omitempty"`
- UpdatedAt time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
+ CreatedAt time.Time `structs:"created_at" json:"createdAt"`
+ UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
Tracks MediaFiles `structs:"-" json:"tracks,omitempty"`
Albums Albums `structs:"-" json:"albums,omitempty"`
URL string `structs:"-" json:"-"`
@@ -50,17 +49,9 @@ func (s Share) CoverArtID() ArtworkID {
type Shares []Share
-// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
-// https://docs.fileformat.com/audio/m3u/#extended-m3u
+// ToM3U8 exports the share to the Extended M3U8 format.
func (s Share) ToM3U8() string {
- buf := strings.Builder{}
- buf.WriteString("#EXTM3U\n")
- buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", cmp.Or(s.Description, s.ID)))
- for _, t := range s.Tracks {
- buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
- buf.WriteString(t.Path + "\n")
- }
- return buf.String()
+ return s.Tracks.ToM3U8(cmp.Or(s.Description, s.ID), false)
}
type ShareRepository interface {
diff --git a/model/tag.go b/model/tag.go
index a1f4e28da..1f6b24d21 100644
--- a/model/tag.go
+++ b/model/tag.go
@@ -12,11 +12,11 @@ import (
)
type Tag struct {
- ID string `json:"id,omitempty"`
- TagName TagName `json:"tagName,omitempty"`
- TagValue string `json:"tagValue,omitempty"`
- AlbumCount int `json:"albumCount,omitempty"`
- MediaFileCount int `json:"songCount,omitempty"`
+ ID string `json:"id,omitempty"`
+ TagName TagName `json:"tagName,omitempty"`
+ TagValue string `json:"tagValue,omitempty"`
+ AlbumCount int `json:"albumCount,omitempty"`
+ SongCount int `json:"songCount,omitempty"`
}
type TagList []Tag
@@ -144,16 +144,14 @@ func (t Tags) Merge(tags Tags) {
}
func (t Tags) Add(name TagName, v string) {
- for _, existing := range t[name] {
- if existing == v {
- return
- }
+ if slices.Contains(t[name], v) {
+ return
}
t[name] = append(t[name], v)
}
type TagRepository interface {
- Add(...Tag) error
+ Add(libraryID int, tags ...Tag) error
UpdateCounts() error
}
diff --git a/model/tag_mappings.go b/model/tag_mappings.go
index d54f51f43..dd19a157b 100644
--- a/model/tag_mappings.go
+++ b/model/tag_mappings.go
@@ -34,23 +34,25 @@ type TagConf struct {
SplitRx *regexp.Regexp `yaml:"-"`
}
-// SplitTagValue splits a tag value by the split separators, but only if it has a single value.
+// SplitTagValue splits tag values by the configured split separators.
+// Each value in the input slice is individually split and trimmed.
func (c TagConf) SplitTagValue(values []string) []string {
- // If there's not exactly one value or no separators, return early.
- if len(values) != 1 || c.SplitRx == nil {
+ if c.SplitRx == nil || len(values) == 0 {
return values
}
- tag := values[0]
- // Replace all occurrences of any separator with the zero-width space.
- tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp)
+ var result []string
+ for _, tag := range values {
+ // Replace all occurrences of any separator with the zero-width space.
+ tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp)
- // Split by the zero-width space and trim each substring.
- parts := strings.Split(tag, consts.Zwsp)
- for i, part := range parts {
- parts[i] = strings.TrimSpace(part)
+ // Split by the zero-width space and trim each substring.
+ parts := strings.SplitSeq(tag, consts.Zwsp)
+ for part := range parts {
+ result = append(result, strings.TrimSpace(part))
+ }
}
- return parts
+ return result
}
type TagType string
@@ -139,7 +141,9 @@ func compileSplitRegex(tagName TagName, split []string) *regexp.Regexp {
}
// If no valid separators remain, return the original value.
if len(escaped) == 0 {
- log.Warn("No valid separators found in split list", "split", split, "tag", tagName)
+ if len(split) > 0 {
+ log.Warn("No valid separators found in split list", "split", split, "tag", tagName)
+ }
return nil
}
@@ -147,7 +151,7 @@ func compileSplitRegex(tagName TagName, split []string) *regexp.Regexp {
pattern := "(?i)(" + strings.Join(escaped, "|") + ")"
re, err := regexp.Compile(pattern)
if err != nil {
- log.Error("Error compiling regexp", "pattern", pattern, "tag", tagName, "err", err)
+ log.Warn("Error compiling regexp for split list", "pattern", pattern, "tag", tagName, "split", split, err)
return nil
}
return re
diff --git a/model/tag_mappings_test.go b/model/tag_mappings_test.go
new file mode 100644
index 000000000..1665d557b
--- /dev/null
+++ b/model/tag_mappings_test.go
@@ -0,0 +1,64 @@
+package model
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("TagConf", func() {
+ Describe("SplitTagValue", func() {
+ var conf TagConf
+
+ BeforeEach(func() {
+ conf = TagConf{Split: []string{";", "/", ","}}
+ conf.SplitRx = compileSplitRegex("test", conf.Split)
+ })
+
+ It("splits a single value on configured separators", func() {
+ Expect(conf.SplitTagValue([]string{"Rock/Pop;Punk"})).To(Equal([]string{"Rock", "Pop", "Punk"}))
+ })
+
+ It("trims whitespace around split values", func() {
+ Expect(conf.SplitTagValue([]string{"Love, Emotional, Ballad"})).To(Equal([]string{"Love", "Emotional", "Ballad"}))
+ })
+
+ // Regression test for https://github.com/navidrome/navidrome/issues/5065
+ //
+ // When multiple ID3v2 frames map to the same logical tag (e.g. TMOO + TXXX:MOOD),
+ // TagLib's PropertyMap merges them into a slice with several entries. Previously
+ // SplitTagValue had a `len(values) != 1` guard that skipped splitting in this case.
+ It("splits each value individually when given multiple inputs", func() {
+ input := []string{"Love, Emotional, Ballad", "Love; Emotional; Ballad"}
+ Expect(conf.SplitTagValue(input)).To(Equal([]string{
+ "Love", "Emotional", "Ballad",
+ "Love", "Emotional", "Ballad",
+ }))
+ })
+
+ It("matches separators case-insensitively when the split pattern allows", func() {
+ c := TagConf{Split: []string{" AND "}}
+ c.SplitRx = compileSplitRegex("test", c.Split)
+ Expect(c.SplitTagValue([]string{"foo and bar AND baz"})).To(Equal([]string{"foo", "bar", "baz"}))
+ })
+
+ It("returns values unchanged when no separators are configured", func() {
+ c := TagConf{}
+ Expect(c.SplitTagValue([]string{"Foo, Bar"})).To(Equal([]string{"Foo, Bar"}))
+ Expect(c.SplitTagValue([]string{"a", "b"})).To(Equal([]string{"a", "b"}))
+ })
+
+ It("returns an empty slice for empty input", func() {
+ Expect(conf.SplitTagValue([]string{})).To(BeEmpty())
+ })
+
+ It("handles a value with no separator as a single-element result", func() {
+ Expect(conf.SplitTagValue([]string{"JustOneMood"})).To(Equal([]string{"JustOneMood"}))
+ })
+
+ It("produces empty strings when separators are adjacent (dedup happens downstream)", func() {
+ // SplitTagValue itself does not filter empties; that is the job of
+ // filterDuplicatedOrEmptyValues in the metadata pipeline.
+ Expect(conf.SplitTagValue([]string{"Rock//Pop"})).To(Equal([]string{"Rock", "", "Pop"}))
+ })
+ })
+})
diff --git a/model/user.go b/model/user.go
index 7c41ac041..1c8541ccf 100644
--- a/model/user.go
+++ b/model/user.go
@@ -1,6 +1,8 @@
package model
-import "time"
+import (
+ "time"
+)
type User struct {
ID string `structs:"id" json:"id"`
@@ -13,20 +15,38 @@ type User struct {
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
+ // Library associations (many-to-many relationship)
+ Libraries Libraries `structs:"-" json:"libraries,omitempty"`
+
// This is only available on the backend, and it is never sent over the wire
Password string `structs:"-" json:"-"`
// This is used to set or change a password when calling Put. If it is empty, the password is not changed.
// It is received from the UI with the name "password"
- NewPassword string `structs:"password,omitempty" json:"password,omitempty"`
+ NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec
// If changing the password, this is also required
CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"`
}
+func (u User) HasLibraryAccess(libraryID int) bool {
+ if u.IsAdmin {
+ return true // Admin users have access to all libraries
+ }
+ for _, lib := range u.Libraries {
+ if lib.ID == libraryID {
+ return true
+ }
+ }
+ return false
+}
+
type Users []User
type UserRepository interface {
+ ResourceRepository
CountAll(...QueryOptions) (int64, error)
+ Delete(id string) error
Get(id string) (*User, error)
+ GetAll(options ...QueryOptions) (Users, error)
Put(*User) error
UpdateLastLoginAt(id string) error
UpdateLastAccessAt(id string) error
@@ -35,4 +55,8 @@ type UserRepository interface {
FindByUsername(username string) (*User, error)
// FindByUsernameWithPassword is the same as above, but also returns the decrypted password
FindByUsernameWithPassword(username string) (*User, error)
+
+ // Library association methods
+ GetUserLibraries(userID string) (Libraries, error)
+ SetUserLibraries(userID string, libraryIDs []int) error
}
diff --git a/model/user_test.go b/model/user_test.go
new file mode 100644
index 000000000..ab66a29a9
--- /dev/null
+++ b/model/user_test.go
@@ -0,0 +1,83 @@
+package model_test
+
+import (
+ "github.com/navidrome/navidrome/model"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("User", func() {
+ var user model.User
+ var libraries model.Libraries
+
+ BeforeEach(func() {
+ libraries = model.Libraries{
+ {ID: 1, Name: "Rock Library", Path: "/music/rock"},
+ {ID: 2, Name: "Jazz Library", Path: "/music/jazz"},
+ {ID: 3, Name: "Classical Library", Path: "/music/classical"},
+ }
+
+ user = model.User{
+ ID: "user1",
+ UserName: "testuser",
+ Name: "Test User",
+ Email: "test@example.com",
+ IsAdmin: false,
+ Libraries: libraries,
+ }
+ })
+
+ Describe("HasLibraryAccess", func() {
+ Context("when user is admin", func() {
+ BeforeEach(func() {
+ user.IsAdmin = true
+ })
+
+ It("returns true for any library ID", func() {
+ Expect(user.HasLibraryAccess(1)).To(BeTrue())
+ Expect(user.HasLibraryAccess(99)).To(BeTrue())
+ Expect(user.HasLibraryAccess(-1)).To(BeTrue())
+ })
+
+ It("returns true even when user has no libraries assigned", func() {
+ user.Libraries = nil
+ Expect(user.HasLibraryAccess(1)).To(BeTrue())
+ })
+ })
+
+ Context("when user is not admin", func() {
+ BeforeEach(func() {
+ user.IsAdmin = false
+ })
+
+ It("returns true for libraries the user has access to", func() {
+ Expect(user.HasLibraryAccess(1)).To(BeTrue())
+ Expect(user.HasLibraryAccess(2)).To(BeTrue())
+ Expect(user.HasLibraryAccess(3)).To(BeTrue())
+ })
+
+ It("returns false for libraries the user does not have access to", func() {
+ Expect(user.HasLibraryAccess(4)).To(BeFalse())
+ Expect(user.HasLibraryAccess(99)).To(BeFalse())
+ Expect(user.HasLibraryAccess(-1)).To(BeFalse())
+ Expect(user.HasLibraryAccess(0)).To(BeFalse())
+ })
+
+ It("returns false when user has no libraries assigned", func() {
+ user.Libraries = nil
+ Expect(user.HasLibraryAccess(1)).To(BeFalse())
+ })
+
+ It("handles duplicate library IDs correctly", func() {
+ user.Libraries = model.Libraries{
+ {ID: 1, Name: "Library 1", Path: "/music1"},
+ {ID: 1, Name: "Library 1 Duplicate", Path: "/music1-dup"},
+ {ID: 2, Name: "Library 2", Path: "/music2"},
+ }
+ Expect(user.HasLibraryAccess(1)).To(BeTrue())
+ Expect(user.HasLibraryAccess(2)).To(BeTrue())
+ Expect(user.HasLibraryAccess(3)).To(BeFalse())
+ })
+ })
+ })
+})
diff --git a/persistence/album_repository.go b/persistence/album_repository.go
index 3f238ee23..99ed10877 100644
--- a/persistence/album_repository.go
+++ b/persistence/album_repository.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "iter"
"maps"
"slices"
"strings"
@@ -61,11 +62,14 @@ func (a *dbAlbum) PostScan() error {
func (a *dbAlbum) PostMapArgs(args map[string]any) error {
fullText := []string{a.Name, a.SortAlbumName, a.AlbumArtist}
- fullText = append(fullText, a.Album.Participants.AllNames()...)
+ participantNames := a.Album.Participants.AllNames()
+ fullText = append(fullText, participantNames...)
fullText = append(fullText, slices.Collect(maps.Values(a.Album.Discs))...)
fullText = append(fullText, a.Album.Tags[model.TagAlbumVersion]...)
fullText = append(fullText, a.Album.Tags[model.TagCatalogNumber]...)
args["full_text"] = formatFullText(fullText...)
+ args["search_participants"] = strings.Join(participantNames, " ")
+ args["search_normalized"] = normalizeForFTS(a.Name, a.AlbumArtist)
args["tags"] = marshalTags(a.Album.Tags)
args["participants"] = marshalParticipants(a.Album.Participants)
@@ -105,6 +109,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
"random": "random",
"recently_added": recentlyAddedSort(),
"starred_at": "starred, starred_at",
+ "rated_at": "rating, rated_at",
})
return r
}
@@ -112,16 +117,17 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
var albumFilters = sync.OnceValue(func() map[string]filterFunc {
filters := map[string]filterFunc{
"id": idFilter("album"),
- "name": fullTextFilter("album"),
+ "name": fullTextFilter("album", "mbz_album_id", "mbz_release_group_id"),
"compilation": booleanFilter,
"artist_id": artistFilter,
"year": yearFilter,
"recently_played": recentlyPlayedFilter,
- "starred": booleanFilter,
- "has_rating": hasRatingFilter,
+ "starred": annotationBoolFilter("starred"),
+ "has_rating": annotationBoolFilter("rating"),
"missing": booleanFilter,
"genre_id": tagIDFilter,
"role_total_id": allRolesFilter,
+ "library_id": libraryIdFilter,
}
// Add all album tags as filters
for tag := range model.AlbumLevelTags() {
@@ -137,20 +143,16 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
func recentlyAddedSort() string {
if conf.Server.RecentlyAddedByModTime {
- return "updated_at"
+ return "datetime(album.updated_at)"
}
- return "created_at"
+ return "datetime(album.created_at)"
}
-func recentlyPlayedFilter(string, interface{}) Sqlizer {
+func recentlyPlayedFilter(string, any) Sqlizer {
return Gt{"play_count": 0}
}
-func hasRatingFilter(string, interface{}) Sqlizer {
- return Gt{"rating": 0}
-}
-
-func yearFilter(_ string, value interface{}) Sqlizer {
+func yearFilter(_ string, value any) Sqlizer {
return Or{
And{
Gt{"min_year": 0},
@@ -161,14 +163,14 @@ func yearFilter(_ string, value interface{}) Sqlizer {
}
}
-func artistFilter(_ string, value interface{}) Sqlizer {
+func artistFilter(_ string, value any) Sqlizer {
return Or{
Exists("json_tree(participants, '$.albumartist')", Eq{"value": value}),
Exists("json_tree(participants, '$.artist')", Eq{"value": value}),
}
}
-func artistRoleFilter(name string, value interface{}) Sqlizer {
+func artistRoleFilter(name string, value any) Sqlizer {
roleName := strings.TrimSuffix(strings.TrimPrefix(name, "role_"), "_id")
// Check if the role name is valid. If not, return an invalid filter
@@ -178,14 +180,15 @@ func artistRoleFilter(name string, value interface{}) Sqlizer {
return Exists(fmt.Sprintf("json_tree(participants, '$.%s')", roleName), Eq{"value": value})
}
-func allRolesFilter(_ string, value interface{}) Sqlizer {
+func allRolesFilter(_ string, value any) Sqlizer {
return Like{"participants": fmt.Sprintf(`%%"%s"%%`, value)}
}
func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {
- sql := r.newSelect()
- sql = r.withAnnotation(sql, "album.id")
- return r.count(sql, options...)
+ query := r.newSelect()
+ query = r.withAnnotation(query, "album.id")
+ query = r.applyLibraryFilter(query)
+ return r.count(query, options...)
}
func (r *albumRepository) Exists(id string) (bool, error) {
@@ -200,12 +203,11 @@ func (r *albumRepository) Put(al *model.Album) error {
}
al.ID = id
if len(al.Participants) > 0 {
- err = r.updateParticipants(al.ID, al.Participants)
- if err != nil {
+ if err = r.updateParticipants(al.ID, al.Participants); err != nil {
return err
}
}
- return err
+ return nil
}
// TODO Move external metadata to a separated table
@@ -215,8 +217,10 @@ func (r *albumRepository) UpdateExternalInfo(al *model.Album) error {
}
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
- sql := r.newSelect(options...).Columns("album.*")
- return r.withAnnotation(sql, "album.id")
+ sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name").
+ LeftJoin("library on album.library_id = library.id")
+ sql = r.withAnnotation(sql, "album.id")
+ return r.applyLibraryFilter(sql)
}
func (r *albumRepository) Get(id string) (*model.Album, error) {
@@ -237,7 +241,7 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e
if err != nil {
return nil, err
}
- return res.toModels(), err
+ return res.toModels(), nil
}
func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error {
@@ -246,9 +250,19 @@ func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string)
if err != nil {
return fmt.Errorf("getting album to copy fields from: %w", err)
}
- to := make(map[string]interface{})
+ to := make(map[string]any)
for _, col := range columns {
- to[col] = from[col]
+ v := from[col]
+ // created_at is aggregated from song birth_times and must never be
+ // overwritten with a zero/poisoned value, or it propagates forward on
+ // every metadata-driven album ID change.
+ if col == "created_at" && (!v.Valid || v.String == "" || strings.HasPrefix(v.String, "0001-")) {
+ continue
+ }
+ to[col] = v
+ }
+ if len(to) == 0 {
+ return nil
}
_, err = r.executeSQL(Update(r.tableName).SetMap(to).Where(Eq{"id": toID}))
return err
@@ -290,7 +304,6 @@ func (r *albumRepository) TouchByMissingFolder() (int64, error) {
// It does not need to load participants, as they are not used by the scanner.
func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error) {
query := r.selectAlbum().
- Join("library on library.id = album.library_id").
Where(And{
Eq{"library.id": libID},
ConcatExpr("album.imported_at > library.last_scan_at"),
@@ -299,17 +312,21 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error)
if err != nil {
return nil, err
}
+ return wrapAlbumCursor(cursor), nil
+}
+
+func wrapAlbumCursor(cursor iter.Seq2[dbAlbum, error]) model.AlbumCursor {
return func(yield func(model.Album, error) bool) {
for a, err := range cursor {
if a.Album == nil {
- yield(model.Album{}, fmt.Errorf("unexpected nil album: %v", a))
+ yield(model.Album{}, fmt.Errorf("unexpected nil album (%v): %w", a, err))
return
}
if !yield(*a.Album, err) || err != nil {
return
}
}
- }, nil
+ }
}
// RefreshPlayCounts updates the play count and last play date annotations for all albums, based
@@ -333,8 +350,12 @@ on conflict (user_id, item_id, item_type) do update
return r.executeSQL(query)
}
-func (r *albumRepository) purgeEmpty() error {
+func (r *albumRepository) purgeEmpty(libraryIDs ...int) error {
del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)")
+ // If libraryIDs are specified, only purge albums from those libraries
+ if len(libraryIDs) > 0 {
+ del = del.Where(Eq{"library_id": libraryIDs})
+ }
c, err := r.executeSQL(del)
if err != nil {
return fmt.Errorf("purging empty albums: %w", err)
@@ -345,24 +366,34 @@ func (r *albumRepository) purgeEmpty() error {
return nil
}
-func (r *albumRepository) Search(q string, offset int, size int, includeMissing bool) (model.Albums, error) {
- var res dbAlbums
- err := r.doSearch(r.selectAlbum(), q, offset, size, includeMissing, &res, "name")
- if err != nil {
- return nil, err
+var albumSearchConfig = searchConfig{
+ NaturalOrder: "album.rowid",
+ OrderBy: []string{"name"},
+ MBIDFields: []string{"mbz_album_id", "mbz_release_group_id"},
+}
+
+func (r *albumRepository) Search(q string, options ...model.QueryOptions) (model.Albums, error) {
+ var opts model.QueryOptions
+ if len(options) > 0 {
+ opts = options[0]
}
- return res.toModels(), err
+ var res dbAlbums
+ err := r.doSearch(r.selectAlbum(options...), q, &res, albumSearchConfig, opts)
+ if err != nil {
+ return nil, fmt.Errorf("searching album %q: %w", q, err)
+ }
+ return res.toModels(), nil
}
func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
-func (r *albumRepository) Read(id string) (interface{}, error) {
+func (r *albumRepository) Read(id string) (any, error) {
return r.Get(id)
}
-func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
+func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
@@ -370,7 +401,7 @@ func (r *albumRepository) EntityName() string {
return "album"
}
-func (r *albumRepository) NewInstance() interface{} {
+func (r *albumRepository) NewInstance() any {
return &model.Album{}
}
diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go
index 529458c26..a6270933f 100644
--- a/persistence/album_repository_test.go
+++ b/persistence/album_repository_test.go
@@ -1,13 +1,14 @@
package persistence
import (
- "context"
+ "errors"
"fmt"
"time"
+ "github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
"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/id"
"github.com/navidrome/navidrome/model/request"
@@ -16,16 +17,16 @@ import (
)
var _ = Describe("AlbumRepository", func() {
- var repo model.AlbumRepository
+ var albumRepo *albumRepository
BeforeEach(func() {
- ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe"})
- repo = NewAlbumRepository(ctx, GetDBXBuilder())
+ ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"})
+ albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
})
Describe("Get", func() {
var Get = func(id string) (*model.Album, error) {
- album, err := repo.Get(id)
+ album, err := albumRepo.Get(id)
if album != nil {
album.ImportedAt = time.Time{}
}
@@ -40,9 +41,35 @@ var _ = Describe("AlbumRepository", func() {
})
})
+ Describe("CopyAttributes", func() {
+ var srcTime, dstTime time.Time
+ BeforeEach(func() {
+ srcTime = time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
+ dstTime = time.Date(2024, 6, 7, 8, 9, 10, 0, time.UTC)
+ Expect(albumRepo.Put(&model.Album{ID: "copy-src", Name: "src", LibraryID: 1, CreatedAt: srcTime})).To(Succeed())
+ Expect(albumRepo.Put(&model.Album{ID: "copy-dst", Name: "dst", LibraryID: 1, CreatedAt: dstTime})).To(Succeed())
+ Expect(albumRepo.Put(&model.Album{ID: "copy-zero", Name: "zero", LibraryID: 1})).To(Succeed())
+ DeferCleanup(func() {
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"copy-src", "copy-dst", "copy-zero"}}))
+ })
+ })
+ It("copies a valid created_at from source to destination", func() {
+ Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "created_at")).To(Succeed())
+ got, err := albumRepo.Get("copy-dst")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.CreatedAt).To(BeTemporally("~", srcTime, time.Second))
+ })
+ It("leaves destination untouched when source created_at is zero", func() {
+ Expect(albumRepo.CopyAttributes("copy-zero", "copy-dst", "created_at")).To(Succeed())
+ got, err := albumRepo.Get("copy-dst")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second))
+ })
+ })
+
Describe("GetAll", func() {
var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) {
- albums, err := repo.GetAll(opts...)
+ albums, err := albumRepo.GetAll(opts...)
for i := range albums {
albums[i].ImportedAt = time.Time{}
}
@@ -56,15 +83,23 @@ var _ = Describe("AlbumRepository", func() {
It("returns all records sorted", func() {
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
albumAbbeyRoad,
+ albumWithVersion,
+ albumCJK,
+ albumMultiDisc,
albumRadioactivity,
albumSgtPeppers,
+ albumPunctuation,
}))
})
It("returns all records sorted desc", func() {
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
+ albumPunctuation,
albumSgtPeppers,
albumRadioactivity,
+ albumMultiDisc,
+ albumCJK,
+ albumWithVersion,
albumAbbeyRoad,
}))
})
@@ -76,6 +111,129 @@ var _ = Describe("AlbumRepository", func() {
})
})
+ Describe("recently_added sort", func() {
+ It("sorts correctly regardless of timestamp format (T-format vs space-format)", func() {
+ // Both timestamps share the same date prefix "2024-01-15" so the T vs space
+ // character at position 10 determines sort order in raw string comparison.
+ // Without normalization, 'T' (ASCII 84) > ' ' (ASCII 32) makes the older
+ // T-format timestamp sort AFTER the newer space-format one.
+
+ // Older album: morning of Jan 15, stored in T-format
+ olderAlbum := &model.Album{LibraryID: 1, ID: "ts-older", Name: "Older Album"}
+ Expect(albumRepo.Put(olderAlbum)).To(Succeed())
+ _, err := albumRepo.executeSQL(squirrel.Update("album").
+ Set("created_at", "2024-01-15T08:00:00Z").
+ Where(squirrel.Eq{"id": "ts-older"}))
+ Expect(err).ToNot(HaveOccurred())
+
+ // Newer album: evening of Jan 15, stored in space-format
+ newerAlbum := &model.Album{LibraryID: 1, ID: "ts-newer", Name: "Newer Album"}
+ Expect(albumRepo.Put(newerAlbum)).To(Succeed())
+ _, err = albumRepo.executeSQL(squirrel.Update("album").
+ Set("created_at", "2024-01-15 20:00:00+00:00").
+ Where(squirrel.Eq{"id": "ts-newer"}))
+ Expect(err).ToNot(HaveOccurred())
+
+ albums, err := albumRepo.GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc"})
+ Expect(err).ToNot(HaveOccurred())
+
+ // Find positions of our test albums
+ olderIdx, newerIdx := -1, -1
+ for i, a := range albums {
+ switch a.ID {
+ case "ts-older":
+ olderIdx = i
+ case "ts-newer":
+ newerIdx = i
+ }
+ }
+ Expect(olderIdx).To(BeNumerically(">=", 0), "older album not found in results")
+ Expect(newerIdx).To(BeNumerically(">=", 0), "newer album not found in results")
+ // Newer album (evening, space-format) should come before older album (morning, T-format) in desc order
+ Expect(newerIdx).To(BeNumerically("<", olderIdx),
+ "Newer album (20:00 space-format) should sort before older album (08:00 T-format) in desc order")
+
+ // Clean up
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"ts-older", "ts-newer"}}))
+ })
+ })
+
+ Context("Filters", func() {
+ var albumWithoutAnnotation model.Album
+
+ BeforeEach(func() {
+ // Create album without any annotation (no star, no rating)
+ albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1}
+ Expect(albumRepo.Put(&albumWithoutAnnotation)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID}))
+ })
+
+ Describe("starred", func() {
+ It("false includes items without annotations", func() {
+ res, err := albumRepo.ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": "false"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ albums := res.(model.Albums)
+
+ var found bool
+ for _, a := range albums {
+ if a.ID == albumWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Album without annotation should be included in starred=false filter")
+ })
+
+ It("true excludes items without annotations", func() {
+ res, err := albumRepo.ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": "true"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ albums := res.(model.Albums)
+
+ for _, a := range albums {
+ Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
+ }
+ })
+ })
+
+ Describe("has_rating", func() {
+ It("false includes items without annotations", func() {
+ res, err := albumRepo.ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"has_rating": "false"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ albums := res.(model.Albums)
+
+ var found bool
+ for _, a := range albums {
+ if a.ID == albumWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Album without annotation should be included in has_rating=false filter")
+ })
+
+ It("true excludes items without annotations", func() {
+ res, err := albumRepo.ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"has_rating": "true"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ albums := res.(model.Albums)
+
+ for _, a := range albums {
+ Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
+ }
+ })
+ })
+ })
+
Describe("Album.PlayCount", func() {
// Implementation is in withAnnotation() method
DescribeTable("normalizes play count when AlbumPlayCountMode is absolute",
@@ -83,12 +241,12 @@ var _ = Describe("AlbumRepository", func() {
conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeAbsolute
newID := id.NewRandom()
- Expect(repo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed())
- for i := 0; i < playCount; i++ {
- Expect(repo.IncPlayCount(newID, time.Now())).To(Succeed())
+ Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed())
+ for range playCount {
+ Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed())
}
- album, err := repo.Get(newID)
+ album, err := albumRepo.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(album.PlayCount).To(Equal(int64(expected)))
},
@@ -106,12 +264,12 @@ var _ = Describe("AlbumRepository", func() {
conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeNormalized
newID := id.NewRandom()
- Expect(repo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed())
- for i := 0; i < playCount; i++ {
- Expect(repo.IncPlayCount(newID, time.Now())).To(Succeed())
+ Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed())
+ for range playCount {
+ Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed())
}
- album, err := repo.Get(newID)
+ album, err := albumRepo.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(album.PlayCount).To(Equal(int64(expected)))
},
@@ -125,6 +283,89 @@ var _ = Describe("AlbumRepository", func() {
)
})
+ Describe("Album.AverageRating", func() {
+ It("returns 0 when no ratings exist", func() {
+ newID := id.NewRandom()
+ Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "no ratings album"})).To(Succeed())
+
+ album, err := albumRepo.Get(newID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(album.AverageRating).To(Equal(0.0))
+
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
+ })
+
+ It("returns the user's rating as average when only one user rated", func() {
+ newID := id.NewRandom()
+ Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "single rating album"})).To(Succeed())
+ Expect(albumRepo.SetRating(4, newID)).To(Succeed())
+
+ album, err := albumRepo.Get(newID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(album.AverageRating).To(Equal(4.0))
+
+ _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
+ })
+
+ It("calculates average across multiple users", func() {
+ newID := id.NewRandom()
+ Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "multi rating album"})).To(Succeed())
+
+ Expect(albumRepo.SetRating(4, newID)).To(Succeed())
+
+ user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
+ user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
+ Expect(user2Repo.SetRating(5, newID)).To(Succeed())
+
+ album, err := albumRepo.Get(newID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(album.AverageRating).To(Equal(4.5))
+
+ _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
+ })
+
+ It("excludes zero ratings from average calculation", func() {
+ newID := id.NewRandom()
+ Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "zero rating excluded album"})).To(Succeed())
+ Expect(albumRepo.SetRating(3, newID)).To(Succeed())
+
+ user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
+ user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
+ Expect(user2Repo.SetRating(0, newID)).To(Succeed())
+
+ album, err := albumRepo.Get(newID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(album.AverageRating).To(Equal(3.0))
+
+ _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
+ })
+
+ It("rounds to 2 decimal places", func() {
+ newID := id.NewRandom()
+ Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "rounding test album"})).To(Succeed())
+
+ Expect(albumRepo.SetRating(5, newID)).To(Succeed())
+
+ user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
+ user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
+ Expect(user2Repo.SetRating(4, newID)).To(Succeed())
+
+ user3Ctx := request.WithUser(GinkgoT().Context(), thirdUser)
+ user3Repo := NewAlbumRepository(user3Ctx, GetDBXBuilder()).(*albumRepository)
+ Expect(user3Repo.SetRating(4, newID)).To(Succeed())
+
+ album, err := albumRepo.Get(newID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(album.AverageRating).To(Equal(4.33)) // (5 + 4 + 4) / 3 = 4.333...
+
+ _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
+ })
+ })
+
Describe("dbAlbum mapping", func() {
var (
a model.Album
@@ -245,7 +486,7 @@ var _ = Describe("AlbumRepository", func() {
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(expectedSQL))
- Expect(args).To(Equal([]interface{}{artistID}))
+ Expect(args).To(Equal([]any{artistID}))
},
Entry("artist role", "role_artist_id", "123",
"exists (select 1 from json_tree(participants, '$.artist') where value = ?)"),
@@ -267,7 +508,7 @@ var _ = Describe("AlbumRepository", func() {
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(fmt.Sprintf("exists (select 1 from json_tree(participants, '$.%s') where value = ?)", roleName)))
- Expect(args).To(Equal([]interface{}{"test-id"}))
+ Expect(args).To(Equal([]any{"test-id"}))
}
})
@@ -283,6 +524,339 @@ var _ = Describe("AlbumRepository", func() {
Expect(err).To(HaveOccurred())
})
})
+
+ Describe("Participant Foreign Key Handling", func() {
+ // albumArtistRecord represents a record in the album_artists table
+ type albumArtistRecord struct {
+ ArtistID string `db:"artist_id"`
+ Role string `db:"role"`
+ SubRole string `db:"sub_role"`
+ }
+
+ var artistRepo *artistRepository
+
+ BeforeEach(func() {
+ ctx := request.WithUser(GinkgoT().Context(), adminUser)
+ artistRepo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository)
+ })
+
+ // Helper to verify album_artists records
+ verifyAlbumArtists := func(albumID string, expected []albumArtistRecord) {
+ GinkgoHelper()
+ var actual []albumArtistRecord
+ sq := squirrel.Select("artist_id", "role", "sub_role").
+ From("album_artists").
+ Where(squirrel.Eq{"album_id": albumID}).
+ OrderBy("role", "artist_id", "sub_role")
+
+ err := albumRepo.queryAll(sq, &actual)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(actual).To(Equal(expected))
+ }
+
+ It("verifies that participant records are actually inserted into database", func() {
+ // Create a real artist in the database first
+ artist := &model.Artist{
+ ID: "real-artist-1",
+ Name: "Real Artist",
+ OrderArtistName: "real artist",
+ SortArtistName: "Artist, Real",
+ }
+ err := createArtistWithLibrary(artistRepo, artist, 1)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Create an album with participants that reference the real artist
+ album := &model.Album{
+ LibraryID: 1,
+ ID: "test-album-db-insert",
+ Name: "Test Album DB Insert",
+ AlbumArtistID: "real-artist-1",
+ AlbumArtist: "Real Artist",
+ Participants: model.Participants{
+ model.RoleArtist: {
+ {Artist: model.Artist{ID: "real-artist-1", Name: "Real Artist"}},
+ },
+ model.RoleComposer: {
+ {Artist: model.Artist{ID: "real-artist-1", Name: "Real Artist"}, SubRole: "primary"},
+ },
+ },
+ }
+
+ // Insert the album
+ err = albumRepo.Put(album)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify that participant records were actually inserted into album_artists table
+ expected := []albumArtistRecord{
+ {ArtistID: "real-artist-1", Role: "artist", SubRole: ""},
+ {ArtistID: "real-artist-1", Role: "composer", SubRole: "primary"},
+ }
+ verifyAlbumArtists(album.ID, expected)
+
+ // Clean up the test artist and album created for this test
+ _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID}))
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
+ })
+
+ It("filters out invalid artist IDs leaving only valid participants in database", func() {
+ // Create two real artists in the database
+ artist1 := &model.Artist{
+ ID: "real-artist-mix-1",
+ Name: "Real Artist 1",
+ OrderArtistName: "real artist 1",
+ }
+ artist2 := &model.Artist{
+ ID: "real-artist-mix-2",
+ Name: "Real Artist 2",
+ OrderArtistName: "real artist 2",
+ }
+ err := createArtistWithLibrary(artistRepo, artist1, 1)
+ Expect(err).ToNot(HaveOccurred())
+ err = createArtistWithLibrary(artistRepo, artist2, 1)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Create an album with mix of valid and invalid artist IDs
+ album := &model.Album{
+ LibraryID: 1,
+ ID: "test-album-mixed-validity",
+ Name: "Test Album Mixed Validity",
+ AlbumArtistID: "real-artist-mix-1",
+ AlbumArtist: "Real Artist 1",
+ Participants: model.Participants{
+ model.RoleArtist: {
+ {Artist: model.Artist{ID: "real-artist-mix-1", Name: "Real Artist 1"}},
+ {Artist: model.Artist{ID: "non-existent-mix-1", Name: "Non Existent 1"}},
+ {Artist: model.Artist{ID: "real-artist-mix-2", Name: "Real Artist 2"}},
+ },
+ model.RoleComposer: {
+ {Artist: model.Artist{ID: "non-existent-mix-2", Name: "Non Existent 2"}},
+ {Artist: model.Artist{ID: "real-artist-mix-1", Name: "Real Artist 1"}},
+ },
+ },
+ }
+
+ // This should not fail - only valid artists should be inserted
+ err = albumRepo.Put(album)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify that only valid artist IDs were inserted into album_artists table
+ // Non-existent artists should be filtered out by the INNER JOIN
+ expected := []albumArtistRecord{
+ {ArtistID: "real-artist-mix-1", Role: "artist", SubRole: ""},
+ {ArtistID: "real-artist-mix-2", Role: "artist", SubRole: ""},
+ {ArtistID: "real-artist-mix-1", Role: "composer", SubRole: ""},
+ }
+ verifyAlbumArtists(album.ID, expected)
+
+ // Clean up the test artists and album created for this test
+ artistIDs := []string{artist1.ID, artist2.ID}
+ _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs}))
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
+ })
+
+ It("handles complex nested JSON with multiple roles and sub-roles", func() {
+ // Create 4 artists for this test
+ artists := []*model.Artist{
+ {ID: "complex-artist-1", Name: "Lead Vocalist", OrderArtistName: "lead vocalist"},
+ {ID: "complex-artist-2", Name: "Guitarist", OrderArtistName: "guitarist"},
+ {ID: "complex-artist-3", Name: "Producer", OrderArtistName: "producer"},
+ {ID: "complex-artist-4", Name: "Engineer", OrderArtistName: "engineer"},
+ }
+
+ for _, artist := range artists {
+ err := createArtistWithLibrary(artistRepo, artist, 1)
+ Expect(err).ToNot(HaveOccurred())
+ }
+
+ // Create album with complex participant structure
+ album := &model.Album{
+ LibraryID: 1,
+ ID: "test-album-complex-json",
+ Name: "Test Album Complex JSON",
+ AlbumArtistID: "complex-artist-1",
+ AlbumArtist: "Lead Vocalist",
+ Participants: model.Participants{
+ model.RoleArtist: {
+ {Artist: model.Artist{ID: "complex-artist-1", Name: "Lead Vocalist"}},
+ {Artist: model.Artist{ID: "complex-artist-2", Name: "Guitarist"}, SubRole: "lead guitar"},
+ {Artist: model.Artist{ID: "complex-artist-2", Name: "Guitarist"}, SubRole: "rhythm guitar"},
+ },
+ model.RoleProducer: {
+ {Artist: model.Artist{ID: "complex-artist-3", Name: "Producer"}, SubRole: "executive"},
+ },
+ model.RoleEngineer: {
+ {Artist: model.Artist{ID: "complex-artist-4", Name: "Engineer"}, SubRole: "mixing"},
+ {Artist: model.Artist{ID: "complex-artist-4", Name: "Engineer"}, SubRole: "mastering"},
+ },
+ },
+ }
+
+ err := albumRepo.Put(album)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify complex JSON structure was correctly parsed and inserted
+ expected := []albumArtistRecord{
+ {ArtistID: "complex-artist-1", Role: "artist", SubRole: ""},
+ {ArtistID: "complex-artist-2", Role: "artist", SubRole: "lead guitar"},
+ {ArtistID: "complex-artist-2", Role: "artist", SubRole: "rhythm guitar"},
+ {ArtistID: "complex-artist-4", Role: "engineer", SubRole: "mastering"},
+ {ArtistID: "complex-artist-4", Role: "engineer", SubRole: "mixing"},
+ {ArtistID: "complex-artist-3", Role: "producer", SubRole: "executive"},
+ }
+ verifyAlbumArtists(album.ID, expected)
+
+ // Clean up the test artists and album created for this test
+ artistIDs := make([]string, len(artists))
+ for i, artist := range artists {
+ artistIDs[i] = artist.ID
+ }
+ _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs}))
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
+ })
+
+ It("handles albums with non-existent artist IDs without constraint errors", func() {
+ // Regression test for foreign key constraint error when album participants
+ // contain artist IDs that don't exist in the artist table
+
+ // Create an album with participants that reference non-existent artist IDs
+ album := &model.Album{
+ LibraryID: 1,
+ ID: "test-album-fk-constraints",
+ Name: "Test Album with Invalid Artist References",
+ AlbumArtistID: "non-existent-artist-1",
+ AlbumArtist: "Non Existent Album Artist",
+ Participants: model.Participants{
+ model.RoleArtist: {
+ {Artist: model.Artist{ID: "non-existent-artist-1", Name: "Non Existent Artist 1"}},
+ {Artist: model.Artist{ID: "non-existent-artist-2", Name: "Non Existent Artist 2"}},
+ },
+ model.RoleComposer: {
+ {Artist: model.Artist{ID: "non-existent-composer-1", Name: "Non Existent Composer 1"}},
+ {Artist: model.Artist{ID: "non-existent-composer-2", Name: "Non Existent Composer 2"}},
+ },
+ model.RoleAlbumArtist: {
+ {Artist: model.Artist{ID: "non-existent-album-artist-1", Name: "Non Existent Album Artist 1"}},
+ },
+ },
+ }
+
+ // This should not fail with foreign key constraint error
+ // The updateParticipants method should handle non-existent artist IDs gracefully
+ err := albumRepo.Put(album)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify that no participant records were inserted since all artist IDs were invalid
+ // The INNER JOIN with the artist table should filter out all non-existent artists
+ verifyAlbumArtists(album.ID, []albumArtistRecord{})
+
+ // Clean up the test album created for this test
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
+ })
+
+ It("removes stale role associations when artist role changes", func() {
+ // Regression test for issue #4242: Composers displayed in albumartist list
+ // This happens when an artist's role changes (e.g., was both albumartist and composer,
+ // now only composer) and the old role association isn't properly removed.
+
+ // Create an artist that will have changing roles
+ artist := &model.Artist{
+ ID: "role-change-artist-1",
+ Name: "Role Change Artist",
+ OrderArtistName: "role change artist",
+ }
+ err := createArtistWithLibrary(artistRepo, artist, 1)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Create album with artist as both albumartist and composer
+ album := &model.Album{
+ LibraryID: 1,
+ ID: "test-album-role-change",
+ Name: "Test Album Role Change",
+ AlbumArtistID: "role-change-artist-1",
+ AlbumArtist: "Role Change Artist",
+ Participants: model.Participants{
+ model.RoleAlbumArtist: {
+ {Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
+ },
+ model.RoleComposer: {
+ {Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
+ },
+ },
+ }
+
+ err = albumRepo.Put(album)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify initial state: artist has both albumartist and composer roles
+ expected := []albumArtistRecord{
+ {ArtistID: "role-change-artist-1", Role: "albumartist", SubRole: ""},
+ {ArtistID: "role-change-artist-1", Role: "composer", SubRole: ""},
+ }
+ verifyAlbumArtists(album.ID, expected)
+
+ // Now update album so artist is ONLY a composer (remove albumartist role)
+ album.Participants = model.Participants{
+ model.RoleComposer: {
+ {Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
+ },
+ }
+
+ err = albumRepo.Put(album)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify that the albumartist role was removed - only composer should remain
+ // This is the key test: before the fix, the albumartist role would remain
+ // causing composers to appear in the albumartist filter
+ expectedAfter := []albumArtistRecord{
+ {ArtistID: "role-change-artist-1", Role: "composer", SubRole: ""},
+ }
+ verifyAlbumArtists(album.ID, expectedAfter)
+
+ // Clean up
+ _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID}))
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
+ })
+ })
+
+ Describe("wrapAlbumCursor", func() {
+ It("does not panic when the cursor yields a dbAlbum with nil Album", func() {
+ // Simulate what queryWithStableResults does on the rows.Err() path:
+ // it yields a zero-value dbAlbum (where Album is nil) with an error.
+ dbErr := fmt.Errorf("database is locked")
+ cursor := func(yield func(dbAlbum, error) bool) {
+ var empty dbAlbum // Album pointer is nil
+ yield(empty, dbErr)
+ }
+
+ // wrapAlbumCursor should handle the nil Album without panicking
+ wrappedCursor := wrapAlbumCursor(cursor)
+ var gotErr error
+ Expect(func() {
+ for _, err := range wrappedCursor {
+ gotErr = err
+ }
+ }).ToNot(Panic())
+ Expect(gotErr).To(HaveOccurred())
+ Expect(gotErr.Error()).To(ContainSubstring("unexpected nil album"))
+ Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
+ })
+
+ It("yields albums from a valid cursor", func() {
+ album := &model.Album{ID: "a1", Name: "Test"}
+ cursor := func(yield func(dbAlbum, error) bool) {
+ yield(dbAlbum{Album: album}, nil)
+ }
+
+ wrappedCursor := wrapAlbumCursor(cursor)
+ var albums []model.Album
+ for a, err := range wrappedCursor {
+ Expect(err).ToNot(HaveOccurred())
+ albums = append(albums, a)
+ }
+ Expect(albums).To(HaveLen(1))
+ Expect(albums[0].ID).To(Equal("a1"))
+ })
+ })
})
func _p(id, name string, sortName ...string) model.Participant {
diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go
index eb87ed006..cfdc499e0 100644
--- a/persistence/artist_repository.go
+++ b/persistence/artist_repository.go
@@ -4,7 +4,9 @@ import (
"cmp"
"context"
"encoding/json"
+ "errors"
"fmt"
+ "os"
"slices"
"strings"
"time"
@@ -12,10 +14,10 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
- . "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
)
@@ -26,9 +28,9 @@ type artistRepository struct {
}
type dbArtist struct {
- *model.Artist `structs:",flatten"`
- SimilarArtists string `structs:"-" json:"-"`
- Stats string `structs:"-" json:"-"`
+ *model.Artist `structs:",flatten"`
+ SimilarArtists string `structs:"-" json:"-"`
+ LibraryStatsJSON string `structs:"-" json:"-"`
}
type dbSimilarArtist struct {
@@ -37,27 +39,45 @@ type dbSimilarArtist struct {
}
func (a *dbArtist) PostScan() error {
- var stats map[string]map[string]int64
- if err := json.Unmarshal([]byte(a.Stats), &stats); err != nil {
- return fmt.Errorf("parsing artist stats from db: %w", err)
- }
a.Artist.Stats = make(map[model.Role]model.ArtistStats)
- for key, c := range stats {
- if key == "total" {
- a.Artist.Size = c["s"]
- a.Artist.SongCount = int(c["m"])
- a.Artist.AlbumCount = int(c["a"])
+
+ if a.LibraryStatsJSON != "" {
+ var rawLibStats map[string]map[string]map[string]int64
+ if err := json.Unmarshal([]byte(a.LibraryStatsJSON), &rawLibStats); err != nil {
+ return fmt.Errorf("parsing artist stats from db: %w", err)
}
- role := model.RoleFromString(key)
- if role == model.RoleInvalid {
- continue
- }
- a.Artist.Stats[role] = model.ArtistStats{
- SongCount: int(c["m"]),
- AlbumCount: int(c["a"]),
- Size: c["s"],
+
+ for _, stats := range rawLibStats {
+ // Sum all libraries roles stats
+ for key, stat := range stats {
+ // Aggregate stats into the main Artist.Stats map
+ artistStats := model.ArtistStats{
+ SongCount: int(stat["m"]),
+ AlbumCount: int(stat["a"]),
+ Size: stat["s"],
+ }
+
+ // Store total stats into the main attributes
+ if key == "total" {
+ a.Artist.Size += artistStats.Size
+ a.Artist.SongCount += artistStats.SongCount
+ a.Artist.AlbumCount += artistStats.AlbumCount
+ }
+
+ role := model.RoleFromString(key)
+ if role == model.RoleInvalid {
+ continue
+ }
+
+ current := a.Artist.Stats[role]
+ current.Size += artistStats.Size
+ current.SongCount += artistStats.SongCount
+ current.AlbumCount += artistStats.AlbumCount
+ a.Artist.Stats[role] = current
+ }
}
}
+
a.Artist.SimilarArtists = nil
if a.SimilarArtists == "" {
return nil
@@ -83,6 +103,7 @@ func (a *dbArtist) PostMapArgs(m map[string]any) error {
similarArtists, _ := json.Marshal(sa)
m["similar_artists"] = string(similarArtists)
m["full_text"] = formatFullText(a.Name, a.SortArtistName)
+ m["search_normalized"] = normalizeForFTS(a.Name)
// Do not override the sort_artist_name and mbz_artist_id fields if they are empty
// TODO: Better way to handle this?
@@ -112,45 +133,92 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
r.tableName = "artist" // To be used by the idFilter below
r.registerModel(&model.Artist{}, map[string]filterFunc{
- "id": idFilter(r.tableName),
- "name": fullTextFilter(r.tableName),
- "starred": booleanFilter,
- "role": roleFilter,
- "missing": booleanFilter,
+ "id": idFilter(r.tableName),
+ "name": fullTextFilter(r.tableName, "mbz_artist_id"),
+ "starred": annotationBoolFilter("starred"),
+ "has_rating": annotationBoolFilter("rating"),
+ "role": roleFilter,
+ "missing": booleanFilter,
+ "library_id": artistLibraryIdFilter,
})
- r.setSortMappings(map[string]string{
+ r.setSortMappings(map[string]string{ //nolint:gosec
"name": "order_artist_name",
"starred_at": "starred, starred_at",
+ "rated_at": "rating, rated_at",
"song_count": "stats->>'total'->>'m'",
"album_count": "stats->>'total'->>'a'",
"size": "stats->>'total'->>'s'",
+
+ // Stats by credits that are currently available
+ "maincredit_song_count": "sum(stats->>'maincredit'->>'m')",
+ "maincredit_album_count": "sum(stats->>'maincredit'->>'a')",
+ "maincredit_size": "sum(stats->>'maincredit'->>'s')",
})
return r
}
func roleFilter(_ string, role any) Sqlizer {
- return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil}
+ if role, ok := role.(string); ok {
+ if _, ok := model.AllRoles[role]; ok {
+ return Expr("JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL")
+ }
+ }
+ return Eq{"1": 2}
+}
+
+// artistLibraryIdFilter filters artists based on library access through the library_artist table
+func artistLibraryIdFilter(_ string, value any) Sqlizer {
+ return Eq{"library_artist.library_id": value}
+}
+
+// applyLibraryFilterToArtistQuery applies library filtering to artist queries through the library_artist junction table
+func (r *artistRepository) applyLibraryFilterToArtistQuery(query SelectBuilder) SelectBuilder {
+ user := loggedUser(r.ctx)
+ // Join with library_artist first to ensure only artists with content in libraries are included
+ // Exclude artists with empty stats (no actual content in the library)
+ query = query.Join("library_artist on library_artist.artist_id = artist.id")
+ //query = query.Join("library_artist on library_artist.artist_id = artist.id AND library_artist.stats != '{}'")
+
+ // Admin users see all artists from all libraries, no additional filtering needed
+ if user.ID != invalidUserId && !user.IsAdmin {
+ // Apply library filtering only for non-admin users by joining with their accessible libraries
+ query = query.Join("user_library on user_library.library_id = library_artist.library_id AND user_library.user_id = ?", user.ID)
+ }
+
+ return query
}
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
- query := r.newSelect(options...).Columns("artist.*")
- query = r.withAnnotation(query, "artist.id")
- return query
+ // Stats Format: {"1": {"albumartist": {"m": 10, "a": 5, "s": 1024}, "artist": {...}}, "2": {...}}
+ query := r.newSelect(options...).Columns("artist.*",
+ "JSON_GROUP_OBJECT(library_artist.library_id, JSONB(library_artist.stats)) as library_stats_json")
+
+ query = r.applyLibraryFilterToArtistQuery(query)
+ query = query.GroupBy("artist.id")
+ return r.withAnnotation(query, "artist.id")
}
func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
query := r.newSelect()
+ query = r.applyLibraryFilterToArtistQuery(query)
query = r.withAnnotation(query, "artist.id")
return r.count(query, options...)
}
+// Exists checks if an artist with the given ID exists in the database and is accessible by the current user.
func (r *artistRepository) Exists(id string) (bool, error) {
- return r.exists(Eq{"artist.id": id})
+ // Create a query using the same library filtering logic as selectArtist()
+ query := r.newSelect().Columns("count(distinct artist.id) as exist").Where(Eq{"artist.id": id})
+ query = r.applyLibraryFilterToArtistQuery(query)
+
+ var res struct{ Exist int64 }
+ err := r.queryOne(query, &res)
+ return res.Exist > 0, err
}
func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error {
dba := &dbArtist{Artist: a}
- dba.CreatedAt = P(time.Now())
+ dba.CreatedAt = new(time.Now())
dba.UpdatedAt = dba.CreatedAt
_, err := r.put(dba.ID, dba, colsToUpdate...)
return err
@@ -202,14 +270,21 @@ func (r *artistRepository) getIndexKey(a model.Artist) string {
return "#"
}
-// TODO Cache the index (recalculate when there are changes to the DB)
-func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (model.ArtistIndexes, error) {
+// GetIndex returns a list of artists grouped by the first letter of their name, or by the index group if configured.
+// It can filter by roles and libraries, and optionally include artists that are missing (i.e., have no albums).
+// TODO Cache the index (recalculate at scan time)
+func (r *artistRepository) GetIndex(includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) {
+ // Validate library IDs. If no library IDs are provided, return an empty index.
+ if len(libraryIds) == 0 {
+ return nil, nil
+ }
+
options := model.QueryOptions{Sort: "name"}
if len(roles) > 0 {
roleFilters := slice.Map(roles, func(r model.Role) Sqlizer {
- return roleFilter("role", r)
+ return roleFilter("role", r.String())
})
- options.Filters = And(roleFilters)
+ options.Filters = Or(roleFilters)
}
if !includeMissing {
if options.Filters == nil {
@@ -218,10 +293,19 @@ func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (m
options.Filters = And{options.Filters, Eq{"artist.missing": false}}
}
}
+
+ libFilter := artistLibraryIdFilter("library_id", libraryIds)
+ if options.Filters == nil {
+ options.Filters = libFilter
+ } else {
+ options.Filters = And{options.Filters, libFilter}
+ }
+
artists, err := r.GetAll(options)
if err != nil {
return nil, err
}
+
var result model.ArtistIndexes
for k, v := range slice.Group(artists, r.getIndexKey) {
result = append(result, model.ArtistIndex{ID: k, Artists: v})
@@ -233,7 +317,19 @@ func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (m
}
func (r *artistRepository) purgeEmpty() error {
- del := Delete(r.tableName).Where("id not in (select artist_id from album_artists)")
+ orphanFilter := "id not in (select artist_id from album_artists)"
+
+ // Collect uploaded image filenames before deleting
+ sel := Select("uploaded_image").From(r.tableName).
+ Where(orphanFilter).
+ Where("uploaded_image != ''")
+ var imageFiles []string
+ if err := r.queryAllSlice(sel, &imageFiles); err != nil && !errors.Is(err, model.ErrNotFound) {
+ return fmt.Errorf("collecting artist images for cleanup: %w", err)
+ }
+
+ // Delete orphan artists
+ del := Delete(r.tableName).Where(orphanFilter)
c, err := r.executeSQL(del)
if err != nil {
return fmt.Errorf("purging empty artists: %w", err)
@@ -241,6 +337,19 @@ func (r *artistRepository) purgeEmpty() error {
if c > 0 {
log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c)
}
+
+ if len(imageFiles) == 0 {
+ return nil
+ }
+
+ // Best-effort cleanup of uploaded image files
+ log.Debug(r.ctx, "Cleaning up artist images", "totalImages", len(imageFiles))
+ for _, filename := range imageFiles {
+ path := model.UploadedImagePath(consts.EntityArtist, filename)
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ log.Warn(r.ctx, "Failed to remove artist image during GC", "path", path, err)
+ }
+ }
return nil
}
@@ -287,75 +396,97 @@ on conflict (user_id, item_id, item_type) do update
}
// RefreshStats updates the stats field for artists whose associated media files were updated after the oldest recorded library scan time.
-// It processes artists in batches to handle potentially large updates.
-func (r *artistRepository) RefreshStats() (int64, error) {
- touchedArtistsQuerySQL := `
- SELECT DISTINCT mfa.artist_id
- FROM media_file_artists mfa
- JOIN media_file mf ON mfa.media_file_id = mf.id
- WHERE mf.updated_at > (SELECT last_scan_at FROM library ORDER BY last_scan_at ASC LIMIT 1)
- `
-
+// When allArtists is true, it refreshes stats for all artists. It processes artists in batches to handle potentially large updates.
+// This method now calculates per-library statistics and stores them in the library_artist junction table.
+func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
var allTouchedArtistIDs []string
- if err := r.db.NewQuery(touchedArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil {
- return 0, fmt.Errorf("fetching touched artist IDs: %w", err)
+ if allArtists {
+ // Refresh stats for all artists
+ allArtistsQuerySQL := `SELECT DISTINCT id FROM artist WHERE id <> ''`
+ if err := r.db.NewQuery(allArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil {
+ return 0, fmt.Errorf("fetching all artist IDs: %w", err)
+ }
+ log.Debug(r.ctx, "RefreshStats: Refreshing all artists.", "count", len(allTouchedArtistIDs))
+ } else {
+ // Only refresh artists with updated timestamps
+ touchedArtistsQuerySQL := `
+ SELECT DISTINCT id
+ FROM artist
+ WHERE updated_at > (SELECT last_scan_at FROM library ORDER BY last_scan_at ASC LIMIT 1)
+ `
+ if err := r.db.NewQuery(touchedArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil {
+ return 0, fmt.Errorf("fetching touched artist IDs: %w", err)
+ }
+ log.Debug(r.ctx, "RefreshStats: Refreshing touched artists.", "count", len(allTouchedArtistIDs))
}
if len(allTouchedArtistIDs) == 0 {
log.Debug(r.ctx, "RefreshStats: No artists to update.")
return 0, nil
}
- log.Debug(r.ctx, "RefreshStats: Found artists to update.", "count", len(allTouchedArtistIDs))
// Template for the batch update with placeholder markers that we'll replace
+ // This now calculates per-library statistics and stores them in library_artist.stats
batchUpdateStatsSQL := `
WITH artist_role_counters AS (
- SELECT jt.atom AS artist_id,
- substr(
- replace(jt.path, '$.', ''),
- 1,
- CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0
- THEN instr(replace(jt.path, '$.', ''), '[') - 1
- ELSE length(replace(jt.path, '$.', ''))
- END
- ) AS role,
+ SELECT mfa.artist_id,
+ mf.library_id,
+ mfa.role,
count(DISTINCT mf.album_id) AS album_count,
- count(mf.id) AS count,
+ count(DISTINCT mf.id) AS count,
sum(mf.size) AS size
- FROM media_file mf
- JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL
- WHERE jt.atom IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
- GROUP BY jt.atom, role
+ FROM media_file_artists mfa
+ JOIN media_file mf ON mfa.media_file_id = mf.id
+ WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
+ GROUP BY mfa.artist_id, mf.library_id, mfa.role
),
artist_total_counters AS (
SELECT mfa.artist_id,
+ mf.library_id,
'total' AS role,
count(DISTINCT mf.album_id) AS album_count,
count(DISTINCT mf.id) AS count,
sum(mf.size) AS size
FROM media_file_artists mfa
JOIN media_file mf ON mfa.media_file_id = mf.id
- WHERE mfa.artist_id IN (TOTAL_IDS_PLACEHOLDER) -- Will replace with actual placeholders
- GROUP BY mfa.artist_id
+ WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
+ GROUP BY mfa.artist_id, mf.library_id
+ ),
+ artist_participant_counter AS (
+ SELECT mfa.artist_id,
+ mf.library_id,
+ 'maincredit' AS role,
+ count(DISTINCT mf.album_id) AS album_count,
+ count(DISTINCT mf.id) AS count,
+ sum(mf.size) AS size
+ FROM media_file_artists mfa
+ JOIN media_file mf ON mfa.media_file_id = mf.id
+ WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
+ AND mfa.role IN ('albumartist', 'artist')
+ GROUP BY mfa.artist_id, mf.library_id
),
combined_counters AS (
- SELECT artist_id, role, album_count, count, size FROM artist_role_counters
- UNION
- SELECT artist_id, role, album_count, count, size FROM artist_total_counters
+ SELECT artist_id, library_id, role, album_count, count, size FROM artist_role_counters
+ UNION ALL
+ SELECT artist_id, library_id, role, album_count, count, size FROM artist_total_counters
+ UNION ALL
+ SELECT artist_id, library_id, role, album_count, count, size FROM artist_participant_counter
),
- artist_counters AS (
- SELECT artist_id AS id,
+ library_artist_counters AS (
+ SELECT artist_id,
+ library_id,
json_group_object(
- replace(role, '"', ''),
+ role,
json_object('a', album_count, 'm', count, 's', size)
) AS counters
FROM combined_counters
- GROUP BY artist_id
+ GROUP BY artist_id, library_id
)
- UPDATE artist
- SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'),
- updated_at = datetime(current_timestamp, 'localtime')
- WHERE artist.id IN (UPDATE_IDS_PLACEHOLDER) AND artist.id <> '';` // Will replace with actual placeholders
+ UPDATE library_artist
+ SET stats = coalesce((SELECT counters FROM library_artist_counters lac
+ WHERE lac.artist_id = library_artist.artist_id
+ AND lac.library_id = library_artist.library_id), '{}')
+ WHERE library_artist.artist_id IN (ROLE_IDS_PLACEHOLDER);` // Will replace with actual placeholders
var totalRowsAffected int64 = 0
const batchSize = 1000
@@ -374,21 +505,16 @@ func (r *artistRepository) RefreshStats() (int64, error) {
inClause := strings.Join(placeholders, ",")
// Replace the placeholder markers with actual SQL placeholders
- batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 1)
- batchSQL = strings.Replace(batchSQL, "TOTAL_IDS_PLACEHOLDER", inClause, 1)
- batchSQL = strings.Replace(batchSQL, "UPDATE_IDS_PLACEHOLDER", inClause, 1)
+ batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 4)
- // Create a single parameter array with all IDs (repeated 3 times for each IN clause)
- // We need to repeat each ID 3 times (once for each IN clause)
- var args []interface{}
- for _, id := range artistIDBatch {
- args = append(args, id) // For ROLE_IDS_PLACEHOLDER
- }
- for _, id := range artistIDBatch {
- args = append(args, id) // For TOTAL_IDS_PLACEHOLDER
- }
- for _, id := range artistIDBatch {
- args = append(args, id) // For UPDATE_IDS_PLACEHOLDER
+ // Create a single parameter array with all IDs (repeated 4 times for each IN clause)
+ // We need to repeat each ID 4 times (once for each IN clause)
+ args := make([]any, 4*len(artistIDBatch))
+ for idx, id := range artistIDBatch {
+ for i := range 4 {
+ startIdx := i * len(artistIDBatch)
+ args[startIdx+idx] = id
+ }
}
// Now use Expr with the expanded SQL and all parameters
@@ -401,37 +527,60 @@ func (r *artistRepository) RefreshStats() (int64, error) {
totalRowsAffected += rowsAffected
}
+ // // Remove library_artist entries for artists that no longer have any content in any library
+ cleanupSQL := Delete("library_artist").Where("stats = '{}'")
+ cleanupRows, err := r.executeSQL(cleanupSQL)
+ if err != nil {
+ log.Warn(r.ctx, "Failed to cleanup empty library_artist entries", "error", err)
+ } else if cleanupRows > 0 {
+ log.Debug(r.ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows)
+ }
+
log.Debug(r.ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected)
return totalRowsAffected, nil
}
-func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool) (model.Artists, error) {
- var dba dbArtists
- err := r.doSearch(r.selectArtist(), q, offset, size, includeMissing, &dba, "json_extract(stats, '$.total.m') desc", "name")
- if err != nil {
- return nil, err
+func (r *artistRepository) searchCfg() searchConfig {
+ return searchConfig{
+ // Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist
+ NaturalOrder: "artist.id",
+ OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"},
+ MBIDFields: []string{"mbz_artist_id"},
+ LibraryFilter: r.applyLibraryFilterToArtistQuery,
}
- return dba.toModels(), nil
+}
+
+func (r *artistRepository) Search(q string, options ...model.QueryOptions) (model.Artists, error) {
+ var opts model.QueryOptions
+ if len(options) > 0 {
+ opts = options[0]
+ }
+ var res dbArtists
+ err := r.doSearch(r.selectArtist(options...), q, &res, r.searchCfg(), opts)
+ if err != nil {
+ return nil, fmt.Errorf("searching artist %q: %w", q, err)
+ }
+ return res.toModels(), nil
}
func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
-func (r *artistRepository) Read(id string) (interface{}, error) {
+func (r *artistRepository) Read(id string) (any, error) {
return r.Get(id)
}
-func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
+func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
role := "total"
if len(options) > 0 {
if v, ok := options[0].Filters["role"].(string); ok {
role = v
}
}
- r.sortMappings["song_count"] = "stats->>'" + role + "'->>'m'"
- r.sortMappings["album_count"] = "stats->>'" + role + "'->>'a'"
- r.sortMappings["size"] = "stats->>'" + role + "'->>'s'"
+ r.sortMappings["song_count"] = "sum(stats->>'" + role + "'->>'m')"
+ r.sortMappings["album_count"] = "sum(stats->>'" + role + "'->>'a')"
+ r.sortMappings["size"] = "sum(stats->>'" + role + "'->>'s')"
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
@@ -439,7 +588,7 @@ func (r *artistRepository) EntityName() string {
return "artist"
}
-func (r *artistRepository) NewInstance() interface{} {
+func (r *artistRepository) NewInstance() any {
return &model.Artist{}
}
diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go
index 0c7018dc8..076a9da3b 100644
--- a/persistence/artist_repository_test.go
+++ b/persistence/artist_repository_test.go
@@ -3,11 +3,14 @@ package persistence
import (
"context"
"encoding/json"
+ "os"
+ "path/filepath"
"github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
- "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils"
@@ -15,310 +18,914 @@ import (
. "github.com/onsi/gomega"
)
+// Test helper functions to reduce duplication
+func createTestArtistWithMBID(id, name, mbid string) model.Artist {
+ return model.Artist{
+ ID: id,
+ Name: name,
+ MbzArtistID: mbid,
+ }
+}
+
+func createUserWithLibraries(userID string, libraryIDs []int) model.User {
+ user := model.User{
+ ID: userID,
+ UserName: userID,
+ Name: userID,
+ Email: userID + "@test.com",
+ IsAdmin: false,
+ }
+
+ if len(libraryIDs) > 0 {
+ user.Libraries = make(model.Libraries, len(libraryIDs))
+ for i, libID := range libraryIDs {
+ user.Libraries[i] = model.Library{ID: libID, Name: "Test Library", Path: "/test"}
+ }
+ }
+
+ return user
+}
+
var _ = Describe("ArtistRepository", func() {
- var repo model.ArtistRepository
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- ctx := log.NewContext(context.TODO())
- ctx = request.WithUser(ctx, model.User{ID: "userid"})
- repo = NewArtistRepository(ctx, GetDBXBuilder())
- })
+ Context("Core Functionality", func() {
+ Describe("GetIndexKey", func() {
+ // Note: OrderArtistName should never be empty, so we don't need to test for that
+ r := artistRepository{indexGroups: utils.ParseIndexGroups(conf.Server.IndexGroups)}
- Describe("Count", func() {
- It("returns the number of artists in the DB", func() {
- Expect(repo.CountAll()).To(Equal(int64(2)))
+ DescribeTable("returns correct index key based on PreferSortTags setting",
+ func(preferSortTags bool, sortArtistName, orderArtistName, expectedKey string) {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.PreferSortTags = preferSortTags
+ a := model.Artist{SortArtistName: sortArtistName, OrderArtistName: orderArtistName, Name: "Test"}
+ idx := GetIndexKey(&r, a)
+ Expect(idx).To(Equal(expectedKey))
+ },
+ Entry("PreferSortTags=false, SortArtistName empty -> uses OrderArtistName", false, "", "Bar", "B"),
+ Entry("PreferSortTags=false, SortArtistName not empty -> still uses OrderArtistName", false, "Foo", "Bar", "B"),
+ Entry("PreferSortTags=true, SortArtistName not empty -> uses SortArtistName", true, "Foo", "Bar", "F"),
+ Entry("PreferSortTags=true, SortArtistName empty -> falls back to OrderArtistName", true, "", "Bar", "B"),
+ )
})
- })
- Describe("Exists", func() {
- It("returns true for an artist that is in the DB", func() {
- Expect(repo.Exists("3")).To(BeTrue())
- })
- It("returns false for an artist that is in the DB", func() {
- Expect(repo.Exists("666")).To(BeFalse())
- })
- })
+ Describe("roleFilter", func() {
+ DescribeTable("validates roles and returns appropriate SQL expressions",
+ func(role string, shouldBeValid bool) {
+ result := roleFilter("", role)
+ if shouldBeValid {
+ expectedExpr := squirrel.Expr("JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL")
+ Expect(result).To(Equal(expectedExpr))
+ } else {
+ expectedInvalid := squirrel.Eq{"1": 2}
+ Expect(result).To(Equal(expectedInvalid))
+ }
+ },
+ // Valid roles from model.AllRoles
+ Entry("artist role", "artist", true),
+ Entry("albumartist role", "albumartist", true),
+ Entry("composer role", "composer", true),
+ Entry("conductor role", "conductor", true),
+ Entry("lyricist role", "lyricist", true),
+ Entry("arranger role", "arranger", true),
+ Entry("producer role", "producer", true),
+ Entry("director role", "director", true),
+ Entry("engineer role", "engineer", true),
+ Entry("mixer role", "mixer", true),
+ Entry("remixer role", "remixer", true),
+ Entry("djmixer role", "djmixer", true),
+ Entry("performer role", "performer", true),
+ Entry("maincredit role", "maincredit", true),
+ // Invalid roles
+ Entry("invalid role - wizard", "wizard", false),
+ Entry("invalid role - songanddanceman", "songanddanceman", false),
+ Entry("empty string", "", false),
+ Entry("SQL injection attempt", "artist') SELECT LIKE(CHAR(65,66,67,68,69,70,71),UPPER(HEX(RANDOMBLOB(500000000/2))))--", false),
+ )
- Describe("Get", func() {
- It("saves and retrieves data", func() {
- artist, err := repo.Get("2")
- Expect(err).ToNot(HaveOccurred())
- Expect(artist.Name).To(Equal(artistKraftwerk.Name))
+ It("handles non-string input types", func() {
+ expectedInvalid := squirrel.Eq{"1": 2}
+ Expect(roleFilter("", 123)).To(Equal(expectedInvalid))
+ Expect(roleFilter("", nil)).To(Equal(expectedInvalid))
+ Expect(roleFilter("", []string{"artist"})).To(Equal(expectedInvalid))
+ })
})
- })
- Describe("GetIndexKey", func() {
- // Note: OrderArtistName should never be empty, so we don't need to test for that
- r := artistRepository{indexGroups: utils.ParseIndexGroups(conf.Server.IndexGroups)}
- When("PreferSortTags is false", func() {
+ Describe("dbArtist mapping", func() {
+ var (
+ artist *model.Artist
+ dba *dbArtist
+ )
+
BeforeEach(func() {
- conf.Server.PreferSortTags = false
+ artist = &model.Artist{ID: "1", Name: "Eddie Van Halen", SortArtistName: "Van Halen, Eddie"}
+ dba = &dbArtist{Artist: artist}
})
- It("returns the OrderArtistName key is SortArtistName is empty", func() {
- conf.Server.PreferSortTags = false
- a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"}
- idx := GetIndexKey(&r, a)
- Expect(idx).To(Equal("B"))
+
+ Describe("PostScan", func() {
+ It("parses stats and similar artists correctly", func() {
+ stats := map[string]map[string]map[string]int64{
+ "1": {
+ "total": {"s": 1000, "m": 10, "a": 2},
+ "composer": {"s": 500, "m": 5, "a": 1},
+ },
+ }
+ statsJSON, _ := json.Marshal(stats)
+ dba.LibraryStatsJSON = string(statsJSON)
+ dba.SimilarArtists = `[{"id":"2","Name":"AC/DC"},{"name":"Test;With:Sep,Chars"}]`
+
+ err := dba.PostScan()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(dba.Artist.Size).To(Equal(int64(1000)))
+ Expect(dba.Artist.SongCount).To(Equal(10))
+ Expect(dba.Artist.AlbumCount).To(Equal(2))
+ Expect(dba.Artist.Stats).To(HaveLen(1))
+ Expect(dba.Artist.Stats[model.RoleFromString("composer")].Size).To(Equal(int64(500)))
+ Expect(dba.Artist.Stats[model.RoleFromString("composer")].SongCount).To(Equal(5))
+ Expect(dba.Artist.Stats[model.RoleFromString("composer")].AlbumCount).To(Equal(1))
+ Expect(dba.Artist.SimilarArtists).To(HaveLen(2))
+ Expect(dba.Artist.SimilarArtists[0].ID).To(Equal("2"))
+ Expect(dba.Artist.SimilarArtists[0].Name).To(Equal("AC/DC"))
+ Expect(dba.Artist.SimilarArtists[1].ID).To(BeEmpty())
+ Expect(dba.Artist.SimilarArtists[1].Name).To(Equal("Test;With:Sep,Chars"))
+ })
})
- It("returns the OrderArtistName key even if SortArtistName is not empty", func() {
- a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"}
- idx := GetIndexKey(&r, a)
- Expect(idx).To(Equal("B"))
- })
- })
- When("PreferSortTags is true", func() {
- BeforeEach(func() {
- conf.Server.PreferSortTags = true
- })
- It("returns the SortArtistName key if it is not empty", func() {
- a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"}
- idx := GetIndexKey(&r, a)
- Expect(idx).To(Equal("F"))
- })
- It("returns the OrderArtistName key if SortArtistName is empty", func() {
- a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"}
- idx := GetIndexKey(&r, a)
- Expect(idx).To(Equal("B"))
+
+ Describe("PostMapArgs", func() {
+ It("maps empty similar artists correctly", func() {
+ m := make(map[string]any)
+ err := dba.PostMapArgs(m)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(m).To(HaveKeyWithValue("similar_artists", "[]"))
+ })
+
+ It("maps similar artists and full text correctly", func() {
+ artist.SimilarArtists = []model.Artist{
+ {ID: "2", Name: "AC/DC"},
+ {Name: "Test;With:Sep,Chars"},
+ }
+ m := make(map[string]any)
+ err := dba.PostMapArgs(m)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(m).To(HaveKeyWithValue("similar_artists", `[{"id":"2","name":"AC/DC"},{"name":"Test;With:Sep,Chars"}]`))
+ Expect(m).To(HaveKeyWithValue("full_text", " eddie halen van"))
+ })
+
+ It("does not override empty sort_artist_name and mbz_artist_id", func() {
+ m := map[string]any{
+ "sort_artist_name": "",
+ "mbz_artist_id": "",
+ }
+ err := dba.PostMapArgs(m)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(m).ToNot(HaveKey("sort_artist_name"))
+ Expect(m).ToNot(HaveKey("mbz_artist_id"))
+ })
})
})
})
- Describe("GetIndex", func() {
- When("PreferSortTags is true", func() {
- BeforeEach(func() {
- conf.Server.PreferSortTags = true
- })
- It("returns the index when PreferSortTags is true and SortArtistName is not empty", func() {
- // Set SortArtistName to "Foo" for Beatles
- artistBeatles.SortArtistName = "Foo"
- er := repo.Put(&artistBeatles)
- Expect(er).To(BeNil())
-
- idx, err := repo.GetIndex(false)
- Expect(err).ToNot(HaveOccurred())
- Expect(idx).To(HaveLen(2))
- Expect(idx[0].ID).To(Equal("F"))
- Expect(idx[0].Artists).To(HaveLen(1))
- Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
- Expect(idx[1].ID).To(Equal("K"))
- Expect(idx[1].Artists).To(HaveLen(1))
- Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
-
- // Restore the original value
- artistBeatles.SortArtistName = ""
- er = repo.Put(&artistBeatles)
- Expect(er).To(BeNil())
- })
-
- // BFR Empty SortArtistName is not saved in the DB anymore
- XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
- idx, err := repo.GetIndex(false)
- Expect(err).ToNot(HaveOccurred())
- Expect(idx).To(HaveLen(2))
- Expect(idx[0].ID).To(Equal("B"))
- Expect(idx[0].Artists).To(HaveLen(1))
- Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
- Expect(idx[1].ID).To(Equal("K"))
- Expect(idx[1].Artists).To(HaveLen(1))
- Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
- })
- })
-
- When("PreferSortTags is false", func() {
- BeforeEach(func() {
- conf.Server.PreferSortTags = false
- })
- It("returns the index when SortArtistName is NOT empty", func() {
- // Set SortArtistName to "Foo" for Beatles
- artistBeatles.SortArtistName = "Foo"
- er := repo.Put(&artistBeatles)
- Expect(er).To(BeNil())
-
- idx, err := repo.GetIndex(false)
- Expect(err).ToNot(HaveOccurred())
- Expect(idx).To(HaveLen(2))
- Expect(idx[0].ID).To(Equal("B"))
- Expect(idx[0].Artists).To(HaveLen(1))
- Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
- Expect(idx[1].ID).To(Equal("K"))
- Expect(idx[1].Artists).To(HaveLen(1))
- Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
-
- // Restore the original value
- artistBeatles.SortArtistName = ""
- er = repo.Put(&artistBeatles)
- Expect(er).To(BeNil())
- })
-
- It("returns the index when SortArtistName is empty", func() {
- idx, err := repo.GetIndex(false)
- Expect(err).ToNot(HaveOccurred())
- Expect(idx).To(HaveLen(2))
- Expect(idx[0].ID).To(Equal("B"))
- Expect(idx[0].Artists).To(HaveLen(1))
- Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
- Expect(idx[1].ID).To(Equal("K"))
- Expect(idx[1].Artists).To(HaveLen(1))
- Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
- })
- })
- })
-
- Describe("dbArtist mapping", func() {
- var (
- artist *model.Artist
- dba *dbArtist
- )
+ Context("Admin User Operations", func() {
+ var repo model.ArtistRepository
BeforeEach(func() {
- artist = &model.Artist{ID: "1", Name: "Eddie Van Halen", SortArtistName: "Van Halen, Eddie"}
- dba = &dbArtist{Artist: artist}
+ ctx := GinkgoT().Context()
+ ctx = request.WithUser(ctx, adminUser)
+ repo = NewArtistRepository(ctx, GetDBXBuilder())
})
- Describe("PostScan", func() {
- It("parses stats and similar artists correctly", func() {
- stats := map[string]map[string]int64{
- "total": {"s": 1000, "m": 10, "a": 2},
- "composer": {"s": 500, "m": 5, "a": 1},
- }
- statsJSON, _ := json.Marshal(stats)
- dba.Stats = string(statsJSON)
- dba.SimilarArtists = `[{"id":"2","Name":"AC/DC"},{"name":"Test;With:Sep,Chars"}]`
+ Describe("Basic Operations", func() {
+ Describe("Count", func() {
+ It("returns the number of artists in the DB", func() {
+ Expect(repo.CountAll()).To(Equal(int64(4)))
+ })
+ })
- err := dba.PostScan()
- Expect(err).ToNot(HaveOccurred())
- Expect(dba.Artist.Size).To(Equal(int64(1000)))
- Expect(dba.Artist.SongCount).To(Equal(10))
- Expect(dba.Artist.AlbumCount).To(Equal(2))
- Expect(dba.Artist.Stats).To(HaveLen(1))
- Expect(dba.Artist.Stats[model.RoleFromString("composer")].Size).To(Equal(int64(500)))
- Expect(dba.Artist.Stats[model.RoleFromString("composer")].SongCount).To(Equal(5))
- Expect(dba.Artist.Stats[model.RoleFromString("composer")].AlbumCount).To(Equal(1))
- Expect(dba.Artist.SimilarArtists).To(HaveLen(2))
- Expect(dba.Artist.SimilarArtists[0].ID).To(Equal("2"))
- Expect(dba.Artist.SimilarArtists[0].Name).To(Equal("AC/DC"))
- Expect(dba.Artist.SimilarArtists[1].ID).To(BeEmpty())
- Expect(dba.Artist.SimilarArtists[1].Name).To(Equal("Test;With:Sep,Chars"))
+ Describe("Exists", func() {
+ It("returns true for an artist that is in the DB", func() {
+ Expect(repo.Exists("3")).To(BeTrue())
+ })
+ It("returns false for an artist that is NOT in the DB", func() {
+ Expect(repo.Exists("666")).To(BeFalse())
+ })
+ })
+
+ Describe("Get", func() {
+ It("retrieves existing artist data", func() {
+ artist, err := repo.Get("2")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(artist.Name).To(Equal(artistKraftwerk.Name))
+ })
})
})
- Describe("PostMapArgs", func() {
- It("maps empty similar artists correctly", func() {
- m := make(map[string]any)
- err := dba.PostMapArgs(m)
- Expect(err).ToNot(HaveOccurred())
- Expect(m).To(HaveKeyWithValue("similar_artists", "[]"))
+ Describe("GetIndex", func() {
+ When("PreferSortTags is true", func() {
+ BeforeEach(func() {
+ conf.Server.PreferSortTags = true
+ })
+ It("returns the index when PreferSortTags is true and SortArtistName is not empty", func() {
+ // Set SortArtistName to "Foo" for Beatles
+ artistBeatles.SortArtistName = "Foo"
+ er := repo.Put(&artistBeatles)
+ Expect(er).To(BeNil())
+
+ idx, err := repo.GetIndex(false, []int{1})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(idx).To(HaveLen(4))
+ Expect(idx[0].ID).To(Equal("F"))
+ Expect(idx[0].Artists).To(HaveLen(1))
+ Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
+ Expect(idx[1].ID).To(Equal("K"))
+ Expect(idx[1].Artists).To(HaveLen(1))
+ Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
+ Expect(idx[2].ID).To(Equal("R"))
+ Expect(idx[2].Artists).To(HaveLen(1))
+ Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
+ Expect(idx[3].ID).To(Equal("S"))
+ Expect(idx[3].Artists).To(HaveLen(1))
+ Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
+
+ // Restore the original value
+ artistBeatles.SortArtistName = ""
+ er = repo.Put(&artistBeatles)
+ Expect(er).To(BeNil())
+ })
+
+ // BFR Empty SortArtistName is not saved in the DB anymore
+ XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
+ idx, err := repo.GetIndex(false, []int{1})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(idx).To(HaveLen(4))
+ Expect(idx[0].ID).To(Equal("B"))
+ Expect(idx[0].Artists).To(HaveLen(1))
+ Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
+ Expect(idx[1].ID).To(Equal("K"))
+ Expect(idx[1].Artists).To(HaveLen(1))
+ Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
+ Expect(idx[2].ID).To(Equal("R"))
+ Expect(idx[2].Artists).To(HaveLen(1))
+ Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
+ Expect(idx[3].ID).To(Equal("S"))
+ Expect(idx[3].Artists).To(HaveLen(1))
+ Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
+ })
})
- It("maps similar artists and full text correctly", func() {
- artist.SimilarArtists = []model.Artist{
- {ID: "2", Name: "AC/DC"},
- {Name: "Test;With:Sep,Chars"},
- }
- m := make(map[string]any)
- err := dba.PostMapArgs(m)
- Expect(err).ToNot(HaveOccurred())
- Expect(m).To(HaveKeyWithValue("similar_artists", `[{"id":"2","name":"AC/DC"},{"name":"Test;With:Sep,Chars"}]`))
- Expect(m).To(HaveKeyWithValue("full_text", " eddie halen van"))
+ When("PreferSortTags is false", func() {
+ BeforeEach(func() {
+ conf.Server.PreferSortTags = false
+ })
+ It("returns the index when SortArtistName is NOT empty", func() {
+ // Set SortArtistName to "Foo" for Beatles
+ artistBeatles.SortArtistName = "Foo"
+ er := repo.Put(&artistBeatles)
+ Expect(er).To(BeNil())
+
+ idx, err := repo.GetIndex(false, []int{1})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(idx).To(HaveLen(4))
+ Expect(idx[0].ID).To(Equal("B"))
+ Expect(idx[0].Artists).To(HaveLen(1))
+ Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
+ Expect(idx[1].ID).To(Equal("K"))
+ Expect(idx[1].Artists).To(HaveLen(1))
+ Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
+ Expect(idx[2].ID).To(Equal("R"))
+ Expect(idx[2].Artists).To(HaveLen(1))
+ Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
+ Expect(idx[3].ID).To(Equal("S"))
+ Expect(idx[3].Artists).To(HaveLen(1))
+ Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
+
+ // Restore the original value
+ artistBeatles.SortArtistName = ""
+ er = repo.Put(&artistBeatles)
+ Expect(er).To(BeNil())
+ })
+
+ It("returns the index when SortArtistName is empty", func() {
+ idx, err := repo.GetIndex(false, []int{1})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(idx).To(HaveLen(4))
+ Expect(idx[0].ID).To(Equal("B"))
+ Expect(idx[0].Artists).To(HaveLen(1))
+ Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
+ Expect(idx[1].ID).To(Equal("K"))
+ Expect(idx[1].Artists).To(HaveLen(1))
+ Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
+ Expect(idx[2].ID).To(Equal("R"))
+ Expect(idx[2].Artists).To(HaveLen(1))
+ Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
+ Expect(idx[3].ID).To(Equal("S"))
+ Expect(idx[3].Artists).To(HaveLen(1))
+ Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
+ })
})
- It("does not override empty sort_artist_name and mbz_artist_id", func() {
- m := map[string]any{
- "sort_artist_name": "",
- "mbz_artist_id": "",
- }
- err := dba.PostMapArgs(m)
- Expect(err).ToNot(HaveOccurred())
- Expect(m).ToNot(HaveKey("sort_artist_name"))
- Expect(m).ToNot(HaveKey("mbz_artist_id"))
+ When("filtering by role", func() {
+ var raw *artistRepository
+
+ BeforeEach(func() {
+ raw = repo.(*artistRepository)
+ // Add stats to library_artist table since stats are now stored per-library
+ composerStats := `{"composer": {"s": 1000, "m": 5, "a": 2}}`
+ producerStats := `{"producer": {"s": 500, "m": 3, "a": 1}}`
+
+ // Set Beatles as composer in library 1
+ _, err := raw.executeSQL(squirrel.Insert("library_artist").
+ Columns("library_id", "artist_id", "stats").
+ Values(1, artistBeatles.ID, composerStats).
+ Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats"))
+ Expect(err).ToNot(HaveOccurred())
+
+ // Set Kraftwerk as producer in library 1
+ _, err = raw.executeSQL(squirrel.Insert("library_artist").
+ Columns("library_id", "artist_id", "stats").
+ Values(1, artistKraftwerk.ID, producerStats).
+ Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats"))
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ AfterEach(func() {
+ // Clean up stats from library_artist table
+ _, _ = raw.executeSQL(squirrel.Update("library_artist").
+ Set("stats", "{}").
+ Where(squirrel.Eq{"artist_id": artistBeatles.ID, "library_id": 1}))
+ _, _ = raw.executeSQL(squirrel.Update("library_artist").
+ Set("stats", "{}").
+ Where(squirrel.Eq{"artist_id": artistKraftwerk.ID, "library_id": 1}))
+ })
+
+ It("returns only artists with the specified role", func() {
+ idx, err := repo.GetIndex(false, []int{1}, model.RoleComposer)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(idx).To(HaveLen(1))
+ Expect(idx[0].ID).To(Equal("B"))
+ Expect(idx[0].Artists).To(HaveLen(1))
+ Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
+ })
+
+ It("returns artists with any of the specified roles", func() {
+ idx, err := repo.GetIndex(false, []int{1}, model.RoleComposer, model.RoleProducer)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(idx).To(HaveLen(2))
+
+ // Find Beatles and Kraftwerk in the results
+ var beatlesFound, kraftwerkFound bool
+ for _, index := range idx {
+ for _, artist := range index.Artists {
+ if artist.Name == artistBeatles.Name {
+ beatlesFound = true
+ }
+ if artist.Name == artistKraftwerk.Name {
+ kraftwerkFound = true
+ }
+ }
+ }
+ Expect(beatlesFound).To(BeTrue())
+ Expect(kraftwerkFound).To(BeTrue())
+ })
+
+ It("returns empty index when no artists have the specified role", func() {
+ idx, err := repo.GetIndex(false, []int{1}, model.RoleDirector)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(idx).To(HaveLen(0))
+ })
+ })
+
+ When("validating library IDs", func() {
+ It("returns nil when no library IDs are provided", func() {
+ idx, err := repo.GetIndex(false, []int{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(idx).To(BeNil())
+ })
+
+ It("returns artists when library IDs are provided (admin user sees all content)", func() {
+ // Admin users can see all content when valid library IDs are provided
+ idx, err := repo.GetIndex(false, []int{1})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(idx).To(HaveLen(4))
+
+ // With non-existent library ID, admin users see no content because no artists are associated with that library
+ idx, err = repo.GetIndex(false, []int{999})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(idx).To(HaveLen(0)) // Even admin users need valid library associations
+ })
})
})
- Describe("Missing artist visibility", func() {
+ Describe("Filters", func() {
+ var artistWithoutAnnotation model.Artist
+
+ BeforeEach(func() {
+ // Create artist without any annotation
+ artistWithoutAnnotation = model.Artist{ID: "no-annotation-artist", Name: "No Annotation Artist"}
+ err := createArtistWithLibrary(repo, &artistWithoutAnnotation, 1)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ AfterEach(func() {
+ if raw, ok := repo.(*artistRepository); ok {
+ _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithoutAnnotation.ID}))
+ }
+ })
+
+ Describe("starred", func() {
+ It("false includes items without annotations", func() {
+ res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": "false"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ artists := res.(model.Artists)
+
+ var found bool
+ for _, a := range artists {
+ if a.ID == artistWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Artist without annotation should be included in starred=false filter")
+ })
+
+ It("true excludes items without annotations", func() {
+ res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": "true"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ artists := res.(model.Artists)
+
+ for _, a := range artists {
+ Expect(a.ID).ToNot(Equal(artistWithoutAnnotation.ID))
+ }
+ })
+ })
+ })
+
+ Describe("MBID and Text Search", func() {
+ var lib2 model.Library
+ var lr model.LibraryRepository
+ var restrictedUser model.User
+ var restrictedRepo model.ArtistRepository
+ var headlessRepo model.ArtistRepository
+
+ BeforeEach(func() {
+ // Set up headless repo (no user context)
+ headlessRepo = NewArtistRepository(context.Background(), GetDBXBuilder())
+
+ // Create library for testing access restrictions
+ lib2 = model.Library{ID: 0, Name: "Artist Test Library", Path: "/artist/test/lib"}
+ lr = NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
+ err := lr.Put(&lib2)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Create a user with access to only library 1
+ restrictedUser = createUserWithLibraries("search_user", []int{1})
+
+ // Create repository context for the restricted user
+ ctx := request.WithUser(GinkgoT().Context(), restrictedUser)
+ restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder())
+
+ // Ensure both test artists are associated with library 1
+ err = lr.AddArtist(1, artistBeatles.ID)
+ Expect(err).ToNot(HaveOccurred())
+ err = lr.AddArtist(1, artistKraftwerk.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Create the restricted user in the database
+ ur := NewUserRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
+ err = ur.Put(&restrictedUser)
+ Expect(err).ToNot(HaveOccurred())
+ err = ur.SetUserLibraries(restrictedUser.ID, []int{1})
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ AfterEach(func() {
+ // Clean up library 2
+ lr := NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
+ _ = lr.(*libraryRepository).delete(squirrel.Eq{"id": lib2.ID})
+ })
+
+ DescribeTable("MBID search behavior across different user types",
+ func(testRepo *model.ArtistRepository, shouldFind bool, testDesc string) {
+ // Create test artist with MBID
+ artistWithMBID := createTestArtistWithMBID("test-mbid-artist", "Test MBID Artist", "550e8400-e29b-41d4-a716-446655440010")
+
+ err := createArtistWithLibrary(*testRepo, &artistWithMBID, 1)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Test the search
+ results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+
+ if shouldFind {
+ Expect(results).To(HaveLen(1), testDesc)
+ Expect(results[0].ID).To(Equal("test-mbid-artist"))
+ } else {
+ Expect(results).To(BeEmpty(), testDesc)
+ }
+
+ // Clean up
+ if raw, ok := (*testRepo).(*artistRepository); ok {
+ _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID}))
+ }
+ },
+ Entry("Admin user can find artist by MBID", &repo, true, "Admin should find MBID artist"),
+ Entry("Restricted user can find artist by MBID in accessible library", &restrictedRepo, true, "Restricted user should find MBID artist in accessible library"),
+ Entry("Headless process can find artist by MBID", &headlessRepo, true, "Headless process should find MBID artist"),
+ )
+
+ It("prevents restricted user from finding artist by MBID when not in accessible library", func() {
+ // Create an artist in library 2 (not accessible to restricted user)
+ inaccessibleArtist := createTestArtistWithMBID("inaccessible-mbid-artist", "Inaccessible MBID Artist", "a74b1b7f-71a5-4011-9441-d0b5e4122711")
+ err := repo.Put(&inaccessibleArtist)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Add to library 2 (not accessible to restricted user)
+ err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Restricted user should not find this artist
+ results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+
+ // But admin should find it
+ results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+
+ // Clean up
+ if raw, ok := repo.(*artistRepository); ok {
+ _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID}))
+ }
+ })
+
+ Context("Text Search", func() {
+ It("allows admin to find artists by name regardless of library", func() {
+ results, err := repo.Search("Beatles", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].Name).To(Equal("The Beatles"))
+ })
+
+ It("correctly prevents restricted user from finding artists by name when not in accessible library", func() {
+ // Create an artist in library 2 (not accessible to restricted user)
+ inaccessibleArtist := model.Artist{
+ ID: "inaccessible-text-artist",
+ Name: "Unique Search Name Artist",
+ }
+ err := repo.Put(&inaccessibleArtist)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Add to library 2 (not accessible to restricted user)
+ err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Restricted user should not find this artist
+ results, err := restrictedRepo.Search("Unique Search Name", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty(), "Text search should respect library filtering")
+
+ // Clean up
+ if raw, ok := repo.(*artistRepository); ok {
+ _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID}))
+ }
+ })
+ })
+
+ Context("Headless Processes (No User Context)", func() {
+ It("should see all artists from all libraries when no user is in context", func() {
+ // Add artists to different libraries
+ err := lr.AddArtist(lib2.ID, artistBeatles.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Headless processes should see all artists regardless of library
+ artists, err := headlessRepo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should see all artists from all libraries
+ found := false
+ for _, artist := range artists {
+ if artist.ID == artistBeatles.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Headless process should see artists from all libraries")
+ })
+
+ It("should allow headless processes to apply explicit library_id filters", func() {
+ // Add artists to different libraries
+ err := lr.AddArtist(lib2.ID, artistBeatles.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Filter by specific library
+ artists, err := headlessRepo.GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib2.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should see only artists from the specified library
+ for _, artist := range artists {
+ if artist.ID == artistBeatles.ID {
+ return // Found the expected artist
+ }
+ }
+ Expect(false).To(BeTrue(), "Should find artist from specified library")
+ })
+
+ It("should get individual artists when no user is in context", func() {
+ // Add artist to a library
+ err := lr.AddArtist(lib2.ID, artistBeatles.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Headless process should be able to get the artist
+ artist, err := headlessRepo.Get(artistBeatles.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(artist.ID).To(Equal(artistBeatles.ID))
+ })
+ })
+ })
+
+ Describe("Admin User Library Access", func() {
+ It("sees all artists regardless of library permissions", func() {
+ count, err := repo.CountAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(Equal(int64(4)))
+
+ artists, err := repo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(artists).To(HaveLen(4))
+
+ exists, err := repo.Exists(artistBeatles.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(exists).To(BeTrue())
+ })
+ })
+
+ Describe("Missing Artist Handling", func() {
+ var missingArtist model.Artist
var raw *artistRepository
- var missing model.Artist
- insertMissing := func() {
- missing = model.Artist{ID: "m1", Name: "Missing", OrderArtistName: "missing"}
- Expect(repo.Put(&missing)).To(Succeed())
+ BeforeEach(func() {
raw = repo.(*artistRepository)
- _, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missing.ID}))
+ missingArtist = model.Artist{ID: "missing_test", Name: "Missing Artist", OrderArtistName: "missing artist"}
+
+ // Create and mark as missing
+ err := createArtistWithLibrary(repo, &missingArtist, 1)
Expect(err).ToNot(HaveOccurred())
- }
- removeMissing := func() {
- if raw != nil {
- _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missing.ID}))
+ _, err = raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missingArtist.ID}))
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ AfterEach(func() {
+ _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID}))
+ })
+
+ It("missing artists are never returned by search", func() {
+ // Should see missing artist in GetAll by default for admin users
+ artists, err := repo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(artists).To(HaveLen(5)) // Including the missing artist
+
+ // Search never returns missing artists (hardcoded behavior)
+ results, err := repo.Search("Missing Artist", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+ })
+ })
+
+ Context("Regular User Operations", func() {
+ var restrictedRepo model.ArtistRepository
+ var unauthorizedUser model.User
+
+ BeforeEach(func() {
+ // Create a user without access to any libraries
+ unauthorizedUser = model.User{ID: "restricted_user", UserName: "restricted", Name: "Restricted User", Email: "restricted@test.com", IsAdmin: false}
+
+ // Create repository context for the unauthorized user
+ ctx := GinkgoT().Context()
+ ctx = request.WithUser(ctx, unauthorizedUser)
+ restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder())
+ })
+
+ Describe("Library Access Restrictions", func() {
+ It("CountAll returns 0 for users without library access", func() {
+ count, err := restrictedRepo.CountAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(Equal(int64(0)))
+ })
+
+ It("GetAll returns empty list for users without library access", func() {
+ artists, err := restrictedRepo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(artists).To(BeEmpty())
+ })
+
+ It("Exists returns false for existing artists when user has no library access", func() {
+ // These artists exist in the DB but the user has no access to them
+ exists, err := restrictedRepo.Exists(artistBeatles.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(exists).To(BeFalse())
+
+ exists, err = restrictedRepo.Exists(artistKraftwerk.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(exists).To(BeFalse())
+ })
+
+ It("Get returns ErrNotFound for existing artists when user has no library access", func() {
+ _, err := restrictedRepo.Get(artistBeatles.ID)
+ Expect(err).To(Equal(model.ErrNotFound))
+
+ _, err = restrictedRepo.Get(artistKraftwerk.ID)
+ Expect(err).To(Equal(model.ErrNotFound))
+ })
+
+ It("Search returns empty results for users without library access", func() {
+ results, err := restrictedRepo.Search("Beatles", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+
+ results, err = restrictedRepo.Search("Kraftwerk", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+
+ It("GetIndex returns empty index for users without library access", func() {
+ idx, err := restrictedRepo.GetIndex(false, []int{1})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(idx).To(HaveLen(0))
+ })
+ })
+
+ Context("when user gains library access", func() {
+ BeforeEach(func() {
+ ctx := GinkgoT().Context()
+ // Give the user access to library 1
+ ur := NewUserRepository(request.WithUser(ctx, adminUser), GetDBXBuilder())
+
+ // First create the user if not exists
+ err := ur.Put(&unauthorizedUser)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Then add library access
+ err = ur.SetUserLibraries(unauthorizedUser.ID, []int{1})
+ Expect(err).ToNot(HaveOccurred())
+
+ // Update the user object with the libraries to simulate middleware behavior
+ libraries, err := ur.GetUserLibraries(unauthorizedUser.ID)
+ Expect(err).ToNot(HaveOccurred())
+ unauthorizedUser.Libraries = libraries
+
+ // Recreate repository context with updated user
+ ctx = request.WithUser(ctx, unauthorizedUser)
+ restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder())
+ })
+
+ AfterEach(func() {
+ // Clean up: remove the user's library access
+ ur := NewUserRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
+ _ = ur.SetUserLibraries(unauthorizedUser.ID, []int{})
+ })
+
+ It("CountAll returns correct count after gaining access", func() {
+ count, err := restrictedRepo.CountAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(Equal(int64(4))) // Beatles, Kraftwerk, Seatbelts, and The Roots
+ })
+
+ It("GetAll returns artists after gaining access", func() {
+ artists, err := restrictedRepo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(artists).To(HaveLen(4))
+
+ var names []string
+ for _, artist := range artists {
+ names = append(names, artist.Name)
}
- }
-
- Context("regular user", func() {
- BeforeEach(func() {
- ctx := log.NewContext(context.TODO())
- ctx = request.WithUser(ctx, model.User{ID: "u1"})
- repo = NewArtistRepository(ctx, GetDBXBuilder())
- insertMissing()
- })
-
- AfterEach(func() { removeMissing() })
-
- It("does not return missing artist in GetAll", func() {
- artists, err := repo.GetAll(model.QueryOptions{Filters: squirrel.Eq{"artist.missing": false}})
- Expect(err).ToNot(HaveOccurred())
- Expect(artists).To(HaveLen(2))
- })
-
- It("does not return missing artist in Search", func() {
- res, err := repo.Search("missing", 0, 10, false)
- Expect(err).ToNot(HaveOccurred())
- Expect(res).To(BeEmpty())
- })
-
- It("does not return missing artist in GetIndex", func() {
- idx, err := repo.GetIndex(false)
- Expect(err).ToNot(HaveOccurred())
- // Only 2 artists should be present
- total := 0
- for _, ix := range idx {
- total += len(ix.Artists)
- }
- Expect(total).To(Equal(2))
- })
+ Expect(names).To(ContainElements("The Beatles", "Kraftwerk", "シートベルツ", "The Roots"))
})
- Context("admin user", func() {
- BeforeEach(func() {
- ctx := log.NewContext(context.TODO())
- ctx = request.WithUser(ctx, model.User{ID: "admin", IsAdmin: true})
- repo = NewArtistRepository(ctx, GetDBXBuilder())
- insertMissing()
- })
+ It("Exists returns true for accessible artists", func() {
+ exists, err := restrictedRepo.Exists(artistBeatles.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(exists).To(BeTrue())
- AfterEach(func() { removeMissing() })
-
- It("returns missing artist in GetAll", func() {
- artists, err := repo.GetAll()
- Expect(err).ToNot(HaveOccurred())
- Expect(artists).To(HaveLen(3))
- })
-
- It("returns missing artist in Search", func() {
- res, err := repo.Search("missing", 0, 10, true)
- Expect(err).ToNot(HaveOccurred())
- Expect(res).To(HaveLen(1))
- })
-
- It("returns missing artist in GetIndex when included", func() {
- idx, err := repo.GetIndex(true)
- Expect(err).ToNot(HaveOccurred())
- total := 0
- for _, ix := range idx {
- total += len(ix.Artists)
- }
- Expect(total).To(Equal(3))
- })
+ exists, err = restrictedRepo.Exists(artistKraftwerk.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(exists).To(BeTrue())
})
+
+ It("GetIndex returns artists with proper library filtering", func() {
+ // With valid library access, should see artists
+ idx, err := restrictedRepo.GetIndex(false, []int{1})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(idx).To(HaveLen(4))
+
+ // With non-existent library ID, should see nothing (non-admin user)
+ idx, err = restrictedRepo.GetIndex(false, []int{999})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(idx).To(HaveLen(0))
+ })
+ })
+ })
+
+ Describe("purgeEmpty", func() {
+ var repo *artistRepository
+ var tmpDir string
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ tmpDir = GinkgoT().TempDir()
+ conf.Server.DataFolder = conf.NewDir(tmpDir)
+
+ ctx := request.WithUser(GinkgoT().Context(), adminUser)
+ repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository)
+ })
+
+ // Helper to create an artist image file on disk and return its path
+ createImageFile := func(filename string) string {
+ dir := filepath.Join(tmpDir, consts.ArtworkFolder, consts.EntityArtist)
+ Expect(os.MkdirAll(dir, 0755)).To(Succeed())
+ path := filepath.Join(dir, filename)
+ Expect(os.WriteFile(path, []byte("fake image data"), 0600)).To(Succeed())
+ return path
+ }
+
+ It("removes uploaded image files for purged artists", func() {
+ // Create an orphan artist (not in album_artists) with an uploaded image
+ orphanArtist := model.Artist{ID: "orphan-with-image", Name: "Orphan Artist", UploadedImage: "orphan-with-image_Orphan_Artist.jpg"}
+ Expect(repo.Put(&orphanArtist)).To(Succeed())
+ imgPath := createImageFile("orphan-with-image_Orphan_Artist.jpg")
+
+ Expect(repo.purgeEmpty()).To(Succeed())
+
+ // Artist should be gone from DB
+ exists, err := repo.Exists("orphan-with-image")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(exists).To(BeFalse())
+
+ // Image file should be removed from disk
+ _, err = os.Stat(imgPath)
+ Expect(os.IsNotExist(err)).To(BeTrue())
+ })
+
+ It("handles missing image files gracefully", func() {
+ // Artist has UploadedImage set but no actual file on disk
+ orphanArtist := model.Artist{ID: "orphan-no-file", Name: "Ghost Image", UploadedImage: "orphan-no-file_Ghost_Image.jpg"}
+ Expect(repo.Put(&orphanArtist)).To(Succeed())
+
+ Expect(repo.purgeEmpty()).To(Succeed())
+
+ // Artist should be gone from DB
+ exists, err := repo.Exists("orphan-no-file")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(exists).To(BeFalse())
+ })
+
+ It("does not delete images for artists that are kept", func() {
+ // Create an artist with an uploaded image AND an album_artists entry so it won't be purged
+ keptArtist := model.Artist{ID: "kept-artist", Name: "Kept Artist", UploadedImage: "kept-artist_Kept_Artist.jpg"}
+ Expect(repo.Put(&keptArtist)).To(Succeed())
+ imgPath := createImageFile("kept-artist_Kept_Artist.jpg")
+
+ // Insert an album_artists record to keep this artist from being purged
+ _, err := repo.executeSQL(squirrel.Insert("album_artists").
+ SetMap(map[string]any{"album_id": "101", "artist_id": "kept-artist", "role": "artist", "sub_role": ""}))
+ Expect(err).ToNot(HaveOccurred())
+
+ DeferCleanup(func() {
+ _, _ = repo.executeSQL(squirrel.Delete("album_artists").Where(squirrel.Eq{"artist_id": "kept-artist"}))
+ _ = repo.delete(squirrel.Eq{"id": "kept-artist"})
+ })
+
+ Expect(repo.purgeEmpty()).To(Succeed())
+
+ // Artist should still exist (check directly, bypassing library filter)
+ var ids []string
+ err = repo.queryAllSlice(squirrel.Select("id").From("artist").Where(squirrel.Eq{"id": "kept-artist"}), &ids)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ids).To(HaveLen(1))
+
+ // Image file should still be on disk
+ _, err = os.Stat(imgPath)
+ Expect(err).ToNot(HaveOccurred())
})
})
})
+
+// Helper function to create an artist with proper library association.
+// This ensures test artists always have library_artist associations to avoid orphaned artists in tests.
+func createArtistWithLibrary(repo model.ArtistRepository, artist *model.Artist, libraryID int) error {
+ err := repo.Put(artist)
+ if err != nil {
+ return err
+ }
+
+ // Add the artist to the specified library
+ lr := NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
+ return lr.AddArtist(libraryID, artist.ID)
+}
diff --git a/persistence/collation_test.go b/persistence/collation_test.go
index 7e1144753..bb1276577 100644
--- a/persistence/collation_test.go
+++ b/persistence/collation_test.go
@@ -32,6 +32,7 @@ var _ = Describe("Collation", func() {
Entry("media_file.sort_title", "media_file", "sort_title"),
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
+ Entry("playlist.name", "playlist", "name"),
Entry("radio.name", "radio", "name"),
Entry("user.name", "user", "name"),
)
@@ -53,6 +54,7 @@ var _ = Describe("Collation", func() {
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
Entry("media_file.path", "media_file", "path collate nocase"),
+ Entry("playlist.name", "playlist", "name collate nocase"),
Entry("radio.name", "radio", "name collate nocase"),
Entry("user.user_name", "user", "user_name collate nocase"),
)
diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go
new file mode 100644
index 000000000..37e4ae340
--- /dev/null
+++ b/persistence/criteria_sql.go
@@ -0,0 +1,663 @@
+package persistence
+
+import (
+ "errors"
+ "fmt"
+ "maps"
+ "reflect"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/criteria"
+)
+
+type smartPlaylistJoinType int
+
+const (
+ smartPlaylistJoinNone smartPlaylistJoinType = 0
+ smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota
+ smartPlaylistJoinArtistAnnotation
+)
+
+func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool {
+ return j&other != 0
+}
+
+type smartPlaylistField struct {
+ expr string
+ order string
+ joinType smartPlaylistJoinType
+}
+
+type smartPlaylistCriteria struct {
+ criteria.Criteria
+ owner model.User
+}
+
+func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria {
+ cSQL := smartPlaylistCriteria{Criteria: c}
+ for _, opt := range opts {
+ opt(&cSQL)
+ }
+ return cSQL
+}
+
+func withSmartPlaylistOwner(owner model.User) func(*smartPlaylistCriteria) {
+ return func(c *smartPlaylistCriteria) {
+ c.owner = owner
+ }
+}
+
+var smartPlaylistFields = map[string]smartPlaylistField{
+ "title": {expr: "media_file.title"},
+ "album": {expr: "media_file.album"},
+ "hascoverart": {expr: "media_file.has_cover_art"},
+ "tracknumber": {expr: "media_file.track_number"},
+ "discnumber": {expr: "media_file.disc_number"},
+ "year": {expr: "media_file.year"},
+ "date": {expr: "media_file.date"},
+ "originalyear": {expr: "media_file.original_year"},
+ "originaldate": {expr: "media_file.original_date"},
+ "releaseyear": {expr: "media_file.release_year"},
+ "releasedate": {expr: "media_file.release_date"},
+ "size": {expr: "media_file.size"},
+ "compilation": {expr: "media_file.compilation"},
+ "missing": {expr: "media_file.missing"},
+ "explicitstatus": {expr: "media_file.explicit_status"},
+ "dateadded": {expr: "media_file.created_at"},
+ "datemodified": {expr: "media_file.updated_at"},
+ "discsubtitle": {expr: "media_file.disc_subtitle"},
+ "comment": {expr: "media_file.comment"},
+ "lyrics": {expr: "media_file.lyrics"},
+ "sorttitle": {expr: "media_file.sort_title"},
+ "sortalbum": {expr: "media_file.sort_album_name"},
+ "sortartist": {expr: "media_file.sort_artist_name"},
+ "sortalbumartist": {expr: "media_file.sort_album_artist_name"},
+ "albumcomment": {expr: "media_file.mbz_album_comment"},
+ "catalognumber": {expr: "media_file.catalog_num"},
+ "filepath": {expr: "media_file.path"},
+ "filetype": {expr: "media_file.suffix"},
+ "codec": {expr: "media_file.codec"},
+ "duration": {expr: "media_file.duration"},
+ "bitrate": {expr: "media_file.bit_rate"},
+ "bitdepth": {expr: "media_file.bit_depth"},
+ "samplerate": {expr: "media_file.sample_rate"},
+ "bpm": {expr: "media_file.bpm"},
+ "channels": {expr: "media_file.channels"},
+ "loved": {expr: "COALESCE(annotation.starred, false)"},
+ "dateloved": {expr: "annotation.starred_at"},
+ "lastplayed": {expr: "annotation.play_date"},
+ "daterated": {expr: "annotation.rated_at"},
+ "playcount": {expr: "COALESCE(annotation.play_count, 0)"},
+ "rating": {expr: "COALESCE(annotation.rating, 0)"},
+ "averagerating": {expr: "media_file.average_rating"},
+ "albumrating": {expr: "COALESCE(album_annotation.rating, 0)", joinType: smartPlaylistJoinAlbumAnnotation},
+ "albumloved": {expr: "COALESCE(album_annotation.starred, false)", joinType: smartPlaylistJoinAlbumAnnotation},
+ "albumplaycount": {expr: "COALESCE(album_annotation.play_count, 0)", joinType: smartPlaylistJoinAlbumAnnotation},
+ "albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation},
+ "albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation},
+ "albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation},
+ "artistrating": {expr: "COALESCE(artist_annotation.rating, 0)", joinType: smartPlaylistJoinArtistAnnotation},
+ "artistloved": {expr: "COALESCE(artist_annotation.starred, false)", joinType: smartPlaylistJoinArtistAnnotation},
+ "artistplaycount": {expr: "COALESCE(artist_annotation.play_count, 0)", joinType: smartPlaylistJoinArtistAnnotation},
+ "artistlastplayed": {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation},
+ "artistdateloved": {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation},
+ "artistdaterated": {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation},
+ "mbz_album_id": {expr: "media_file.mbz_album_id"},
+ "mbz_album_artist_id": {expr: "media_file.mbz_album_artist_id"},
+ "mbz_artist_id": {expr: "media_file.mbz_artist_id"},
+ "mbz_recording_id": {expr: "media_file.mbz_recording_id"},
+ "mbz_release_track_id": {expr: "media_file.mbz_release_track_id"},
+ "mbz_release_group_id": {expr: "media_file.mbz_release_group_id"},
+ "rgalbumgain": {expr: "media_file.rg_album_gain"},
+ "rgalbumpeak": {expr: "media_file.rg_album_peak"},
+ "rgtrackgain": {expr: "media_file.rg_track_gain"},
+ "rgtrackpeak": {expr: "media_file.rg_track_peak"},
+ "library_id": {expr: "media_file.library_id"},
+ "random": {order: "random()"},
+}
+
+func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) {
+ if c.Criteria.Expression == nil {
+ return squirrel.Expr("1 = 1"), nil
+ }
+ return c.exprSQL(c.Criteria.Expression)
+}
+
+func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) {
+ switch e := expr.(type) {
+ case criteria.All:
+ and := squirrel.And{}
+ for _, child := range e {
+ cond, err := c.exprSQL(child)
+ if err != nil {
+ return nil, err
+ }
+ and = append(and, cond)
+ }
+ return and, nil
+ case criteria.Any:
+ or := squirrel.Or{}
+ for _, child := range e {
+ cond, err := c.exprSQL(child)
+ if err != nil {
+ return nil, err
+ }
+ or = append(or, cond)
+ }
+ return mergeJsonConds(or), nil
+ case criteria.Is:
+ return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
+ return squirrel.Eq(fields)
+ }, false)
+ case criteria.IsNot:
+ return isNotExpr(e)
+ case criteria.Gt:
+ return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
+ return squirrel.Gt(fields)
+ }, false)
+ case criteria.Lt:
+ return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
+ return squirrel.Lt(fields)
+ }, false)
+ case criteria.Before:
+ return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
+ return squirrel.Lt(fields)
+ }, false)
+ case criteria.After:
+ return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
+ return squirrel.Gt(fields)
+ }, false)
+ case criteria.Contains:
+ return likeExpr(e, "%%%v%%", false)
+ case criteria.NotContains:
+ return likeExpr(e, "%%%v%%", true)
+ case criteria.StartsWith:
+ return likeExpr(e, "%v%%", false)
+ case criteria.EndsWith:
+ return likeExpr(e, "%%%v", false)
+ case criteria.InTheRange:
+ return rangeExpr(e)
+ case criteria.InTheLast:
+ return periodExpr(e, false)
+ case criteria.NotInTheLast:
+ return periodExpr(e, true)
+ case criteria.InPlaylist:
+ return c.inList(e, false)
+ case criteria.NotInPlaylist:
+ return c.inList(e, true)
+ case criteria.IsMissing:
+ return missingExpr(e, true)
+ case criteria.IsPresent:
+ return missingExpr(e, false)
+ default:
+ return nil, fmt.Errorf("unknown criteria expression type %T", expr)
+ }
+}
+
+func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) {
+ if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
+ return jsonExpr(info, squirrel.Eq{"value": value}, true), nil
+ }
+ fields, err := sqlFields(values)
+ if err != nil {
+ return nil, err
+ }
+ return squirrel.NotEq(fields), nil
+}
+
+func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) {
+ field, value, info, ok := singleField(values)
+ if !ok {
+ if len(values) != 1 {
+ return nil, fmt.Errorf("invalid field in criteria: isMissing/isPresent requires exactly one field")
+ }
+ return nil, fmt.Errorf("invalid field in criteria: %s", field)
+ }
+ if !info.IsTag && !info.IsRole {
+ return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field)
+ }
+
+ b, ok := value.(bool)
+ if !ok {
+ return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value)
+ }
+ negate := checkAbsence == b
+ return jsonExpr(info, nil, negate), nil
+}
+
+func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) {
+ if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
+ return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil
+ }
+ fields, err := sqlFields(values)
+ if err != nil {
+ return nil, err
+ }
+ return makeCond(fields), nil
+}
+
+func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqlizer, error) {
+ if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
+ return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil
+ }
+ fields, err := sqlFields(values)
+ if err != nil {
+ return nil, err
+ }
+ if negate {
+ lk := squirrel.NotLike{}
+ for field, value := range fields {
+ lk[field] = fmt.Sprintf(pattern, value)
+ }
+ return lk, nil
+ }
+ lk := squirrel.Like{}
+ for field, value := range fields {
+ lk[field] = fmt.Sprintf(pattern, value)
+ }
+ return lk, nil
+}
+
+func rangeExpr(values map[string]any) (squirrel.Sqlizer, error) {
+ fields, err := sqlFields(values)
+ if err != nil {
+ return nil, err
+ }
+ and := squirrel.And{}
+ for field, value := range fields {
+ s := reflect.ValueOf(value)
+ if s.Kind() != reflect.Slice || s.Len() != 2 {
+ return nil, fmt.Errorf("invalid range for 'in' operator: %s", value)
+ }
+ and = append(and,
+ squirrel.GtOrEq{field: s.Index(0).Interface()},
+ squirrel.LtOrEq{field: s.Index(1).Interface()},
+ )
+ }
+ return and, nil
+}
+
+func periodExpr(values map[string]any, negate bool) (squirrel.Sqlizer, error) {
+ fields, err := sqlFields(values)
+ if err != nil {
+ return nil, err
+ }
+ var field string
+ var value any
+ for f, v := range fields {
+ field, value = f, v
+ break
+ }
+ days, err := strconv.ParseInt(fmt.Sprintf("%v", value), 10, 64)
+ if err != nil {
+ return nil, err
+ }
+ firstDate := startOfPeriod(days, time.Now())
+ if negate {
+ return squirrel.Or{
+ squirrel.Lt{field: firstDate},
+ squirrel.Eq{field: nil},
+ }, nil
+ }
+ return squirrel.Gt{field: firstDate}, nil
+}
+
+func startOfPeriod(numDays int64, from time.Time) string {
+ return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
+}
+
+func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) {
+ playlistID, ok := values["id"].(string)
+ if !ok {
+ return nil, errors.New("playlist id not given")
+ }
+ filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}}
+ if !c.owner.IsAdmin {
+ if c.owner.ID == "" {
+ filters = append(filters, squirrel.Eq{"playlist.public": 1})
+ } else {
+ filters = append(filters, squirrel.Or{
+ squirrel.Eq{"playlist.public": 1},
+ squirrel.Eq{"playlist.owner_id": c.owner.ID},
+ })
+ }
+ }
+ subQuery := squirrel.Select("media_file_id").
+ From("playlist_tracks pl").
+ LeftJoin("playlist on pl.playlist_id = playlist.id").
+ Where(filters)
+ subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
+ if err != nil {
+ return nil, err
+ }
+ if negate {
+ return squirrel.Expr("media_file.id NOT IN ("+subSQL+")", subArgs...), nil
+ }
+ return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil
+}
+
+func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
+ if info.IsRole {
+ return roleCond{role: info.Name(), cond: cond, not: negate}
+ }
+ return tagCond{tag: info.Name(), numeric: info.Numeric, cond: cond, not: negate}
+}
+
+type tagCond struct {
+ tag string
+ numeric bool
+ cond squirrel.Sqlizer
+ not bool
+}
+
+func (e tagCond) ToSql() (string, []any, error) {
+ var cond string
+ var args []any
+ var err error
+ if e.cond != nil {
+ cond, args, err = e.cond.ToSql()
+ if e.numeric {
+ cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
+ }
+ cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond)
+ } else {
+ cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value')", e.tag)
+ }
+ if e.not {
+ cond = "not " + cond
+ }
+ return cond, args, err
+}
+
+type roleCond struct {
+ role string
+ cond squirrel.Sqlizer
+ not bool
+}
+
+func (e roleCond) ToSql() (string, []any, error) {
+ var cond string
+ var args []any
+ if e.cond != nil {
+ innerSQL, innerArgs, err := roleCondSQL(e.cond)
+ if err != nil {
+ return "", nil, err
+ }
+ cond = roleExistsSQL(innerSQL)
+ args = append([]any{e.role}, innerArgs...)
+ } else {
+ cond = "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)"
+ args = []any{e.role}
+ }
+ if e.not {
+ cond = "not " + cond
+ }
+ return cond, args, nil
+}
+
+// roleCondSQL extracts SQL from a squirrel condition and rewrites the placeholder column name.
+func roleCondSQL(cond squirrel.Sqlizer) (string, []any, error) {
+ sql, args, err := cond.ToSql()
+ if err != nil {
+ return "", nil, err
+ }
+ return strings.ReplaceAll(sql, "value", "artist.name"), args, nil
+}
+
+// roleExistsSQL wraps a condition fragment in the standard role EXISTS subquery.
+func roleExistsSQL(innerCond string) string {
+ return fmt.Sprintf("exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id "+
+ "where mfa.media_file_id = media_file.id and mfa.role = ? and %s)", innerCond)
+}
+
+// jsonCondBatchSize limits how many conditions are ORed inside a single EXISTS subquery
+// to stay within SQLite's expression tree depth limit (max 1000). The EXISTS wrapper
+// consumes ~4 levels; each ORed condition adds 1 level. Empirically, 496 is the maximum.
+const jsonCondBatchSize = 350
+
+// mergeJsonConds collapses multiple non-negated roleCond or tagCond entries for the same
+// field within an OR group into batched EXISTS subqueries with the conditions ORed inside.
+// This turns N separate correlated subqueries into ceil(N/batchSize), dramatically
+// improving performance for smart playlists with many patterns.
+func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer {
+ type condEntry struct {
+ index int
+ cond squirrel.Sqlizer
+ }
+ type group struct {
+ entries []condEntry
+ isRole bool
+ numeric bool
+ tag string
+ }
+ groups := make(map[string]*group)
+ for i, s := range or {
+ switch c := s.(type) {
+ case roleCond:
+ if c.not || c.cond == nil {
+ continue
+ }
+ g, exists := groups["role:"+c.role]
+ if !exists {
+ g = &group{isRole: true}
+ groups["role:"+c.role] = g
+ }
+ g.entries = append(g.entries, condEntry{index: i, cond: c.cond})
+ case tagCond:
+ if c.not || c.cond == nil {
+ continue
+ }
+ g, exists := groups["tag:"+c.tag]
+ if !exists {
+ g = &group{tag: c.tag, numeric: c.numeric}
+ groups["tag:"+c.tag] = g
+ }
+ g.entries = append(g.entries, condEntry{index: i, cond: c.cond})
+ }
+ }
+
+ merged := false
+ remove := make(map[int]bool)
+ var additions []squirrel.Sqlizer
+ for _, key := range slices.Sorted(maps.Keys(groups)) {
+ g := groups[key]
+ if len(g.entries) < 2 {
+ continue
+ }
+ merged = true
+ for _, e := range g.entries {
+ remove[e.index] = true
+ }
+ conds := make([]squirrel.Sqlizer, len(g.entries))
+ for i, e := range g.entries {
+ conds[i] = e.cond
+ }
+ if g.isRole {
+ role := key[len("role:"):]
+ for batch := range slices.Chunk(conds, jsonCondBatchSize) {
+ additions = append(additions, roleCondGroup{role: role, conds: batch})
+ }
+ } else {
+ for batch := range slices.Chunk(conds, jsonCondBatchSize) {
+ additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch})
+ }
+ }
+ }
+
+ if !merged {
+ return or
+ }
+
+ result := make(squirrel.Or, 0, len(or)-len(remove)+len(additions))
+ for i, s := range or {
+ if !remove[i] {
+ result = append(result, s)
+ }
+ }
+ result = append(result, additions...)
+ return result
+}
+
+// roleCondGroup represents multiple role conditions for the same role, merged into
+// a single EXISTS subquery for performance.
+type roleCondGroup struct {
+ role string
+ conds []squirrel.Sqlizer
+}
+
+func (g roleCondGroup) ToSql() (string, []any, error) {
+ innerParts := make([]string, 0, len(g.conds))
+ allArgs := []any{g.role}
+ for _, c := range g.conds {
+ part, args, err := roleCondSQL(c)
+ if err != nil {
+ return "", nil, err
+ }
+ innerParts = append(innerParts, part)
+ allArgs = append(allArgs, args...)
+ }
+ cond := roleExistsSQL("(" + strings.Join(innerParts, " OR ") + ")")
+ return cond, allArgs, nil
+}
+
+// tagCondGroup represents multiple tag conditions for the same tag, merged into
+// a single EXISTS subquery for performance.
+type tagCondGroup struct {
+ tag string
+ numeric bool
+ conds []squirrel.Sqlizer
+}
+
+func (g tagCondGroup) ToSql() (string, []any, error) {
+ innerParts := make([]string, 0, len(g.conds))
+ var allArgs []any
+ for _, c := range g.conds {
+ part, args, err := c.ToSql()
+ if err != nil {
+ return "", nil, err
+ }
+ if g.numeric {
+ part = strings.ReplaceAll(part, "value", "CAST(value AS REAL)")
+ }
+ innerParts = append(innerParts, part)
+ allArgs = append(allArgs, args...)
+ }
+ cond := fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and (%s))",
+ g.tag, strings.Join(innerParts, " OR "))
+ return cond, allArgs, nil
+}
+
+func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) {
+ if len(values) != 1 {
+ return "", nil, criteria.FieldInfo{}, false
+ }
+ for field, value := range values {
+ info, ok := criteria.LookupField(field)
+ return field, value, info, ok
+ }
+ return "", nil, criteria.FieldInfo{}, false
+}
+
+func sqlFields(values map[string]any) (map[string]any, error) {
+ fields := make(map[string]any, len(values))
+ for field, value := range values {
+ info, ok := criteria.LookupField(field)
+ if !ok {
+ return nil, fmt.Errorf("invalid field in criteria: %s", field)
+ }
+ if info.IsTag || info.IsRole {
+ return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field)
+ }
+ sqlField, ok := fieldExpr(info.Name())
+ if !ok || sqlField == "" {
+ return nil, fmt.Errorf("invalid field in criteria: %s", field)
+ }
+ fields[sqlField] = value
+ }
+ return fields, nil
+}
+
+func fieldExpr(name string) (string, bool) {
+ field, ok := smartPlaylistFields[strings.ToLower(name)]
+ return field.expr, ok
+}
+
+func fieldJoinType(name string) smartPlaylistJoinType {
+ info, ok := criteria.LookupField(name)
+ if !ok {
+ return smartPlaylistJoinNone
+ }
+ field, ok := smartPlaylistFields[info.Name()]
+ if !ok {
+ return smartPlaylistJoinNone
+ }
+ return field.joinType
+}
+
+func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType {
+ var joins smartPlaylistJoinType
+ _ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error {
+ for field := range criteria.Fields(expr) {
+ joins |= fieldJoinType(field)
+ }
+ return nil
+ })
+ return joins
+}
+
+func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType {
+ joins := c.ExpressionJoins()
+ for _, name := range c.Criteria.SortFieldNames() {
+ joins |= fieldJoinType(name)
+ }
+ return joins
+}
+
+func (c smartPlaylistCriteria) OrderBy() string {
+ sortFields := c.Criteria.OrderByFields()
+ parts := make([]string, 0, len(sortFields))
+ for _, sf := range sortFields {
+ mapped, ok := sortExpr(sf.Field)
+ if !ok {
+ continue
+ }
+ dir := "asc"
+ if sf.Desc {
+ dir = "desc"
+ }
+ parts = append(parts, mapped+" "+dir)
+ }
+ return strings.Join(parts, ", ")
+}
+
+func sortExpr(sortField string) (string, bool) {
+ info, ok := criteria.LookupField(sortField)
+ if !ok {
+ return "", false
+ }
+ if field, ok := smartPlaylistFields[info.Name()]; ok && field.order != "" {
+ return field.order, true
+ }
+ var mapped string
+ switch {
+ case info.IsTag:
+ mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name() + "[0].value'), '')"
+ case info.IsRole:
+ mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name() + "[0].name'), '')"
+ default:
+ field, ok := smartPlaylistFields[info.Name()]
+ if !ok || field.expr == "" {
+ return "", false
+ }
+ mapped = field.expr
+ }
+ if info.Numeric {
+ mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
+ }
+ return mapped, true
+}
diff --git a/persistence/criteria_sql_benchmark_test.go b/persistence/criteria_sql_benchmark_test.go
new file mode 100644
index 000000000..d901e9eda
--- /dev/null
+++ b/persistence/criteria_sql_benchmark_test.go
@@ -0,0 +1,236 @@
+package persistence
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/db"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/criteria"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/pocketbase/dbx"
+)
+
+const (
+ benchNumArtists = 1_000
+ benchNumTracks = 40_000
+ benchNumPatterns = 500
+ benchArtistsPerTrack = 3
+)
+
+// BenchmarkSmartPlaylistRole compares role-based smart playlist query performance
+// between the current implementation (merged join-table via criteria pipeline) and
+// the old baseline (unmerged json_tree subqueries).
+func BenchmarkSmartPlaylistRole(b *testing.B) {
+ configtest.SetupConfig()
+ tmpDir := b.TempDir()
+ conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl.db")
+ cleanup := db.Init(context.Background())
+ defer cleanup()
+ log.SetLevel(log.LevelFatal)
+
+ conn := dbx.NewFromDB(db.Db(), db.Dialect)
+ ctx := log.NewContext(context.Background())
+ user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true}
+ ctx = request.WithUser(ctx, user)
+
+ setupBenchData(b, ctx, conn, user)
+ criteria.AddRoles([]string{"artist"})
+
+ // Build the criteria expression: 500 "contains artist" patterns in an OR group
+ anyExprs := make(criteria.Any, benchNumPatterns)
+ for i := range benchNumPatterns {
+ anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist %04d", i)}
+ }
+ expr := criteria.Criteria{Expression: anyExprs, Sort: "title", Limit: 500}
+
+ b.Run("Current", func(b *testing.B) {
+ benchmarkCriteriaPipeline(b, ctx, expr)
+ })
+ b.Run("Baseline_UnmergedJSONTree", func(b *testing.B) {
+ benchmarkUnmergedJSONTree(b, ctx)
+ })
+}
+
+// benchmarkCriteriaPipeline runs the criteria through the actual production code path:
+// newSmartPlaylistCriteria → Where() → ToSql(), then executes the resulting query.
+func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.Criteria) {
+ b.Helper()
+
+ cSQL := newSmartPlaylistCriteria(expr)
+
+ // Build the full query matching buildSmartPlaylistQuery + addCriteria
+ sq := squirrel.Select("media_file.id").From("media_file")
+ cond, err := cSQL.Where()
+ if err != nil {
+ b.Fatal(err)
+ }
+ sq = sq.Where(cond)
+ if expr.Limit > 0 {
+ sq = sq.Limit(uint64(expr.Limit))
+ }
+ if order := cSQL.OrderBy(); order != "" {
+ sq = sq.OrderBy(order)
+ }
+
+ query, args, err := sq.PlaceholderFormat(squirrel.Question).ToSql()
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ runBenchQuery(b, ctx, query, args)
+}
+
+// benchmarkUnmergedJSONTree builds the old-style query with N separate json_tree EXISTS
+// subqueries (the pre-optimization baseline).
+func benchmarkUnmergedJSONTree(b *testing.B, ctx context.Context) {
+ b.Helper()
+
+ var sb strings.Builder
+ sb.WriteString("SELECT media_file.id FROM media_file WHERE (")
+ args := make([]any, 0, benchNumPatterns)
+ for i := range benchNumPatterns {
+ if i > 0 {
+ sb.WriteString(" OR ")
+ }
+ sb.WriteString("exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)")
+ args = append(args, fmt.Sprintf("%%Artist %04d%%", i))
+ }
+ sb.WriteString(") ORDER BY media_file.title LIMIT 500")
+
+ runBenchQuery(b, ctx, sb.String(), args)
+}
+
+func runBenchQuery(b *testing.B, ctx context.Context, query string, args []any) {
+ b.Helper()
+ sqlDB := db.Db()
+ b.ResetTimer()
+ for range b.N {
+ rows, err := sqlDB.QueryContext(ctx, query, args...)
+ if err != nil {
+ b.Fatal(err)
+ }
+ for rows.Next() {
+ var id string
+ _ = rows.Scan(&id)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func setupBenchData(b *testing.B, ctx context.Context, conn *dbx.DB, user model.User) {
+ b.Helper()
+
+ sqlDB := db.Db()
+
+ ur := NewUserRepository(ctx, conn)
+ if err := ur.Put(&user); err != nil {
+ b.Fatal(err)
+ }
+ if err := ur.SetUserLibraries(user.ID, []int{1}); err != nil {
+ b.Fatal(err)
+ }
+
+ tx, err := sqlDB.Begin()
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ // Create artists
+ artistStmt, err := tx.Prepare("INSERT INTO artist (id, name) VALUES (?, ?)")
+ if err != nil {
+ b.Fatal(err)
+ }
+ for i := range benchNumArtists {
+ if _, err := artistStmt.Exec(fmt.Sprintf("artist-%04d", i), fmt.Sprintf("Artist %04d", i)); err != nil {
+ b.Fatal(err)
+ }
+ }
+ artistStmt.Close()
+
+ // Ensure folder exists
+ folderID := "bench-folder"
+ if _, err := tx.Exec("INSERT OR IGNORE INTO folder (id, library_id, path, name, parent_id) VALUES (?, 1, '.', '.', '')", folderID); err != nil {
+ b.Fatal(err)
+ }
+
+ // Create media files with participants JSON, cycling through artists
+ mfStmt, err := tx.Prepare(`INSERT INTO media_file (id, path, title, album, artist, artist_id, album_id,
+ duration, year, size, suffix, tags, participants, lyrics, library_id, folder_id, pid, codec)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ // Populate media_file_artists join table
+ mfaStmt, err := tx.Prepare("INSERT INTO media_file_artists (media_file_id, artist_id, role, sub_role) VALUES (?, ?, ?, ?)")
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ for i := range benchNumTracks {
+ trackID := fmt.Sprintf("track-%05d", i)
+
+ // Assign benchArtistsPerTrack artists to each track, cycling through the pool
+ artistEntries := make([]map[string]string, benchArtistsPerTrack)
+ for a := range benchArtistsPerTrack {
+ artistIdx := (i + a) % benchNumArtists
+ artistEntries[a] = map[string]string{
+ "id": fmt.Sprintf("artist-%04d", artistIdx),
+ "name": fmt.Sprintf("Artist %04d", artistIdx),
+ }
+ }
+ primaryArtistIdx := i % benchNumArtists
+ primaryArtistID := fmt.Sprintf("artist-%04d", primaryArtistIdx)
+ primaryArtistName := fmt.Sprintf("Artist %04d", primaryArtistIdx)
+
+ participants := map[string][]map[string]string{"artist": artistEntries}
+ participantsJSON, _ := json.Marshal(participants)
+
+ if _, err := mfStmt.Exec(
+ trackID,
+ fmt.Sprintf("music/%s.mp3", trackID),
+ fmt.Sprintf("Track %05d", i),
+ "Bench Album",
+ primaryArtistName,
+ primaryArtistID,
+ "bench-album",
+ 180, 2024, 5000000, "mp3",
+ "{}",
+ string(participantsJSON),
+ "[]",
+ 1, folderID, trackID, "mp3",
+ ); err != nil {
+ b.Fatal(err)
+ }
+
+ // Insert all artist associations into the join table
+ for a := range benchArtistsPerTrack {
+ artistIdx := (i + a) % benchNumArtists
+ artistID := fmt.Sprintf("artist-%04d", artistIdx)
+ if _, err := mfaStmt.Exec(trackID, artistID, "artist", ""); err != nil {
+ b.Fatal(err)
+ }
+ }
+ }
+ mfStmt.Close()
+ mfaStmt.Close()
+
+ if err := tx.Commit(); err != nil {
+ b.Fatal(err)
+ }
+
+ b.Logf("Setup complete: %d artists, %d tracks (%d artists/track), %d patterns",
+ benchNumArtists, benchNumTracks, benchArtistsPerTrack, benchNumPatterns)
+}
diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go
new file mode 100644
index 000000000..5c8909e1c
--- /dev/null
+++ b/persistence/criteria_sql_test.go
@@ -0,0 +1,381 @@
+package persistence
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/criteria"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Smart playlist criteria SQL", func() {
+ BeforeEach(func() {
+ criteria.AddRoles([]string{"artist", "composer", "producer"})
+ criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate"})
+ criteria.AddNumericTags([]string{"rate"})
+ })
+
+ DescribeTable("expressions",
+ func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) {
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(Equal(expectedSQL))
+ Expect(args).To(HaveExactElements(expectedArgs...))
+ },
+ Entry("all group",
+ criteria.All{criteria.Contains{"title": "love"}, criteria.Gt{"rating": 3}},
+ "(media_file.title LIKE ? AND COALESCE(annotation.rating, 0) > ?)", "%love%", 3),
+ Entry("any group",
+ criteria.Any{criteria.Is{"title": "Low Rider"}, criteria.Is{"album": "Best Of"}},
+ "(media_file.title = ? OR media_file.album = ?)", "Low Rider", "Best Of"),
+ Entry("is string", criteria.Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
+ Entry("is bool", criteria.Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
+ Entry("is numeric list", criteria.Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2),
+ Entry("is not", criteria.IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
+ Entry("gt", criteria.Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10),
+ Entry("lt", criteria.Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10),
+ Entry("contains", criteria.Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
+ Entry("not contains", criteria.NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"),
+ Entry("starts with", criteria.StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
+ Entry("ends with", criteria.EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
+ Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
+ Entry("before", criteria.Before{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
+ Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
+ Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
+ Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
+ Entry("album annotation", criteria.Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3),
+ Entry("artist annotation", criteria.Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true),
+ Entry("tag is", criteria.Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
+ Entry("tag is not", criteria.IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
+ Entry("tag contains", criteria.Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
+ Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
+ Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6),
+ Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"),
+ Entry("field alias via tag registration", criteria.Is{"recordingdate": "2024-01-01"}, "media_file.date = ?", "2024-01-01"),
+ Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name = ?)", "artist", "u2"),
+ Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "composer", "%Lennon%"),
+ Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "artist", "%u2%"),
+ // ReplayGain fields
+ Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0),
+ Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0),
+ Entry("rgTrackPeak lt", criteria.Lt{"rgTrackPeak": 1.0}, "media_file.rg_track_peak < ?", 1.0),
+ // isMissing — tags
+ Entry("isMissing tag [true]", criteria.IsMissing{"genre": true},
+ "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
+ Entry("isMissing tag [false]", criteria.IsMissing{"genre": false},
+ "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
+ // isMissing — roles
+ Entry("isMissing role [true]", criteria.IsMissing{"artist": true},
+ "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"),
+ Entry("isMissing role [false]", criteria.IsMissing{"artist": false},
+ "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"),
+ // isPresent — tags
+ Entry("isPresent tag [true]", criteria.IsPresent{"genre": true},
+ "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
+ Entry("isPresent tag [false]", criteria.IsPresent{"genre": false},
+ "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
+ // isPresent — roles
+ Entry("isPresent role [true]", criteria.IsPresent{"composer": true},
+ "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"),
+ Entry("isPresent role [false]", criteria.IsPresent{"composer": false},
+ "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"),
+ )
+
+ Describe("playlist permissions", func() {
+ It("allows public or same-owner playlist references for regular users", func() {
+ sqlizer, err := newSmartPlaylistCriteria(
+ criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
+ withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}),
+ ).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(Equal("media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND (playlist.public = ? OR playlist.owner_id = ?)))"))
+ Expect(args).To(HaveExactElements("deadbeef-dead-beef", 1, "owner-id"))
+ })
+
+ It("allows all playlist references for admins", func() {
+ sqlizer, err := newSmartPlaylistCriteria(
+ criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
+ withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}),
+ ).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(Equal("media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ?))"))
+ Expect(args).To(HaveExactElements("deadbeef-dead-beef"))
+ })
+ })
+
+ It("builds relative date expressions", func() {
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(Equal("annotation.play_date > ?"))
+ Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now())))
+ })
+
+ It("builds negated relative date expressions", func() {
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(Equal("(annotation.play_date < ? OR annotation.play_date IS NULL)"))
+ Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now())))
+ })
+
+ It("returns an error for unknown fields", func() {
+ _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).Where()
+
+ Expect(err).To(MatchError("invalid field in criteria: unknown"))
+ })
+
+ It("returns an error when isMissing is used with a regular field", func() {
+ _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where()
+ Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields")))
+ })
+
+ It("returns an error when isPresent is used with a regular field", func() {
+ _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where()
+ Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields")))
+ })
+
+ It("returns an error when isMissing has a non-boolean value", func() {
+ _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).Where()
+ Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression")))
+ })
+
+ Describe("sort", func() {
+ It("sorts by regular fields", func() {
+ Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc"))
+ })
+
+ It("sorts by tag fields", func() {
+ Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc"))
+ })
+
+ It("sorts by role fields", func() {
+ Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc"))
+ })
+
+ It("casts numeric tags when sorting", func() {
+ Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).OrderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"))
+ })
+
+ It("sorts by albumtype alias", func() {
+ Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc"))
+ })
+
+ It("sorts by random", func() {
+ Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc"))
+ })
+
+ It("sorts by multiple fields", func() {
+ Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc"))
+ })
+
+ It("reverts order when order is desc", func() {
+ Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).OrderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc"))
+ })
+
+ It("ignores invalid sort fields", func() {
+ Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).OrderBy()).To(Equal("media_file.title asc"))
+ })
+ })
+
+ It("has SQL mappings for all non-tag/non-role criteria fields", func() {
+ for _, name := range criteria.AllFieldNames() {
+ info, ok := criteria.LookupField(name)
+ Expect(ok).To(BeTrue(), "field %q registered but LookupField fails", name)
+ if info.IsTag || info.IsRole {
+ continue
+ }
+ _, hasSQLField := smartPlaylistFields[info.Name()]
+ Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name())
+ }
+ })
+
+ Describe("JSON condition merging", func() {
+ It("merges multiple role conditions in an OR group into a single EXISTS", func() {
+ expr := criteria.Any{
+ criteria.Contains{"artist": "Beatles"},
+ criteria.Contains{"artist": "Kraftwerk"},
+ criteria.Contains{"artist": "Pink Floyd"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(Equal("(exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and (artist.name LIKE ? OR artist.name LIKE ? OR artist.name LIKE ?)))"))
+ Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%", "%Pink Floyd%"))
+ })
+
+ It("does not merge role conditions from different roles", func() {
+ expr := criteria.Any{
+ criteria.Contains{"artist": "Beatles"},
+ criteria.Contains{"composer": "Lennon"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, _, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("mfa.role = ?"))
+ // Two separate EXISTS since roles differ
+ Expect(strings.Count(sql, "exists")).To(Equal(2))
+ })
+
+ It("does not merge negated role conditions", func() {
+ expr := criteria.Any{
+ criteria.NotContains{"artist": "Beatles"},
+ criteria.NotContains{"artist": "Kraftwerk"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, _, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ // Two separate "not exists" since they are negated
+ Expect(strings.Count(sql, "not exists")).To(Equal(2))
+ })
+
+ It("batches large groups to avoid SQLite expression tree depth limit", func() {
+ // Create jsonCondBatchSize + 1 conditions to trigger batching into 2 groups
+ anyExprs := make(criteria.Any, jsonCondBatchSize+1)
+ for i := range anyExprs {
+ anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)}
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ // Should produce 2 EXISTS subqueries (one batch of jsonCondBatchSize, one of 1)
+ Expect(strings.Count(sql, "exists")).To(Equal(2))
+ // First batch has jsonCondBatchSize patterns, second has 1 => total args:
+ // 2 roles + (jsonCondBatchSize + 1) patterns
+ Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1))
+ })
+
+ It("merges role conditions while preserving non-role conditions", func() {
+ expr := criteria.Any{
+ criteria.Contains{"title": "Love"},
+ criteria.Contains{"artist": "Beatles"},
+ criteria.Contains{"artist": "Kraftwerk"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("media_file.title LIKE ?"))
+ Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?"))
+ Expect(args).To(HaveExactElements("%Love%", "artist", "%Beatles%", "%Kraftwerk%"))
+ })
+
+ It("merges multiple tag conditions in an OR group into a single EXISTS", func() {
+ expr := criteria.Any{
+ criteria.Contains{"genre": "Rock"},
+ criteria.Contains{"genre": "Metal"},
+ criteria.Contains{"genre": "Punk"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(Equal("(exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and (value LIKE ? OR value LIKE ? OR value LIKE ?)))"))
+ Expect(args).To(HaveExactElements("%Rock%", "%Metal%", "%Punk%"))
+ })
+
+ It("does not merge tag conditions from different tags", func() {
+ expr := criteria.Any{
+ criteria.Contains{"genre": "Rock"},
+ criteria.Contains{"mood": "Happy"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, _, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(strings.Count(sql, "exists")).To(Equal(2))
+ })
+
+ It("does not merge negated tag conditions", func() {
+ expr := criteria.Any{
+ criteria.NotContains{"genre": "Rock"},
+ criteria.NotContains{"genre": "Metal"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, _, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(strings.Count(sql, "not exists")).To(Equal(2))
+ })
+
+ It("merges role and tag conditions independently", func() {
+ expr := criteria.Any{
+ criteria.Contains{"artist": "Beatles"},
+ criteria.Contains{"artist": "Kraftwerk"},
+ criteria.Contains{"genre": "Rock"},
+ criteria.Contains{"genre": "Metal"},
+ }
+ sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
+ Expect(err).ToNot(HaveOccurred())
+
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ // Two merged EXISTS: one for roles, one for tags
+ Expect(strings.Count(sql, "exists")).To(Equal(2))
+ Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?"))
+ Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?"))
+ Expect(args).To(HaveLen(2 + 2 + 1)) // 2 tag patterns + 2 role patterns + 1 role name
+ })
+ })
+
+ Describe("joins", func() {
+ It("excludes sort-only joins from expression joins", func() {
+ c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"}
+ cSQL := newSmartPlaylistCriteria(c)
+
+ Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone))
+ Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
+ })
+
+ It("includes expression-based joins", func() {
+ c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}}
+
+ Expect(newSmartPlaylistCriteria(c).ExpressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
+ })
+
+ It("detects nested album and artist joins", func() {
+ c := criteria.Criteria{Expression: criteria.All{
+ criteria.Any{criteria.All{criteria.Is{"albumLoved": true}}},
+ criteria.Any{criteria.Gt{"artistPlayCount": 10}},
+ }}
+
+ joins := newSmartPlaylistCriteria(c).RequiredJoins()
+ Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
+ Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
+ })
+
+ It("detects join types from sort fields with direction prefixes", func() {
+ c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-artistRating"}
+
+ Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
+ })
+ })
+})
diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go
new file mode 100644
index 000000000..f42292f02
--- /dev/null
+++ b/persistence/e2e/e2e_suite_test.go
@@ -0,0 +1,345 @@
+package e2e
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "testing"
+ "testing/fstest"
+ "time"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core"
+ "github.com/navidrome/navidrome/core/artwork"
+ "github.com/navidrome/navidrome/core/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/criteria"
+ "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 TestSmartPlaylistE2E(t *testing.T) {
+ tests.Init(t, false)
+ defer db.Close(t.Context())
+ log.SetLevel(log.LevelFatal)
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Smart Playlist E2E Suite")
+}
+
+type _t = map[string]any
+
+var template = storagetest.Template
+var track = storagetest.Track
+
+var (
+ ctx context.Context
+ ds *tests.MockDataStore
+ lib model.Library
+
+ dbFilePath string
+ snapshotPath string
+ snapshotTables []string
+
+ adminUser = model.User{
+ ID: "sp-test-user-1",
+ UserName: "sptestuser",
+ Name: "SP Test User",
+ IsAdmin: true,
+ }
+
+ regularUser = model.User{
+ ID: "sp-test-user-2",
+ UserName: "spotheruser",
+ Name: "SP Other User",
+ IsAdmin: false,
+ }
+)
+
+func buildTestFS() {
+ abbeyRoad := template(_t{
+ "albumartist": "The Beatles",
+ "artist": "The Beatles",
+ "album": "Abbey Road",
+ "year": 1969,
+ "genre": "Rock;Blues",
+ })
+ ledZepIV := template(_t{
+ "albumartist": "Led Zeppelin",
+ "artist": "Led Zeppelin",
+ "album": "IV",
+ "year": 1971,
+ })
+ kindOfBlue := template(_t{
+ "albumartist": "Miles Davis",
+ "artist": "Miles Davis",
+ "album": "Kind of Blue",
+ "year": 1959,
+ "genre": "Jazz",
+ "composer": "Miles Davis",
+ })
+ nightAtOpera := template(_t{
+ "albumartist": "Queen",
+ "artist": "Queen",
+ "album": "A Night at the Opera",
+ "year": 1975,
+ "genre": "Rock",
+ })
+ electricLadyland := template(_t{
+ "albumartist": "Jimi Hendrix",
+ "artist": "Jimi Hendrix",
+ "album": "Electric Ladyland",
+ "year": 1968,
+ "genre": "Rock;Blues",
+ })
+ newsOfWorld := template(_t{
+ "albumartist": "Queen",
+ "artist": "Queen",
+ "album": "News of the World",
+ "year": 1977,
+ "genre": "Rock;Pop",
+ "compilation": "1",
+ })
+
+ fs := storagetest.FakeFS{}
+ fs.SetFiles(fstest.MapFS{
+ "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together",
+ _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks"})),
+ "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something",
+ _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks"})),
+ "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven",
+ _t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac",
+ "bitrate": 900, "samplerate": 44100, "bitdepth": 16})),
+ "Rock/Led Zeppelin/IV/02 - Black Dog.flac": ledZepIV(track(2, "Black Dog",
+ _t{"genre": "Rock;Blues", "composer": "Page/Plant/Jones", "bpm": 150, "suffix": "flac",
+ "bitrate": 900, "samplerate": 44100, "bitdepth": 16})),
+ "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What",
+ _t{"bpm": 136})),
+ "Rock/Queen/A Night at the Opera/01 - Bohemian Rhapsody.mp3": nightAtOpera(track(1, "Bohemian Rhapsody",
+ _t{"composer": "Freddie Mercury", "bpm": 72})),
+ "Rock/Jimi Hendrix/Electric Ladyland/01 - All Along the Watchtower.mp3": electricLadyland(track(1, "All Along the Watchtower",
+ _t{"composer": "Bob Dylan", "bpm": 112})),
+ "Rock/Queen/News of the World/01 - We Are the Champions.mp3": newsOfWorld(track(1, "We Are the Champions",
+ _t{"composer": "Freddie Mercury", "bpm": 64})),
+ })
+ storagetest.Register("fake", &fs)
+}
+
+func findMediaFileByTitle(title string) string {
+ mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"media_file.title": title},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mfs).To(HaveLen(1), "expected exactly one media file with title %q", title)
+ return mfs[0].ID
+}
+
+func evaluateRule(jsonRule string) []string {
+ titles := evaluateRuleOrderedAs(adminUser, jsonRule)
+ sort.Strings(titles)
+ return titles
+}
+
+func evaluateRuleOrdered(jsonRule string) []string {
+ return evaluateRuleOrderedAs(adminUser, jsonRule)
+}
+
+func evaluateRuleAs(owner model.User, jsonRule string) []string {
+ titles := evaluateRuleOrderedAs(owner, jsonRule)
+ sort.Strings(titles)
+ return titles
+}
+
+func evaluateRuleOrderedAs(owner model.User, jsonRule string) []string {
+ userCtx := request.WithUser(GinkgoT().Context(), owner)
+ var rules criteria.Criteria
+ err := json.Unmarshal([]byte(jsonRule), &rules)
+ Expect(err).ToNot(HaveOccurred(), "invalid criteria JSON: %s", jsonRule)
+
+ pls := &model.Playlist{
+ Name: "test-smart-playlist",
+ OwnerID: owner.ID,
+ Rules: &rules,
+ }
+ err = ds.Playlist(userCtx).Put(pls)
+ Expect(err).ToNot(HaveOccurred())
+
+ loaded, err := ds.Playlist(userCtx).GetWithTracks(pls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ titles := make([]string, len(loaded.Tracks))
+ for i, t := range loaded.Tracks {
+ titles[i] = t.Title
+ }
+ return titles
+}
+
+func createPlaylist(owner model.User, public bool, titles ...string) string {
+ pls := &model.Playlist{
+ Name: "ref-playlist",
+ OwnerID: owner.ID,
+ Public: public,
+ }
+ for _, title := range titles {
+ mfID := findMediaFileByTitle(title)
+ pls.AddMediaFilesByID([]string{mfID})
+ }
+ Expect(ds.Playlist(ctx).Put(pls)).To(Succeed())
+ return pls.ID
+}
+
+func createPublicPlaylist(owner model.User, titles ...string) string {
+ return createPlaylist(owner, true, titles...)
+}
+
+func createPrivatePlaylist(owner model.User, titles ...string) string {
+ return createPlaylist(owner, false, titles...)
+}
+
+func createPublicSmartPlaylist(owner model.User, jsonRule string) string {
+ return createSmartPlaylist(owner, true, jsonRule)
+}
+
+func createPrivateSmartPlaylist(owner model.User, jsonRule string) string {
+ return createSmartPlaylist(owner, false, jsonRule)
+}
+
+func createSmartPlaylist(owner model.User, public bool, jsonRule string) string {
+ var rules criteria.Criteria
+ Expect(json.Unmarshal([]byte(jsonRule), &rules)).To(Succeed())
+ pls := &model.Playlist{
+ Name: "ref-smart-playlist",
+ OwnerID: owner.ID,
+ Public: public,
+ Rules: &rules,
+ }
+ Expect(ds.Playlist(ctx).Put(pls)).To(Succeed())
+ return pls.ID
+}
+
+var _ = BeforeSuite(func() {
+ ctx = request.WithUser(GinkgoT().Context(), adminUser)
+ tmpDir := GinkgoT().TempDir()
+ dbFilePath = filepath.Join(tmpDir, "smartplaylist-e2e.db")
+ snapshotPath = filepath.Join(tmpDir, "smartplaylist-e2e.db.snapshot")
+ conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL"
+ db.Db().SetMaxOpenConns(1)
+
+ conf.Server.MusicFolder = "fake:///music"
+ conf.Server.DevExternalScanner = false
+ conf.Server.SmartPlaylistRefreshDelay = 0
+
+ db.Init(ctx)
+
+ initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())}
+
+ userWithPass := adminUser
+ userWithPass.NewPassword = "password"
+ Expect(initDS.User(ctx).Put(&userWithPass)).To(Succeed())
+
+ regularUserWithPass := regularUser
+ regularUserWithPass.NewPassword = "password"
+ Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed())
+
+ lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"}
+ Expect(initDS.Library(ctx).Put(&lib)).To(Succeed())
+ Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
+ Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed())
+
+ loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName)
+ Expect(err).ToNot(HaveOccurred())
+ adminUser.Libraries = loadedUser.Libraries
+
+ loadedOther, err := initDS.User(ctx).FindByUsername(regularUser.UserName)
+ Expect(err).ToNot(HaveOccurred())
+ regularUser.Libraries = loadedOther.Libraries
+
+ ctx = request.WithUser(GinkgoT().Context(), adminUser)
+
+ buildTestFS()
+ s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(),
+ playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance())
+ _, err = s.ScanAll(ctx, true)
+ Expect(err).ToNot(HaveOccurred())
+
+ ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
+
+ comeTogetherID := findMediaFileByTitle("Come Together")
+ Expect(ds.MediaFile(ctx).SetStar(true, comeTogetherID)).To(Succeed())
+ Expect(ds.MediaFile(ctx).SetStar(true, findMediaFileByTitle("So What"))).To(Succeed())
+ Expect(ds.MediaFile(ctx).SetRating(3, findMediaFileByTitle("Stairway To Heaven"))).To(Succeed())
+ Expect(ds.MediaFile(ctx).SetRating(5, findMediaFileByTitle("Bohemian Rhapsody"))).To(Succeed())
+ for range 10 {
+ Expect(ds.MediaFile(ctx).IncPlayCount(comeTogetherID, time.Now())).To(Succeed())
+ }
+ Expect(ds.MediaFile(ctx).IncPlayCount(findMediaFileByTitle("Black Dog"), time.Now())).To(Succeed())
+
+ rows, err := db.Db().Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'")
+ Expect(err).ToNot(HaveOccurred())
+ defer rows.Close()
+ for rows.Next() {
+ var name string
+ Expect(rows.Scan(&name)).To(Succeed())
+ snapshotTables = append(snapshotTables, name)
+ }
+ Expect(rows.Err()).ToNot(HaveOccurred())
+
+ _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)")
+ Expect(err).ToNot(HaveOccurred())
+ data, err := os.ReadFile(dbFilePath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed())
+})
+
+var _ = AfterSuite(func() {
+ db.Close(ctx)
+})
+
+func restoreDB() {
+ sqlDB := db.Db()
+
+ _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF")
+ Expect(err).ToNot(HaveOccurred())
+ defer func() { _, _ = sqlDB.Exec("PRAGMA foreign_keys = ON") }()
+
+ _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath)
+ Expect(err).ToNot(HaveOccurred())
+ defer func() { _, _ = sqlDB.Exec("DETACH DATABASE snapshot") }()
+
+ _, err = sqlDB.Exec("BEGIN TRANSACTION")
+ Expect(err).ToNot(HaveOccurred())
+ defer func() { _, _ = sqlDB.Exec("ROLLBACK") }()
+
+ for _, table := range snapshotTables {
+ _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec
+ Expect(err).ToNot(HaveOccurred())
+ _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec
+ Expect(err).ToNot(HaveOccurred())
+ }
+
+ _, err = sqlDB.Exec("COMMIT")
+ Expect(err).ToNot(HaveOccurred())
+}
+
+func setupTestDB() {
+ ctx = request.WithUser(GinkgoT().Context(), adminUser)
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.MusicFolder = "fake:///music"
+ conf.Server.DevExternalScanner = false
+ conf.Server.SmartPlaylistRefreshDelay = 0
+
+ restoreDB()
+ ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
+}
diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go
new file mode 100644
index 000000000..a844dc982
--- /dev/null
+++ b/persistence/e2e/smartplaylist_test.go
@@ -0,0 +1,374 @@
+package e2e
+
+import (
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/sirupsen/logrus"
+)
+
+var _ = Describe("Smart Playlists", func() {
+ BeforeEach(func() {
+ setupTestDB()
+ })
+
+ Describe("String fields", func() {
+ It("matches by exact title", func() {
+ results := evaluateRule(`{"all":[{"is":{"title":"Something"}}]}`)
+ Expect(results).To(ConsistOf("Something"))
+ })
+
+ It("matches by title contains", func() {
+ results := evaluateRule(`{"all":[{"contains":{"title":"the"}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "All Along the Watchtower", "We Are the Champions"))
+ })
+
+ It("matches by artist startsWith", func() {
+ results := evaluateRule(`{"all":[{"startsWith":{"artist":"Led"}}]}`)
+ Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog"))
+ })
+
+ It("matches by title isNot", func() {
+ results := evaluateRule(`{"all":[{"isNot":{"title":"Something"}},{"is":{"artist":"The Beatles"}}]}`)
+ Expect(results).To(ConsistOf("Come Together"))
+ })
+
+ It("matches by artist endsWith", func() {
+ results := evaluateRule(`{"all":[{"endsWith":{"artist":"Davis"}}]}`)
+ Expect(results).To(ConsistOf("So What"))
+ })
+ })
+
+ Describe("Numeric fields", func() {
+ It("matches by year greater than", func() {
+ results := evaluateRule(`{"all":[{"gt":{"year":1970}}]}`)
+ Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "We Are the Champions"))
+ })
+
+ It("matches by year less than", func() {
+ results := evaluateRule(`{"all":[{"lt":{"year":1969}}]}`)
+ Expect(results).To(ConsistOf("So What", "All Along the Watchtower"))
+ })
+
+ It("matches by BPM in range", func() {
+ results := evaluateRule(`{"all":[{"inTheRange":{"bpm":[100,130]}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something", "All Along the Watchtower"))
+ })
+ })
+
+ Describe("Boolean fields", func() {
+ It("matches compilations", func() {
+ results := evaluateRule(`{"all":[{"is":{"compilation":true}}]}`)
+ Expect(results).To(ConsistOf("We Are the Champions"))
+ })
+
+ It("matches non-compilations", func() {
+ results := evaluateRule(`{"all":[{"is":{"compilation":false}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody", "All Along the Watchtower"))
+ })
+ })
+
+ Describe("File type fields", func() {
+ It("matches by filetype", func() {
+ results := evaluateRule(`{"all":[{"is":{"filetype":"flac"}}]}`)
+ Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog"))
+ })
+ })
+
+ Describe("Multi-valued tags", func() {
+ It("matches tracks with Blues genre", func() {
+ results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower"))
+ })
+
+ It("excludes tracks with Rock genre", func() {
+ results := evaluateRule(`{"all":[{"isNot":{"genre":"Rock"}}]}`)
+ Expect(results).To(ConsistOf("So What"))
+ })
+
+ It("matches genre contains", func() {
+ results := evaluateRule(`{"all":[{"contains":{"genre":"ol"}}]}`)
+ Expect(results).To(ConsistOf("Stairway To Heaven"))
+ })
+
+ It("matches tracks with Pop genre", func() {
+ results := evaluateRule(`{"all":[{"is":{"genre":"Pop"}}]}`)
+ Expect(results).To(ConsistOf("We Are the Champions"))
+ })
+
+ It("matches genre startsWith", func() {
+ results := evaluateRule(`{"all":[{"startsWith":{"genre":"Ro"}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
+ "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
+ })
+ })
+
+ Describe("Participants", func() {
+ It("matches by exact composer", func() {
+ results := evaluateRule(`{"all":[{"is":{"composer":"Harrison"}}]}`)
+ Expect(results).To(ConsistOf("Something"))
+ })
+
+ It("matches by composer contains", func() {
+ results := evaluateRule(`{"all":[{"contains":{"composer":"Plant"}}]}`)
+ Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog"))
+ })
+
+ It("matches by composer isNot", func() {
+ results := evaluateRule(`{"all":[{"isNot":{"composer":"Freddie Mercury"}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "All Along the Watchtower"))
+ })
+
+ It("matches by composer endsWith", func() {
+ results := evaluateRule(`{"all":[{"endsWith":{"composer":"Mercury"}}]}`)
+ Expect(results).To(ConsistOf("Bohemian Rhapsody", "We Are the Champions"))
+ })
+ })
+
+ Describe("Annotations", func() {
+ It("matches starred tracks", func() {
+ results := evaluateRule(`{"all":[{"is":{"loved":true}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "So What"))
+ })
+
+ It("matches unstarred tracks", func() {
+ results := evaluateRule(`{"all":[{"is":{"loved":false}}]}`)
+ Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
+ })
+
+ It("matches by rating greater than", func() {
+ results := evaluateRule(`{"all":[{"gt":{"rating":3}}]}`)
+ Expect(results).To(ConsistOf("Bohemian Rhapsody"))
+ })
+
+ It("matches by rating greater than or equal via inTheRange", func() {
+ results := evaluateRule(`{"all":[{"inTheRange":{"rating":[3,5]}}]}`)
+ Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody"))
+ })
+
+ It("matches by play count greater than", func() {
+ results := evaluateRule(`{"all":[{"gt":{"playcount":5}}]}`)
+ Expect(results).To(ConsistOf("Come Together"))
+ })
+
+ It("matches by play count greater than zero", func() {
+ results := evaluateRule(`{"all":[{"gt":{"playcount":0}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Black Dog"))
+ })
+ })
+
+ Describe("Negated string operators", func() {
+ It("matches by title notContains", func() {
+ results := evaluateRule(`{"all":[{"notContains":{"title":"the"}}]}`)
+ Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody"))
+ })
+ })
+
+ Describe("Date/time fields", func() {
+ It("matches dateAdded before a far-future date", func() {
+ results := evaluateRule(`{"all":[{"before":{"dateadded":"2099-01-01"}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
+ "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
+ })
+
+ It("matches lastPlayed inTheLast 1 day", func() {
+ results := evaluateRule(`{"all":[{"inTheLast":{"lastplayed":1}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Black Dog"))
+ })
+
+ It("matches lastPlayed notInTheLast (far future)", func() {
+ results := evaluateRule(`{"all":[{"notInTheLast":{"lastplayed":99999}}]}`)
+ Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "So What",
+ "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
+ })
+
+ It("matches dateLoved after a past date", func() {
+ results := evaluateRule(`{"all":[{"after":{"dateloved":"2020-01-01"}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "So What"))
+ })
+
+ It("matches dateRated after a past date", func() {
+ results := evaluateRule(`{"all":[{"after":{"daterated":"2020-01-01"}}]}`)
+ Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody"))
+ })
+
+ It("matches dateAdded inTheLast 1 day", func() {
+ results := evaluateRule(`{"all":[{"inTheLast":{"dateadded":1}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
+ "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
+ })
+
+ It("resolves recordingdate alias to the date column", func() {
+ results := evaluateRule(`{"all":[{"is":{"recordingdate":"1959"}}]}`)
+ Expect(results).To(ConsistOf("So What"))
+ })
+ })
+
+ Describe("Logic operators", func() {
+ It("matches with ALL (AND)", func() {
+ results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}},{"gt":{"bpm":130}}]}`)
+ Expect(results).To(ConsistOf("Black Dog"))
+ })
+
+ It("matches with ANY (OR)", func() {
+ results := evaluateRule(`{"any":[{"is":{"genre":"Jazz"}},{"is":{"compilation":true}}]}`)
+ Expect(results).To(ConsistOf("So What", "We Are the Champions"))
+ })
+
+ It("matches nested all/any", func() {
+ results := evaluateRule(`{"all":[{"any":[{"is":{"genre":"Blues"}},{"is":{"genre":"Jazz"}}]},{"gt":{"year":1960}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower"))
+ })
+ })
+
+ Describe("Sorting and limits", func() {
+ It("returns tracks sorted by year descending with limit", func() {
+ results := evaluateRuleOrdered(`{"all":[{"gt":{"year":0}}],"sort":"year","order":"desc","limit":2}`)
+ Expect(results).To(Equal([]string{"We Are the Champions", "Bohemian Rhapsody"}))
+ })
+
+ It("returns tracks sorted by title ascending", func() {
+ results := evaluateRuleOrdered(`{"all":[{"is":{"genre":"Blues"}}],"sort":"title","order":"asc"}`)
+ Expect(results).To(Equal([]string{"All Along the Watchtower", "Black Dog", "Come Together"}))
+ })
+ })
+
+ Describe("Combined real-world patterns", func() {
+ It("matches genre filter with exclusion and year range", func() {
+ results := evaluateRuleOrdered(`{
+ "all":[
+ {"any":[
+ {"is":{"genre":"Blues"}},
+ {"is":{"genre":"Folk"}}
+ ]},
+ {"isNot":{"genre":"Jazz"}},
+ {"gt":{"year":1965}}
+ ],
+ "sort":"-year,title"
+ }`)
+ Expect(results).To(Equal([]string{"Black Dog", "Stairway To Heaven", "Come Together", "All Along the Watchtower"}))
+ })
+ })
+
+ Describe("Playlist operators", func() {
+ It("matches tracks in a public regular playlist", func() {
+ refID := createPublicPlaylist(adminUser, "Come Together", "So What")
+ results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "So What"))
+ })
+
+ It("matches tracks not in a public regular playlist", func() {
+ refID := createPublicPlaylist(adminUser, "Come Together", "So What")
+ results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`)
+ Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog",
+ "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
+ })
+
+ It("recursively refreshes a referenced smart playlist owned by the same user", func() {
+ smartBID := createPublicSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
+ results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
+ Expect(results).To(ConsistOf("So What"))
+ })
+
+ It("does not refresh a referenced smart playlist owned by another user", func() {
+ smartBID := createPublicSmartPlaylist(regularUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
+ results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
+ Expect(results).To(BeEmpty())
+ })
+
+ It("does not refresh a playlist or its children when an admin views another user's smart playlist", func() {
+ smartBID := createPrivateSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
+ smartAID := createPublicSmartPlaylist(regularUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
+
+ loadedA, err := ds.Playlist(ctx).GetWithTracks(smartAID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(loadedA.Tracks).To(BeEmpty())
+ Expect(loadedA.EvaluatedAt).To(BeNil())
+
+ loadedB, err := ds.Playlist(ctx).Get(smartBID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(loadedB.EvaluatedAt).To(BeNil())
+ })
+
+ It("matches tracks from a private playlist owned by the same user", func() {
+ refID := createPrivatePlaylist(regularUser, "Come Together", "So What")
+ results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "So What"))
+ })
+
+ It("allows admin-owned smart playlists to reference private playlists owned by other users", func() {
+ refID := createPrivatePlaylist(regularUser, "Bohemian Rhapsody")
+ results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
+ Expect(results).To(ConsistOf("Bohemian Rhapsody"))
+ })
+
+ It("does not match tracks from a private playlist owned by another regular user", func() {
+ refID := createPrivatePlaylist(adminUser, "Come Together", "So What")
+ results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
+ Expect(results).To(BeEmpty())
+ })
+
+ It("warns when a referenced playlist is inaccessible to the smart playlist owner", func() {
+ hook, cleanup := tests.LogHook()
+ defer cleanup()
+
+ refID := createPrivatePlaylist(adminUser, "Come Together")
+ results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
+ "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
+
+ Expect(hook.LastEntry()).ToNot(BeNil())
+ Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
+ Expect(hook.LastEntry().Message).To(Equal("Referenced playlist is not accessible to smart playlist owner"))
+ Expect(hook.LastEntry().Data).To(HaveKeyWithValue("childId", refID))
+ })
+
+ It("matches tracks in a public playlist owned by another user", func() {
+ refID := createPublicPlaylist(adminUser, "Bohemian Rhapsody")
+ results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
+ Expect(results).To(ConsistOf("Bohemian Rhapsody"))
+ })
+
+ })
+
+ Describe("isMissing/isPresent operators", func() {
+ It("isMissing finds tracks without grouping tag", func() {
+ results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}}]}`)
+ Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What",
+ "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
+ })
+
+ It("isMissing false finds tracks with grouping tag", func() {
+ results := evaluateRule(`{"all":[{"isMissing":{"grouping":false}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something"))
+ })
+
+ It("isPresent finds tracks with grouping tag", func() {
+ results := evaluateRule(`{"all":[{"isPresent":{"grouping":true}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something"))
+ })
+
+ It("isPresent false finds tracks without grouping tag", func() {
+ results := evaluateRule(`{"all":[{"isPresent":{"grouping":false}}]}`)
+ Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What",
+ "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
+ })
+
+ It("isMissing returns all tracks for a tag nobody has", func() {
+ results := evaluateRule(`{"all":[{"isMissing":{"lyricist":true}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
+ "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
+ })
+
+ It("isPresent returns all tracks for a role everyone has", func() {
+ results := evaluateRule(`{"all":[{"isPresent":{"composer":true}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
+ "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
+ })
+
+ It("combines isMissing with other operators", func() {
+ results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}},{"is":{"genre":"Blues"}}]}`)
+ Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower"))
+ })
+ })
+})
diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go
index a8b7884b7..f7bb6a4fe 100644
--- a/persistence/folder_repository.go
+++ b/persistence/folder_repository.go
@@ -4,7 +4,12 @@ import (
"context"
"encoding/json"
"fmt"
+ "iter"
+ "maps"
+ "os"
+ "path/filepath"
"slices"
+ "strings"
"time"
. "github.com/Masterminds/squirrel"
@@ -61,8 +66,9 @@ func newFolderRepository(ctx context.Context, db dbx.Builder) model.FolderReposi
}
func (r folderRepository) selectFolder(options ...model.QueryOptions) SelectBuilder {
- return r.newSelect(options...).Columns("folder.*", "library.path as library_path").
+ sql := r.newSelect(options...).Columns("folder.*", "library.path as library_path").
Join("library on library.id = folder.library_id")
+ return r.applyLibraryFilter(sql)
}
func (r folderRepository) Get(id string) (*model.Folder, error) {
@@ -85,23 +91,99 @@ func (r folderRepository) GetAll(opt ...model.QueryOptions) ([]model.Folder, err
}
func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) {
- sq := r.newSelect(opt...).Columns("count(*)")
- return r.count(sq)
+ query := r.newSelect(opt...).Columns("count(*)")
+ query = r.applyLibraryFilter(query)
+ return r.count(query)
}
-func (r folderRepository) GetLastUpdates(lib model.Library) (map[string]time.Time, error) {
- sq := r.newSelect().Columns("id", "updated_at").Where(Eq{"library_id": lib.ID, "missing": false})
+func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) {
+ // If no specific paths, return all folders in the library
+ if len(targetPaths) == 0 {
+ return r.getFolderUpdateInfoAll(lib)
+ }
+
+ // Check if any path is root (return all folders)
+ for _, targetPath := range targetPaths {
+ if targetPath == "" || targetPath == "." {
+ return r.getFolderUpdateInfoAll(lib)
+ }
+ }
+
+ // Process paths in batches to avoid SQLite's expression tree depth limit (max 1000).
+ // Each path generates ~3 conditions, so batch size of 100 keeps us well under the limit.
+ const batchSize = 100
+ result := make(map[string]model.FolderUpdateInfo)
+
+ for batch := range slices.Chunk(targetPaths, batchSize) {
+ batchResult, err := r.getFolderUpdateInfoBatch(lib, batch)
+ if err != nil {
+ return nil, err
+ }
+ maps.Copy(result, batchResult)
+ }
+
+ return result, nil
+}
+
+// getFolderUpdateInfoAll returns update info for all non-missing folders in the library
+func (r folderRepository) getFolderUpdateInfoAll(lib model.Library) (map[string]model.FolderUpdateInfo, error) {
+ where := And{
+ Eq{"library_id": lib.ID},
+ Eq{"missing": false},
+ }
+ return r.queryFolderUpdateInfo(where)
+}
+
+// getFolderUpdateInfoBatch returns update info for a batch of target paths and their descendants
+func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPaths []string) (map[string]model.FolderUpdateInfo, error) {
+ where := And{
+ Eq{"library_id": lib.ID},
+ Eq{"missing": false},
+ }
+
+ // Collect folder IDs for exact target folders and path conditions for descendants
+ folderIDs := make([]string, 0, len(targetPaths))
+ pathConditions := make(Or, 0, len(targetPaths)*2)
+
+ for _, targetPath := range targetPaths {
+ // Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes.
+ cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator))
+ cleanPath = filepath.Clean(cleanPath)
+
+ // Include the target folder itself by ID
+ folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
+
+ // Include all descendants: folders whose path field equals or starts with the target path
+ // Note: Folder.Path is the directory path, so children have path = targetPath
+ pathConditions = append(pathConditions, Eq{"path": cleanPath})
+ pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"})
+ }
+
+ // Combine conditions: exact folder IDs OR descendant path patterns
+ if len(folderIDs) > 0 {
+ where = append(where, Or{Eq{"id": folderIDs}, pathConditions})
+ } else if len(pathConditions) > 0 {
+ where = append(where, pathConditions)
+ }
+
+ return r.queryFolderUpdateInfo(where)
+}
+
+// queryFolderUpdateInfo executes the query and returns the result map
+func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.FolderUpdateInfo, error) {
+ sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where)
var res []struct {
ID string
UpdatedAt time.Time
+ Hash string
}
err := r.queryAll(sq, &res)
if err != nil {
return nil, err
}
- m := make(map[string]time.Time, len(res))
+ m := make(map[string]model.FolderUpdateInfo, len(res))
for _, f := range res {
- m[f.ID] = f.UpdatedAt
+ m[f.ID] = model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash}
}
return m, nil
}
@@ -137,16 +219,24 @@ func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error)
if err != nil {
return nil, err
}
+ return wrapFolderCursor(cursor), nil
+}
+
+func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor {
return func(yield func(model.Folder, error) bool) {
for f, err := range cursor {
+ if f.Folder == nil {
+ yield(model.Folder{}, fmt.Errorf("unexpected nil folder (%v): %w", f, err))
+ return
+ }
if !yield(*f.Folder, err) || err != nil {
return
}
}
- }, nil
+ }
}
-func (r folderRepository) purgeEmpty() error {
+func (r folderRepository) purgeEmpty(libraryIDs ...int) error {
sq := Delete(r.tableName).Where(And{
Eq{"num_audio_files": 0},
Eq{"num_playlists": 0},
@@ -154,6 +244,10 @@ func (r folderRepository) purgeEmpty() error {
ConcatExpr("id not in (select parent_id from folder)"),
ConcatExpr("id not in (select folder_id from media_file)"),
})
+ // If libraryIDs are specified, only purge folders from those libraries
+ if len(libraryIDs) > 0 {
+ sq = sq.Where(Eq{"library_id": libraryIDs})
+ }
c, err := r.executeSQL(sq)
if err != nil {
return fmt.Errorf("purging empty folders: %w", err)
diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go
new file mode 100644
index 000000000..ebc08fd04
--- /dev/null
+++ b/persistence/folder_repository_test.go
@@ -0,0 +1,259 @@
+package persistence
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
+)
+
+var _ = Describe("FolderRepository", func() {
+ var repo model.FolderRepository
+ var ctx context.Context
+ var conn *dbx.DB
+ var testLib, otherLib model.Library
+
+ BeforeEach(func() {
+ ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"})
+ conn = GetDBXBuilder()
+ repo = newFolderRepository(ctx, conn)
+
+ // Use existing library ID 1 from test fixtures
+ libRepo := NewLibraryRepository(ctx, conn)
+ lib, err := libRepo.Get(1)
+ Expect(err).ToNot(HaveOccurred())
+ testLib = *lib
+
+ // Create a second library with its own folder to verify isolation
+ otherLib = model.Library{Name: "Other Library", Path: "/other/path"}
+ Expect(libRepo.Put(&otherLib)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ // Clean up only test folders created by our tests (paths starting with "Test")
+ // This prevents interference with fixture data needed by other tests
+ _, _ = conn.NewQuery("DELETE FROM folder WHERE library_id = 1 AND path LIKE 'Test%'").Execute()
+ _, _ = conn.NewQuery(fmt.Sprintf("DELETE FROM library WHERE id = %d", otherLib.ID)).Execute()
+ })
+
+ Describe("GetFolderUpdateInfo", func() {
+ Context("with no target paths", func() {
+ It("returns all folders in the library", func() {
+ // Create test folders with unique names to avoid conflicts
+ folder1 := model.NewFolder(testLib, "TestGetLastUpdates/Folder1")
+ folder2 := model.NewFolder(testLib, "TestGetLastUpdates/Folder2")
+
+ err := repo.Put(folder1)
+ Expect(err).ToNot(HaveOccurred())
+ err = repo.Put(folder2)
+ Expect(err).ToNot(HaveOccurred())
+
+ otherFolder := model.NewFolder(otherLib, "TestOtherLib/Folder")
+ err = repo.Put(otherFolder)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Query all folders (no target paths) - should only return folders from testLib
+ results, err := repo.GetFolderUpdateInfo(testLib)
+ Expect(err).ToNot(HaveOccurred())
+ // Should include folders from testLib
+ Expect(results).To(HaveKey(folder1.ID))
+ Expect(results).To(HaveKey(folder2.ID))
+ // Should NOT include folders from other library
+ Expect(results).ToNot(HaveKey(otherFolder.ID))
+ })
+ })
+
+ Context("with specific target paths", func() {
+ It("returns folder info for existing folders", func() {
+ // Create test folders with unique names
+ folder1 := model.NewFolder(testLib, "TestSpecific/Rock")
+ folder2 := model.NewFolder(testLib, "TestSpecific/Jazz")
+ folder3 := model.NewFolder(testLib, "TestSpecific/Classical")
+
+ err := repo.Put(folder1)
+ Expect(err).ToNot(HaveOccurred())
+ err = repo.Put(folder2)
+ Expect(err).ToNot(HaveOccurred())
+ err = repo.Put(folder3)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Query specific paths
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestSpecific/Rock", "TestSpecific/Classical")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+
+ // Verify folder IDs are in results
+ Expect(results).To(HaveKey(folder1.ID))
+ Expect(results).To(HaveKey(folder3.ID))
+ Expect(results).ToNot(HaveKey(folder2.ID))
+
+ // Verify update info is populated
+ Expect(results[folder1.ID].UpdatedAt).ToNot(BeZero())
+ Expect(results[folder1.ID].Hash).To(Equal(folder1.Hash))
+ })
+
+ It("includes all child folders when querying parent", func() {
+ tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)")
+ // Create a parent folder with multiple children
+ parent := model.NewFolder(testLib, "TestParent/Music")
+ child1 := model.NewFolder(testLib, "TestParent/Music/Rock/Queen")
+ child2 := model.NewFolder(testLib, "TestParent/Music/Jazz")
+ otherParent := model.NewFolder(testLib, "TestParent2/Music/Jazz")
+
+ Expect(repo.Put(parent)).To(Succeed())
+ Expect(repo.Put(child1)).To(Succeed())
+ Expect(repo.Put(child2)).To(Succeed())
+
+ // Query the parent folder - should return parent and all children
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestParent/Music")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(3))
+ Expect(results).To(HaveKey(parent.ID))
+ Expect(results).To(HaveKey(child1.ID))
+ Expect(results).To(HaveKey(child2.ID))
+ Expect(results).ToNot(HaveKey(otherParent.ID))
+ })
+
+ It("excludes children from other libraries", func() {
+ tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)")
+ // Create parent in testLib
+ parent := model.NewFolder(testLib, "TestIsolation/Parent")
+ child := model.NewFolder(testLib, "TestIsolation/Parent/Child")
+
+ Expect(repo.Put(parent)).To(Succeed())
+ Expect(repo.Put(child)).To(Succeed())
+
+ // Create similar path in other library
+ otherParent := model.NewFolder(otherLib, "TestIsolation/Parent")
+ otherChild := model.NewFolder(otherLib, "TestIsolation/Parent/Child")
+
+ Expect(repo.Put(otherParent)).To(Succeed())
+ Expect(repo.Put(otherChild)).To(Succeed())
+
+ // Query should only return folders from testLib
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestIsolation/Parent")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+ Expect(results).To(HaveKey(parent.ID))
+ Expect(results).To(HaveKey(child.ID))
+ Expect(results).ToNot(HaveKey(otherParent.ID))
+ Expect(results).ToNot(HaveKey(otherChild.ID))
+ })
+
+ It("excludes missing children when querying parent", func() {
+ tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)")
+ // Create parent and children, mark one as missing
+ parent := model.NewFolder(testLib, "TestMissingChild/Parent")
+ child1 := model.NewFolder(testLib, "TestMissingChild/Parent/Child1")
+ child2 := model.NewFolder(testLib, "TestMissingChild/Parent/Child2")
+ child2.Missing = true
+
+ Expect(repo.Put(parent)).To(Succeed())
+ Expect(repo.Put(child1)).To(Succeed())
+ Expect(repo.Put(child2)).To(Succeed())
+
+ // Query parent - should only return parent and non-missing child
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestMissingChild/Parent")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+ Expect(results).To(HaveKey(parent.ID))
+ Expect(results).To(HaveKey(child1.ID))
+ Expect(results).ToNot(HaveKey(child2.ID))
+ })
+
+ It("handles mix of existing and non-existing target paths", func() {
+ tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)")
+ // Create folders for one path but not the other
+ existingParent := model.NewFolder(testLib, "TestMixed/Exists")
+ existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child")
+
+ Expect(repo.Put(existingParent)).To(Succeed())
+ Expect(repo.Put(existingChild)).To(Succeed())
+
+ // Query both existing and non-existing paths
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestMixed/Exists", "TestMixed/DoesNotExist")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+ Expect(results).To(HaveKey(existingParent.ID))
+ Expect(results).To(HaveKey(existingChild.ID))
+ })
+
+ It("handles empty folder path as root", func() {
+ // Test querying for root folder without creating it (fixtures should have one)
+ rootFolderID := model.FolderID(testLib, ".")
+
+ results, err := repo.GetFolderUpdateInfo(testLib, "")
+ Expect(err).ToNot(HaveOccurred())
+ // Should return the root folder if it exists
+ if len(results) > 0 {
+ Expect(results).To(HaveKey(rootFolderID))
+ }
+ })
+
+ It("returns empty map for non-existent folders", func() {
+ results, err := repo.GetFolderUpdateInfo(testLib, "NonExistent/Path")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+
+ It("skips missing folders", func() {
+ // Create a folder and mark it as missing
+ folder := model.NewFolder(testLib, "TestMissing/Folder")
+ folder.Missing = true
+ err := repo.Put(folder)
+ Expect(err).ToNot(HaveOccurred())
+
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestMissing/Folder")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+ })
+ })
+
+ Describe("wrapFolderCursor", func() {
+ It("does not panic when the cursor yields a dbFolder with nil Folder", func() {
+ // Simulate what queryWithStableResults does on the rows.Err() path:
+ // it yields a zero-value dbFolder (where Folder is nil) with an error.
+ dbErr := fmt.Errorf("database is locked")
+ cursor := func(yield func(dbFolder, error) bool) {
+ var empty dbFolder // Folder pointer is nil
+ yield(empty, dbErr)
+ }
+
+ // wrapFolderCursor should handle the nil Folder without panicking
+ wrappedCursor := wrapFolderCursor(cursor)
+ var gotErr error
+ Expect(func() {
+ for _, err := range wrappedCursor {
+ gotErr = err
+ }
+ }).ToNot(Panic())
+ Expect(gotErr).To(HaveOccurred())
+ Expect(gotErr.Error()).To(ContainSubstring("unexpected nil folder"))
+ Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
+ })
+
+ It("yields folders from a valid cursor", func() {
+ folder := &model.Folder{ID: "f1", Name: "Test"}
+ cursor := func(yield func(dbFolder, error) bool) {
+ yield(dbFolder{Folder: folder}, nil)
+ }
+
+ wrappedCursor := wrapFolderCursor(cursor)
+ var folders []model.Folder
+ for f, err := range wrappedCursor {
+ Expect(err).ToNot(HaveOccurred())
+ folders = append(folders, f)
+ }
+ Expect(folders).To(HaveLen(1))
+ Expect(folders[0].ID).To(Equal("f1"))
+ })
+ })
+})
diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go
index e92e1491a..22443284f 100644
--- a/persistence/genre_repository.go
+++ b/persistence/genre_repository.go
@@ -10,31 +10,17 @@ import (
)
type genreRepository struct {
- sqlRepository
+ *baseTagRepository
}
func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository {
- r := &genreRepository{}
- r.ctx = ctx
- r.db = db
- r.registerModel(&model.Tag{}, map[string]filterFunc{
- "name": containsFilter("tag_value"),
- })
- r.setSortMappings(map[string]string{
- "name": "tag_name",
- })
- return r
+ return &genreRepository{
+ baseTagRepository: newBaseTagRepository(ctx, db, new(model.TagGenre)),
+ }
}
func (r *genreRepository) selectGenre(opt ...model.QueryOptions) SelectBuilder {
- return r.newSelect(opt...).
- Columns(
- "id",
- "tag_value as name",
- "album_count",
- "media_file_count as song_count",
- ).
- Where(Eq{"tag.tag_name": model.TagGenre})
+ return r.newSelect(opt...).Columns("tag.tag_value as name")
}
func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error) {
@@ -44,26 +30,20 @@ func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error
return res, err
}
-func (r *genreRepository) Count(options ...rest.QueryOptions) (int64, error) {
- return r.count(r.selectGenre(), r.parseRestOptions(r.ctx, options...))
-}
+// Override ResourceRepository methods to return Genre objects instead of Tag objects
-func (r *genreRepository) Read(id string) (interface{}, error) {
- sel := r.selectGenre().Columns("*").Where(Eq{"id": id})
+func (r *genreRepository) Read(id string) (any, error) {
+ sel := r.selectGenre().Where(Eq{"tag.id": id})
var res model.Genre
err := r.queryOne(sel, &res)
return &res, err
}
-func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
+func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
-func (r *genreRepository) EntityName() string {
- return r.tableName
-}
-
-func (r *genreRepository) NewInstance() interface{} {
+func (r *genreRepository) NewInstance() any {
return &model.Genre{}
}
diff --git a/persistence/genre_repository_test.go b/persistence/genre_repository_test.go
new file mode 100644
index 000000000..e3779725c
--- /dev/null
+++ b/persistence/genre_repository_test.go
@@ -0,0 +1,329 @@
+package persistence
+
+import (
+ "context"
+ "slices"
+ "strings"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/id"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("GenreRepository", func() {
+ var repo model.GenreRepository
+ var restRepo model.ResourceRepository
+ var tagRepo model.TagRepository
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true})
+ genreRepo := NewGenreRepository(ctx, GetDBXBuilder())
+ repo = genreRepo
+ restRepo = genreRepo.(model.ResourceRepository)
+ tagRepo = NewTagRepository(ctx, GetDBXBuilder())
+
+ // Clear any existing tags to ensure test isolation
+ db := GetDBXBuilder()
+ _, err := db.NewQuery("DELETE FROM tag").Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Ensure library 1 exists and user has access to it
+ _, err = db.NewQuery("INSERT OR IGNORE INTO library (id, name, path, default_new_users) VALUES (1, 'Test Library', '/test', true)").Execute()
+ Expect(err).ToNot(HaveOccurred())
+ _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 1)").Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Add comprehensive test data that covers all test scenarios
+ newTag := func(name, value string) model.Tag {
+ return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value}
+ }
+
+ err = tagRepo.Add(1,
+ newTag("genre", "rock"),
+ newTag("genre", "pop"),
+ newTag("genre", "jazz"),
+ newTag("genre", "electronic"),
+ newTag("genre", "classical"),
+ newTag("genre", "ambient"),
+ newTag("genre", "techno"),
+ newTag("genre", "house"),
+ newTag("genre", "trance"),
+ newTag("genre", "Alternative Rock"),
+ newTag("genre", "Blues"),
+ newTag("genre", "Country"),
+ // These should not be counted as genres
+ newTag("mood", "happy"),
+ newTag("mood", "ambient"),
+ )
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ Describe("GetAll", func() {
+ It("should return all genres", func() {
+ genres, err := repo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(genres).To(HaveLen(12))
+
+ // Verify that all returned items are genres (TagName = "genre")
+ genreNames := make([]string, len(genres))
+ for i, genre := range genres {
+ genreNames[i] = genre.Name
+ }
+ Expect(genreNames).To(ContainElement("rock"))
+ Expect(genreNames).To(ContainElement("pop"))
+ Expect(genreNames).To(ContainElement("jazz"))
+ // Should not contain mood tags
+ Expect(genreNames).ToNot(ContainElement("happy"))
+ })
+
+ It("should support query options", func() {
+ // Test with limiting results
+ genres, err := repo.GetAll(model.QueryOptions{Max: 1})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(genres).To(HaveLen(1))
+ })
+
+ It("should handle empty results gracefully", func() {
+ // Clear all genre tags
+ _, err := GetDBXBuilder().NewQuery("DELETE FROM tag WHERE tag_name = 'genre'").Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ genres, err := repo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(genres).To(BeEmpty())
+ })
+ Describe("filtering and sorting", func() {
+ It("should filter by name using like match", func() {
+ // Test filtering by partial name match using the "name" filter which maps to containsFilter("tag_value")
+ options := model.QueryOptions{
+ Filters: squirrel.Like{"tag_value": "%rock%"}, // Direct field access
+ }
+ genres, err := repo.GetAll(options)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(genres).To(HaveLen(2)) // Should match "rock" and "Alternative Rock"
+
+ // Verify all returned genres contain "rock" in their name
+ for _, genre := range genres {
+ Expect(strings.ToLower(genre.Name)).To(ContainSubstring("rock"))
+ }
+ })
+
+ It("should sort by name in ascending order", func() {
+ // Test sorting by name with the fixed mapping
+ options := model.QueryOptions{
+ Filters: squirrel.Like{"tag_value": "%e%"}, // Should match genres containing "e"
+ Sort: "name",
+ }
+ genres, err := repo.GetAll(options)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(genres).To(HaveLen(7))
+
+ Expect(slices.IsSortedFunc(genres, func(a, b model.Genre) int {
+ return strings.Compare(b.Name, a.Name) // Inverted to check descending order
+ }))
+ })
+
+ It("should sort by name in descending order", func() {
+ // Test sorting by name in descending order
+ options := model.QueryOptions{
+ Filters: squirrel.Like{"tag_value": "%e%"}, // Should match genres containing "e"
+ Sort: "name",
+ Order: "desc",
+ }
+ genres, err := repo.GetAll(options)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(genres).To(HaveLen(7))
+
+ Expect(slices.IsSortedFunc(genres, func(a, b model.Genre) int {
+ return strings.Compare(a.Name, b.Name)
+ }))
+ })
+ })
+ })
+
+ Describe("Count", func() {
+ It("should return correct count of genres", func() {
+ count, err := restRepo.Count()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(Equal(int64(12))) // We have 12 genre tags
+ })
+
+ It("should handle zero count", func() {
+ // Clear all genre tags
+ _, err := GetDBXBuilder().NewQuery("DELETE FROM tag WHERE tag_name = 'genre'").Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ count, err := restRepo.Count()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(BeZero())
+ })
+
+ It("should only count genre tags", func() {
+ // Add a non-genre tag
+ nonGenreTag := model.Tag{
+ ID: id.NewTagID("mood", "energetic"),
+ TagName: "mood",
+ TagValue: "energetic",
+ }
+ err := tagRepo.Add(1, nonGenreTag)
+ Expect(err).ToNot(HaveOccurred())
+
+ count, err := restRepo.Count()
+ Expect(err).ToNot(HaveOccurred())
+ // Count should not include the mood tag
+ Expect(count).To(Equal(int64(12))) // Should still be 12 genre tags
+ })
+
+ It("should filter by name using like match", func() {
+ // Test filtering by partial name match using the "name" filter which maps to containsFilter("tag_value")
+ options := rest.QueryOptions{
+ Filters: map[string]any{"name": "%rock%"},
+ }
+ count, err := restRepo.Count(options)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(BeNumerically("==", 2))
+ })
+ })
+
+ Describe("Read", func() {
+ It("should return existing genre", func() {
+ // Use one of the existing genres from our consolidated dataset
+ genreID := id.NewTagID("genre", "rock")
+ result, err := restRepo.Read(genreID)
+ Expect(err).ToNot(HaveOccurred())
+ genre := result.(*model.Genre)
+ Expect(genre.ID).To(Equal(genreID))
+ Expect(genre.Name).To(Equal("rock"))
+ })
+
+ It("should return error for non-existent genre", func() {
+ _, err := restRepo.Read("non-existent-id")
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("should not return non-genre tags", func() {
+ moodID := id.NewTagID("mood", "happy") // This exists as a mood tag, not genre
+ _, err := restRepo.Read(moodID)
+ Expect(err).To(HaveOccurred()) // Should not find it as a genre
+ })
+ })
+
+ Describe("ReadAll", func() {
+ It("should return all genres through ReadAll", func() {
+ result, err := restRepo.ReadAll()
+ Expect(err).ToNot(HaveOccurred())
+ genres := result.(model.Genres)
+ Expect(genres).To(HaveLen(12)) // We have 12 genre tags
+
+ genreNames := make([]string, len(genres))
+ for i, genre := range genres {
+ genreNames[i] = genre.Name
+ }
+ // Check for some of our consolidated dataset genres
+ Expect(genreNames).To(ContainElement("rock"))
+ Expect(genreNames).To(ContainElement("pop"))
+ Expect(genreNames).To(ContainElement("jazz"))
+ })
+
+ It("should support rest query options", func() {
+ result, err := restRepo.ReadAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).ToNot(BeNil())
+ })
+ })
+
+ Describe("Library Filtering", func() {
+ Context("Headless Processes (No User Context)", func() {
+ var headlessRepo model.GenreRepository
+ var headlessRestRepo model.ResourceRepository
+
+ BeforeEach(func() {
+ // Create a repository with no user context (headless)
+ headlessGenreRepo := NewGenreRepository(context.Background(), GetDBXBuilder())
+ headlessRepo = headlessGenreRepo
+ headlessRestRepo = headlessGenreRepo.(model.ResourceRepository)
+
+ // Add genres to different libraries
+ db := GetDBXBuilder()
+ _, err := db.NewQuery("INSERT OR IGNORE INTO library (id, name, path) VALUES (2, 'Test Library 2', '/test2')").Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Add tags to different libraries
+ newTag := func(name, value string) model.Tag {
+ return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value}
+ }
+
+ err = tagRepo.Add(2, newTag("genre", "jazz"))
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("should see all genres from all libraries when no user is in context", func() {
+ // Headless processes should see all genres regardless of library
+ genres, err := headlessRepo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should see genres from all libraries
+ var genreNames []string
+ for _, genre := range genres {
+ genreNames = append(genreNames, genre.Name)
+ }
+
+ // Should include both rock (library 1) and jazz (library 2)
+ Expect(genreNames).To(ContainElement("rock"))
+ Expect(genreNames).To(ContainElement("jazz"))
+ })
+
+ It("should count all genres from all libraries when no user is in context", func() {
+ count, err := headlessRestRepo.Count()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should count all genres from all libraries
+ Expect(count).To(BeNumerically(">=", 2))
+ })
+
+ It("should allow headless processes to apply explicit library_id filters", func() {
+ // Filter by specific library
+ genres, err := headlessRestRepo.ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"library_id": 2},
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ genreList := genres.(model.Genres)
+ // Should see only genres from library 2
+ Expect(genreList).To(HaveLen(1))
+ Expect(genreList[0].Name).To(Equal("jazz"))
+ })
+
+ It("should get individual genres when no user is in context", func() {
+ // Get all genres first to find an ID
+ genres, err := headlessRepo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(genres).ToNot(BeEmpty())
+
+ // Headless process should be able to get the genre
+ genre, err := headlessRestRepo.Read(genres[0].ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(genre).ToNot(BeNil())
+ })
+ })
+ })
+
+ Describe("EntityName", func() {
+ It("should return correct entity name", func() {
+ name := restRepo.EntityName()
+ Expect(name).To(Equal("tag")) // Genre repository uses tag table
+ })
+ })
+
+ Describe("NewInstance", func() {
+ It("should return new genre instance", func() {
+ instance := restRepo.NewInstance()
+ Expect(instance).To(BeAssignableToTypeOf(&model.Genre{}))
+ })
+ })
+})
diff --git a/persistence/helpers.go b/persistence/helpers.go
index 73815ae45..fd6a9a4cd 100644
--- a/persistence/helpers.go
+++ b/persistence/helpers.go
@@ -15,7 +15,7 @@ type PostMapper interface {
PostMapArgs(map[string]any) error
}
-func toSQLArgs(rec interface{}) (map[string]interface{}, error) {
+func toSQLArgs(rec any) (map[string]any, error) {
m := structs.Map(rec)
for k, v := range m {
switch t := v.(type) {
@@ -71,7 +71,7 @@ type existsCond struct {
not bool
}
-func (e existsCond) ToSql() (string, []interface{}, error) {
+func (e existsCond) ToSql() (string, []any, error) {
sql, args, err := e.cond.ToSql()
sql = fmt.Sprintf("exists (select 1 from %s where %s)", e.subTable, sql)
if e.not {
diff --git a/persistence/library_repository.go b/persistence/library_repository.go
index 5ec54b964..1d8e6f35e 100644
--- a/persistence/library_repository.go
+++ b/persistence/library_repository.go
@@ -2,13 +2,17 @@ package persistence
import (
"context"
+ "fmt"
+ "strconv"
"sync"
"time"
. "github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils/run"
"github.com/pocketbase/dbx"
)
@@ -67,41 +71,78 @@ func (r *libraryRepository) GetPath(id int) (string, error) {
}
func (r *libraryRepository) Put(l *model.Library) error {
- cols := map[string]any{
- "name": l.Name,
- "path": l.Path,
- "remote_path": l.RemotePath,
- "updated_at": time.Now(),
- }
- if l.ID != 0 {
- cols["id"] = l.ID
+ if l.ID == model.DefaultLibraryID {
+ currentLib, err := r.Get(1)
+ // if we are creating it, it's ok.
+ if err == nil { // it exists, so we are updating it
+ if currentLib.Path != l.Path {
+ return fmt.Errorf("%w: path for library with ID 1 cannot be changed", model.ErrValidation)
+ }
+ }
}
- sq := Insert(r.tableName).SetMap(cols).
- Suffix(`on conflict(id) do update set name = excluded.name, path = excluded.path,
- remote_path = excluded.remote_path, updated_at = excluded.updated_at`)
- _, err := r.executeSQL(sq)
+ var err error
+ l.UpdatedAt = time.Now()
+ if l.ID == 0 {
+ // Insert with autoassigned ID
+ l.CreatedAt = time.Now()
+ err = r.db.Model(l).Insert()
+ } else {
+ // Try to update first
+ cols := map[string]any{
+ "name": l.Name,
+ "path": l.Path,
+ "remote_path": l.RemotePath,
+ "default_new_users": l.DefaultNewUsers,
+ "updated_at": l.UpdatedAt,
+ }
+ sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID})
+ rowsAffected, updateErr := r.executeSQL(sq)
+ if updateErr != nil {
+ return updateErr
+ }
+
+ // If no rows were affected, the record doesn't exist, so insert it
+ if rowsAffected == 0 {
+ l.CreatedAt = time.Now()
+ l.UpdatedAt = time.Now()
+ err = r.db.Model(l).Insert()
+ }
+ }
if err != nil {
- libLock.Lock()
- defer libLock.Unlock()
- libCache[l.ID] = l.Path
+ return err
}
- return err
-}
-const hardCodedMusicFolderID = 1
+ // Auto-assign all libraries to all admin users
+ sql := Expr(`
+INSERT INTO user_library (user_id, library_id)
+SELECT u.id, l.id
+FROM user u
+CROSS JOIN library l
+WHERE u.is_admin = true
+ON CONFLICT (user_id, library_id) DO NOTHING;`,
+ )
+ if _, err = r.executeSQL(sql); err != nil {
+ return fmt.Errorf("failed to assign library to admin users: %w", err)
+ }
+
+ libLock.Lock()
+ defer libLock.Unlock()
+ libCache[l.ID] = l.Path
+ return nil
+}
// TODO Remove this method when we have a proper UI to add libraries
// This is a temporary method to store the music folder path from the config in the DB
func (r *libraryRepository) StoreMusicFolder() error {
sq := Update(r.tableName).Set("path", conf.Server.MusicFolder).
Set("updated_at", time.Now()).
- Where(Eq{"id": hardCodedMusicFolderID})
+ Where(Eq{"id": model.DefaultLibraryID})
_, err := r.executeSQL(sq)
if err != nil {
libLock.Lock()
defer libLock.Unlock()
- libCache[hardCodedMusicFolderID] = conf.Server.MusicFolder
+ libCache[model.DefaultLibraryID] = conf.Server.MusicFolder
}
return err
}
@@ -136,7 +177,11 @@ func (r *libraryRepository) ScanEnd(id int) error {
return err
}
// https://www.sqlite.org/pragma.html#pragma_optimize
- _, err = r.executeSQL(Expr("PRAGMA optimize=0x10012;"))
+ // Use mask 0x10000 to check table sizes without running ANALYZE
+ // Running ANALYZE can cause query planner issues with expression-based collation indexes
+ if conf.Server.DevOptimizeDB {
+ _, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;"))
+ }
return err
}
@@ -146,6 +191,88 @@ func (r *libraryRepository) ScanInProgress() (bool, error) {
return count > 0, err
}
+func (r *libraryRepository) RefreshStats(id int) error {
+ var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 }
+ var sizeRes struct{ Sum int64 }
+ var durationRes struct{ Sum float64 }
+
+ err := run.Parallel(
+ func() error {
+ return r.queryOne(Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": false}), &songsRes)
+ },
+ func() error {
+ return r.queryOne(Select("count(*) as count").From("album").Where(Eq{"library_id": id, "missing": false}), &albumsRes)
+ },
+ func() error {
+ return r.queryOne(Select("count(*) as count").From("library_artist la").
+ Join("artist a on la.artist_id = a.id").
+ Where(Eq{"la.library_id": id, "a.missing": false}), &artistsRes)
+ },
+ func() error {
+ return r.queryOne(Select("count(*) as count").From("folder").
+ Where(And{
+ Eq{"library_id": id, "missing": false},
+ Gt{"num_audio_files": 0},
+ }), &foldersRes)
+ },
+ func() error {
+ return r.queryOne(Select("ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) as count").
+ From("folder").Where(Eq{"library_id": id, "missing": false}), &filesRes)
+ },
+ func() error {
+ return r.queryOne(Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": true}), &missingRes)
+ },
+ func() error {
+ return r.queryOne(Select("ifnull(sum(size),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &sizeRes)
+ },
+ func() error {
+ return r.queryOne(Select("ifnull(sum(duration),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &durationRes)
+ },
+ )()
+ if err != nil {
+ return err
+ }
+
+ sq := Update(r.tableName).
+ Set("total_songs", songsRes.Count).
+ Set("total_albums", albumsRes.Count).
+ Set("total_artists", artistsRes.Count).
+ Set("total_folders", foldersRes.Count).
+ Set("total_files", filesRes.Count).
+ Set("total_missing_files", missingRes.Count).
+ Set("total_size", sizeRes.Sum).
+ Set("total_duration", durationRes.Sum).
+ Set("updated_at", time.Now()).
+ Where(Eq{"id": id})
+ _, err = r.executeSQL(sq)
+ return err
+}
+
+func (r *libraryRepository) Delete(id int) error {
+ if !loggedUser(r.ctx).IsAdmin {
+ return model.ErrNotAuthorized
+ }
+ if id == 1 {
+ return fmt.Errorf("%w: library with ID 1 cannot be deleted", model.ErrValidation)
+ }
+
+ err := r.delete(Eq{"id": id})
+ if err != nil {
+ return err
+ }
+
+ // Clear cache entry for this library only if DB operation was successful
+ libLock.Lock()
+ defer libLock.Unlock()
+ delete(libCache, id)
+
+ // Clean up orphaned plugin references for the deleted library
+ if err := cleanupPluginLibraryReferences(r.db, id); err != nil {
+ log.Error(r.ctx, "Failed to cleanup plugin library references", "libraryID", id, err)
+ }
+ return nil
+}
+
func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries, error) {
sq := r.newSelect(ops...).Columns("*")
res := model.Libraries{}
@@ -153,4 +280,72 @@ func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries,
return res, err
}
+func (r *libraryRepository) CountAll(ops ...model.QueryOptions) (int64, error) {
+ sq := r.newSelect(ops...)
+ return r.count(sq)
+}
+
+// User-library association methods
+
+func (r *libraryRepository) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) {
+ sel := Select("u.*").
+ From("user u").
+ Join("user_library ul ON u.id = ul.user_id").
+ Where(Eq{"ul.library_id": libraryID}).
+ OrderBy("u.name")
+
+ var res model.Users
+ err := r.queryAll(sel, &res)
+ return res, err
+}
+
+// REST interface methods
+
+func (r *libraryRepository) Count(options ...rest.QueryOptions) (int64, error) {
+ return r.CountAll(r.parseRestOptions(r.ctx, options...))
+}
+
+func (r *libraryRepository) Read(id string) (any, error) {
+ idInt, err := strconv.Atoi(id)
+ if err != nil {
+ log.Trace(r.ctx, "invalid library id: %s", id, err)
+ return nil, rest.ErrNotFound
+ }
+ return r.Get(idInt)
+}
+
+func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
+ return r.GetAll(r.parseRestOptions(r.ctx, options...))
+}
+
+func (r *libraryRepository) EntityName() string {
+ return "library"
+}
+
+func (r *libraryRepository) NewInstance() any {
+ return &model.Library{}
+}
+
+func (r *libraryRepository) Save(entity any) (string, error) {
+ lib := entity.(*model.Library)
+ lib.ID = 0 // Reset ID to ensure we create a new library
+ err := r.Put(lib)
+ if err != nil {
+ return "", err
+ }
+ return strconv.Itoa(lib.ID), nil
+}
+
+func (r *libraryRepository) Update(id string, entity any, cols ...string) error {
+ lib := entity.(*model.Library)
+ idInt, err := strconv.Atoi(id)
+ if err != nil {
+ return fmt.Errorf("invalid library ID: %s", id)
+ }
+
+ lib.ID = idInt
+ return r.Put(lib)
+}
+
var _ model.LibraryRepository = (*libraryRepository)(nil)
+var _ rest.Repository = (*libraryRepository)(nil)
diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go
new file mode 100644
index 000000000..de7161643
--- /dev/null
+++ b/persistence/library_repository_test.go
@@ -0,0 +1,209 @@
+package persistence
+
+import (
+ "context"
+ "time"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
+)
+
+var _ = Describe("LibraryRepository", func() {
+ var repo model.LibraryRepository
+ var ctx context.Context
+ var conn *dbx.DB
+
+ BeforeEach(func() {
+ ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"})
+ conn = GetDBXBuilder()
+ repo = NewLibraryRepository(ctx, conn)
+ })
+
+ AfterEach(func() {
+ // Clean up test libraries (keep ID 1 which is the default library)
+ _, _ = conn.NewQuery("DELETE FROM library WHERE id > 1").Execute()
+ })
+
+ Describe("Put", func() {
+ Context("when ID is 0", func() {
+ It("inserts a new library with autoassigned ID", func() {
+ lib := &model.Library{
+ ID: 0,
+ Name: "Test Library",
+ Path: "/music/test",
+ }
+
+ err := repo.Put(lib)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lib.ID).To(BeNumerically(">", 0))
+ Expect(lib.CreatedAt).ToNot(BeZero())
+ Expect(lib.UpdatedAt).ToNot(BeZero())
+
+ // Verify it was inserted
+ savedLib, err := repo.Get(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(savedLib.Name).To(Equal("Test Library"))
+ Expect(savedLib.Path).To(Equal("/music/test"))
+ })
+ })
+
+ Context("when ID is non-zero and record exists", func() {
+ It("updates the existing record", func() {
+ // First create a library
+ lib := &model.Library{
+ ID: 0,
+ Name: "Original Library",
+ Path: "/music/original",
+ }
+ err := repo.Put(lib)
+ Expect(err).ToNot(HaveOccurred())
+
+ originalID := lib.ID
+ originalCreatedAt := lib.CreatedAt
+
+ // Ensure the update's timestamp is strictly greater than the
+ // create's timestamp on platforms with coarse clock resolution
+ // (Windows' time.Now() is millisecond-granular).
+ time.Sleep(2 * time.Millisecond)
+
+ // Now update it
+ lib.Name = "Updated Library"
+ lib.Path = "/music/updated"
+ err = repo.Put(lib)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify it was updated, not inserted
+ Expect(lib.ID).To(Equal(originalID))
+ Expect(lib.CreatedAt).To(Equal(originalCreatedAt))
+ Expect(lib.UpdatedAt).To(BeTemporally(">", originalCreatedAt))
+
+ // Verify the changes were saved
+ savedLib, err := repo.Get(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(savedLib.Name).To(Equal("Updated Library"))
+ Expect(savedLib.Path).To(Equal("/music/updated"))
+ })
+ })
+
+ Context("when ID is non-zero but record doesn't exist", func() {
+ It("inserts a new record with the specified ID", func() {
+ lib := &model.Library{
+ ID: 999,
+ Name: "New Library with ID",
+ Path: "/music/new",
+ }
+
+ // Ensure the record doesn't exist
+ _, err := repo.Get(999)
+ Expect(err).To(HaveOccurred())
+
+ // Put should insert it
+ err = repo.Put(lib)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lib.ID).To(Equal(999))
+ Expect(lib.CreatedAt).ToNot(BeZero())
+ Expect(lib.UpdatedAt).ToNot(BeZero())
+
+ // Verify it was inserted with the correct ID
+ savedLib, err := repo.Get(999)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(savedLib.ID).To(Equal(999))
+ Expect(savedLib.Name).To(Equal("New Library with ID"))
+ Expect(savedLib.Path).To(Equal("/music/new"))
+ })
+ })
+ })
+
+ It("refreshes stats", func() {
+ libBefore, err := repo.Get(1)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(repo.RefreshStats(1)).To(Succeed())
+ libAfter, err := repo.Get(1)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libAfter.UpdatedAt).To(BeTemporally(">", libBefore.UpdatedAt))
+
+ var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 }
+ var sizeRes struct{ Sum int64 }
+ var durationRes struct{ Sum float64 }
+
+ Expect(conn.NewQuery("select count(*) as count from media_file where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&songsRes)).To(Succeed())
+ Expect(conn.NewQuery("select count(*) as count from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&albumsRes)).To(Succeed())
+ Expect(conn.NewQuery("select count(*) as count from library_artist la join artist a on la.artist_id = a.id where la.library_id = {:id} and a.missing = 0").Bind(dbx.Params{"id": 1}).One(&artistsRes)).To(Succeed())
+ Expect(conn.NewQuery("select count(*) as count from folder where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&foldersRes)).To(Succeed())
+ Expect(conn.NewQuery("select ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) as count from folder where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&filesRes)).To(Succeed())
+ Expect(conn.NewQuery("select count(*) as count from media_file where library_id = {:id} and missing = 1").Bind(dbx.Params{"id": 1}).One(&missingRes)).To(Succeed())
+ Expect(conn.NewQuery("select ifnull(sum(size),0) as sum from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&sizeRes)).To(Succeed())
+ Expect(conn.NewQuery("select ifnull(sum(duration),0) as sum from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&durationRes)).To(Succeed())
+
+ Expect(libAfter.TotalSongs).To(Equal(int(songsRes.Count)))
+ Expect(libAfter.TotalAlbums).To(Equal(int(albumsRes.Count)))
+ Expect(libAfter.TotalArtists).To(Equal(int(artistsRes.Count)))
+ Expect(libAfter.TotalFolders).To(Equal(int(foldersRes.Count)))
+ Expect(libAfter.TotalFiles).To(Equal(int(filesRes.Count)))
+ Expect(libAfter.TotalMissingFiles).To(Equal(int(missingRes.Count)))
+ Expect(libAfter.TotalSize).To(Equal(sizeRes.Sum))
+ Expect(libAfter.TotalDuration).To(Equal(durationRes.Sum))
+ })
+
+ Describe("ScanBegin and ScanEnd", func() {
+ var lib *model.Library
+
+ BeforeEach(func() {
+ lib = &model.Library{
+ ID: 0,
+ Name: "Test Scan Library",
+ Path: "/music/test-scan",
+ }
+ err := repo.Put(lib)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ DescribeTable("ScanBegin",
+ func(fullScan bool, expectedFullScanInProgress bool) {
+ err := repo.ScanBegin(lib.ID, fullScan)
+ Expect(err).ToNot(HaveOccurred())
+
+ updatedLib, err := repo.Get(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updatedLib.LastScanStartedAt).ToNot(BeZero())
+ Expect(updatedLib.FullScanInProgress).To(Equal(expectedFullScanInProgress))
+ },
+ Entry("sets FullScanInProgress to true for full scan", true, true),
+ Entry("sets FullScanInProgress to false for quick scan", false, false),
+ )
+
+ Context("ScanEnd", func() {
+ BeforeEach(func() {
+ err := repo.ScanBegin(lib.ID, true)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("sets LastScanAt and clears FullScanInProgress and LastScanStartedAt", func() {
+ err := repo.ScanEnd(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ updatedLib, err := repo.Get(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updatedLib.LastScanAt).ToNot(BeZero())
+ Expect(updatedLib.FullScanInProgress).To(BeFalse())
+ Expect(updatedLib.LastScanStartedAt).To(BeZero())
+ })
+
+ It("sets LastScanAt to be after LastScanStartedAt", func() {
+ libBefore, err := repo.Get(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ err = repo.ScanEnd(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ libAfter, err := repo.Get(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libAfter.LastScanAt).To(BeTemporally(">=", libBefore.LastScanStartedAt))
+ })
+ })
+ })
+})
diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go
index b0ed637c1..559378262 100644
--- a/persistence/mediafile_repository.go
+++ b/persistence/mediafile_repository.go
@@ -3,12 +3,16 @@ package persistence
import (
"context"
"fmt"
+ "iter"
"slices"
+ "strconv"
+ "strings"
"sync"
"time"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
+ "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
@@ -25,10 +29,10 @@ type dbMediaFile struct {
Tags string `structs:"-" json:"-"`
// These are necessary to map the correct names (rg_*) to the correct fields (RG*)
// without using `db` struct tags in the model.MediaFile struct
- RgAlbumGain float64 `structs:"-" json:"-"`
- RgAlbumPeak float64 `structs:"-" json:"-"`
- RgTrackGain float64 `structs:"-" json:"-"`
- RgTrackPeak float64 `structs:"-" json:"-"`
+ RgAlbumGain *float64 `structs:"-" json:"-"`
+ RgAlbumPeak *float64 `structs:"-" json:"-"`
+ RgTrackGain *float64 `structs:"-" json:"-"`
+ RgTrackPeak *float64 `structs:"-" json:"-"`
}
func (m *dbMediaFile) PostScan() error {
@@ -54,8 +58,11 @@ func (m *dbMediaFile) PostScan() error {
func (m *dbMediaFile) PostMapArgs(args map[string]any) error {
fullText := []string{m.FullTitle(), m.Album, m.Artist, m.AlbumArtist,
m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle}
- fullText = append(fullText, m.MediaFile.Participants.AllNames()...)
+ participantNames := m.MediaFile.Participants.AllNames()
+ fullText = append(fullText, participantNames...)
args["full_text"] = formatFullText(fullText...)
+ args["search_participants"] = strings.Join(participantNames, " ")
+ args["search_normalized"] = normalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist)
args["tags"] = marshalTags(m.MediaFile.Tags)
args["participants"] = marshalParticipants(m.MediaFile.Participants)
return nil
@@ -74,13 +81,15 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
r.tableName = "media_file"
r.registerModel(&model.MediaFile{}, mediaFileFilter())
r.setSortMappings(map[string]string{
- "title": "order_title",
- "artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
- "album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
- "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
- "random": "random",
- "created_at": "media_file.created_at",
- "starred_at": "starred, starred_at",
+ "title": "order_title",
+ "artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
+ "album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
+ "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
+ "random": "random",
+ "created_at": "media_file.created_at",
+ "recently_added": mediaFileRecentlyAddedSort(),
+ "starred_at": "starred, starred_at",
+ "rated_at": "rating, rated_at",
})
return r
}
@@ -88,11 +97,14 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
filters := map[string]filterFunc{
"id": idFilter("media_file"),
- "title": fullTextFilter("media_file"),
- "starred": booleanFilter,
+ "title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"),
+ "starred": annotationBoolFilter("starred"),
+ "has_rating": annotationBoolFilter("rating"),
"genre_id": tagIDFilter,
"missing": booleanFilter,
"artists_id": artistFilter,
+ "library_id": libraryIdFilter,
+ "path": startsWithFilter("media_file.path"),
}
// Add all album tags as filters
for tag := range model.TagMappings() {
@@ -103,18 +115,47 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
return filters
})
+func mediaFileRecentlyAddedSort() string {
+ if conf.Server.RecentlyAddedByModTime {
+ return "media_file.updated_at"
+ }
+ return "media_file.created_at"
+}
+
func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) {
query := r.newSelect()
query = r.withAnnotation(query, "media_file.id")
+ query = r.applyLibraryFilter(query)
return r.count(query, options...)
}
+func (r *mediaFileRepository) CountBySuffix(options ...model.QueryOptions) (map[string]int64, error) {
+ sel := r.newSelect(options...).
+ Columns("lower(suffix) as suffix", "count(*) as count").
+ GroupBy("lower(suffix)")
+ var res []struct {
+ Suffix string
+ Count int64
+ }
+ err := r.queryAll(sel, &res)
+ if err != nil {
+ return nil, err
+ }
+ counts := make(map[string]int64, len(res))
+ for _, c := range res {
+ counts[c.Suffix] = c.Count
+ }
+ return counts, nil
+}
+
func (r *mediaFileRepository) Exists(id string) (bool, error) {
return r.exists(Eq{"media_file.id": id})
}
func (r *mediaFileRepository) Put(m *model.MediaFile) error {
- m.CreatedAt = time.Now()
+ if m.CreatedAt.IsZero() {
+ m.CreatedAt = time.Now()
+ }
id, err := r.putByMatch(Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m})
if err != nil {
return err
@@ -123,11 +164,17 @@ func (r *mediaFileRepository) Put(m *model.MediaFile) error {
return r.updateParticipants(m.ID, m.Participants)
}
+func (r *mediaFileRepository) UpdateProbeData(id string, data string) error {
+ _, err := r.executeSQL(Update(r.tableName).Set("probe_data", data).Where(Eq{"id": id}))
+ return err
+}
+
func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
- sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path").
+ sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name").
LeftJoin("library on media_file.library_id = library.id")
sql = r.withAnnotation(sql, "media_file.id")
- return r.withBookmark(sql, "media_file.id")
+ sql = r.withBookmark(sql, "media_file.id")
+ return r.applyLibraryFilter(sql)
}
func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) {
@@ -160,31 +207,77 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media
return res.toModels(), nil
}
+func (r *mediaFileRepository) GetAllByTags(tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) {
+ placeholders := make([]string, len(values))
+ args := make([]any, len(values))
+ for i, v := range values {
+ placeholders[i] = "?"
+ args[i] = v
+ }
+ tagFilter := Expr(
+ fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and value in (%s))",
+ tag, strings.Join(placeholders, ",")),
+ args...,
+ )
+
+ var opts model.QueryOptions
+ if len(options) > 0 {
+ opts = options[0]
+ }
+ if opts.Filters != nil {
+ opts.Filters = And{tagFilter, opts.Filters}
+ } else {
+ opts.Filters = tagFilter
+ }
+ return r.GetAll(opts)
+}
+
func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.MediaFileCursor, error) {
sq := r.selectMediaFile(options...)
cursor, err := queryWithStableResults[dbMediaFile](r.sqlRepository, sq)
if err != nil {
return nil, err
}
- return func(yield func(model.MediaFile, error) bool) {
- for m, err := range cursor {
- if m.MediaFile == nil {
- yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile: %v", m))
- return
- }
- if !yield(*m.MediaFile, err) || err != nil {
- return
- }
- }
- }, nil
+ return wrapMediaFileCursor(cursor), nil
}
+// FindByPaths finds media files by their paths.
+// The paths can be library-qualified (format: "libraryID:path") or unqualified ("path").
+// Library-qualified paths search within the specified library, while unqualified paths
+// search across all libraries for backward compatibility.
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
- sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
+ query := Or{}
+
+ for _, path := range paths {
+ parts := strings.SplitN(path, ":", 2)
+ if len(parts) == 2 {
+ // Library-qualified path: "libraryID:path"
+ libraryID, err := strconv.Atoi(parts[0])
+ if err != nil {
+ // Invalid format, skip
+ continue
+ }
+ relativePath := parts[1]
+ query = append(query, And{
+ Eq{"path collate nocase": relativePath},
+ Eq{"library_id": libraryID},
+ })
+ } else {
+ // Unqualified path: search across all libraries
+ query = append(query, Eq{"path collate nocase": path})
+ }
+ }
+
+ if len(query) == 0 {
+ return model.MediaFiles{}, nil
+ }
+
+ sel := r.newSelect().Columns("*").Where(query)
var res dbMediaFiles
if err := r.queryAll(sel, &res); err != nil {
return nil, err
}
+
return res.toModels(), nil
}
@@ -263,7 +356,7 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC
if err != nil {
return nil, err
}
- sel := r.newSelect().Columns("media_file.*", "library.path as library_path").
+ sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name").
LeftJoin("library on media_file.library_id = library.id").
Where("pid in ("+subQText+")", subQArgs...).
Where(Or{
@@ -275,33 +368,99 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC
if err != nil {
return nil, err
}
+ return wrapMediaFileCursor(cursor), nil
+}
+
+func wrapMediaFileCursor(cursor iter.Seq2[dbMediaFile, error]) model.MediaFileCursor {
return func(yield func(model.MediaFile, error) bool) {
for m, err := range cursor {
+ if m.MediaFile == nil {
+ yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile (%v): %w", m, err))
+ return
+ }
if !yield(*m.MediaFile, err) || err != nil {
return
}
}
- }, nil
+ }
}
-func (r *mediaFileRepository) Search(q string, offset int, size int, includeMissing bool) (model.MediaFiles, error) {
- results := dbMediaFiles{}
- err := r.doSearch(r.selectMediaFile(), q, offset, size, includeMissing, &results, "title")
+// FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries
+// It uses a lightweight query without annotation/bookmark joins since those are not needed for matching
+func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
+ sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name").
+ LeftJoin("library on media_file.library_id = library.id").
+ Where(And{
+ NotEq{"media_file.library_id": missing.LibraryID},
+ Eq{"media_file.mbz_release_track_id": missing.MbzReleaseTrackID},
+ NotEq{"media_file.mbz_release_track_id": ""}, // Exclude empty MBZ Track IDs
+ Eq{"media_file.suffix": missing.Suffix},
+ Gt{"media_file.created_at": since},
+ Eq{"media_file.missing": false},
+ }).OrderBy("media_file.created_at DESC")
+
+ var res dbMediaFiles
+ err := r.queryAll(sel, &res)
if err != nil {
return nil, err
}
- return results.toModels(), err
+ return res.toModels(), nil
+}
+
+// FindRecentFilesByProperties finds recently added files by intrinsic properties in other libraries
+// It uses a lightweight query without annotation/bookmark joins since those are not needed for matching
+func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
+ sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name").
+ LeftJoin("library on media_file.library_id = library.id").
+ Where(And{
+ NotEq{"media_file.library_id": missing.LibraryID},
+ Eq{"media_file.title": missing.Title},
+ Eq{"media_file.size": missing.Size},
+ Eq{"media_file.suffix": missing.Suffix},
+ Eq{"media_file.disc_number": missing.DiscNumber},
+ Eq{"media_file.track_number": missing.TrackNumber},
+ Eq{"media_file.album": missing.Album},
+ Eq{"media_file.mbz_release_track_id": ""}, // Exclude files with MBZ Track ID
+ Gt{"media_file.created_at": since},
+ Eq{"media_file.missing": false},
+ }).OrderBy("media_file.created_at DESC")
+
+ var res dbMediaFiles
+ err := r.queryAll(sel, &res)
+ if err != nil {
+ return nil, err
+ }
+ return res.toModels(), nil
+}
+
+var mediaFileSearchConfig = searchConfig{
+ NaturalOrder: "media_file.rowid",
+ OrderBy: []string{"title"},
+ MBIDFields: []string{"mbz_recording_id", "mbz_release_track_id"},
+}
+
+func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) {
+ var opts model.QueryOptions
+ if len(options) > 0 {
+ opts = options[0]
+ }
+ var res dbMediaFiles
+ err := r.doSearch(r.selectMediaFile(options...), q, &res, mediaFileSearchConfig, opts)
+ if err != nil {
+ return nil, fmt.Errorf("searching media_file %q: %w", q, err)
+ }
+ return res.toModels(), nil
}
func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
-func (r *mediaFileRepository) Read(id string) (interface{}, error) {
+func (r *mediaFileRepository) Read(id string) (any, error) {
return r.Get(id)
}
-func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
+func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
@@ -309,7 +468,7 @@ func (r *mediaFileRepository) EntityName() string {
return "mediafile"
}
-func (r *mediaFileRepository) NewInstance() interface{} {
+func (r *mediaFileRepository) NewInstance() any {
return &model.MediaFile{}
}
diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go
index c17bc595b..2bc9d0267 100644
--- a/persistence/mediafile_repository_test.go
+++ b/persistence/mediafile_repository_test.go
@@ -2,15 +2,21 @@ package persistence
import (
"context"
+ "errors"
+ "fmt"
"time"
"github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
)
var _ = Describe("MediaRepository", func() {
@@ -35,7 +41,131 @@ var _ = Describe("MediaRepository", func() {
})
It("counts the number of mediafiles in the DB", func() {
- Expect(mr.CountAll()).To(Equal(int64(4)))
+ Expect(mr.CountAll()).To(Equal(int64(13)))
+ })
+
+ Describe("CountBySuffix", func() {
+ var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile
+
+ BeforeEach(func() {
+ mp3File = model.MediaFile{ID: "suffix-mp3", LibraryID: 1, Suffix: "mp3", Path: "test/file.mp3"}
+ flacFile1 = model.MediaFile{ID: "suffix-flac1", LibraryID: 1, Suffix: "flac", Path: "test/file1.flac"}
+ flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "test/file2.flac"}
+ flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "test/file.FLAC"}
+
+ Expect(mr.Put(&mp3File)).To(Succeed())
+ Expect(mr.Put(&flacFile1)).To(Succeed())
+ Expect(mr.Put(&flacFile2)).To(Succeed())
+ Expect(mr.Put(&flacUpperFile)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ _ = mr.Delete(mp3File.ID)
+ _ = mr.Delete(flacFile1.ID)
+ _ = mr.Delete(flacFile2.ID)
+ _ = mr.Delete(flacUpperFile.ID)
+ })
+
+ It("counts media files grouped by suffix with lowercase normalization", func() {
+ counts, err := mr.CountBySuffix()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should have lowercase keys only
+ Expect(counts).To(HaveKey("mp3"))
+ Expect(counts).To(HaveKey("flac"))
+ Expect(counts).ToNot(HaveKey("FLAC"))
+
+ // mp3: 1 file
+ Expect(counts["mp3"]).To(Equal(int64(1)))
+ // flac: 3 files (2 lowercase + 1 uppercase normalized)
+ Expect(counts["flac"]).To(Equal(int64(3)))
+ })
+ })
+
+ It("returns songs ordered by lyrics with a specific title/artist", func() {
+ // attempt to mimic filters.SongsByArtistTitleWithLyricsFirst, except we want all items
+ results, err := mr.GetAll(model.QueryOptions{
+ Sort: "lyrics, updated_at",
+ Order: "desc",
+ Filters: squirrel.And{
+ squirrel.Eq{"title": "Antenna"},
+ squirrel.Or{
+ Exists("json_tree(participants, '$.albumartist')", squirrel.Eq{"value": "Kraftwerk"}),
+ Exists("json_tree(participants, '$.artist')", squirrel.Eq{"value": "Kraftwerk"}),
+ },
+ },
+ })
+
+ Expect(err).To(BeNil())
+ Expect(results).To(HaveLen(3))
+ Expect(results[0].Lyrics).To(Equal(`[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`))
+ for _, item := range results[1:] {
+ Expect(item.Lyrics).To(Equal("[]"))
+ Expect(item.Title).To(Equal("Antenna"))
+ Expect(item.Participants[model.RoleArtist][0].Name).To(Equal("Kraftwerk"))
+ }
+ })
+
+ Describe("Put CreatedAt behavior (#5050)", func() {
+ It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() {
+ before := time.Now().Add(-time.Second)
+ newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "test/created-at-zero.mp3"}
+ Expect(mr.Put(&newFile)).To(Succeed())
+
+ retrieved, err := mr.Get(newFile.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(retrieved.CreatedAt).To(BeTemporally(">", before))
+
+ _ = mr.Delete(newFile.ID)
+ })
+
+ It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() {
+ originalTime := time.Date(2020, 3, 15, 10, 30, 0, 0, time.UTC)
+ newFile := model.MediaFile{
+ ID: id.NewRandom(),
+ LibraryID: 1,
+ Path: "test/created-at-preserved.mp3",
+ CreatedAt: originalTime,
+ }
+ Expect(mr.Put(&newFile)).To(Succeed())
+
+ retrieved, err := mr.Get(newFile.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
+
+ _ = mr.Delete(newFile.ID)
+ })
+
+ It("does not reset CreatedAt when updating an existing file", func() {
+ originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
+ fileID := id.NewRandom()
+ newFile := model.MediaFile{
+ ID: fileID,
+ LibraryID: 1,
+ Path: "test/created-at-update.mp3",
+ Title: "Original Title",
+ CreatedAt: originalTime,
+ }
+ Expect(mr.Put(&newFile)).To(Succeed())
+
+ // Update the file with a new title but zero CreatedAt
+ updatedFile := model.MediaFile{
+ ID: fileID,
+ LibraryID: 1,
+ Path: "test/created-at-update.mp3",
+ Title: "Updated Title",
+ // CreatedAt is zero - should NOT overwrite the stored value
+ }
+ Expect(mr.Put(&updatedFile)).To(Succeed())
+
+ retrieved, err := mr.Get(fileID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(retrieved.Title).To(Equal("Updated Title"))
+ // CreatedAt should still be the original time (not reset)
+ Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
+
+ _ = mr.Delete(fileID)
+ })
})
It("checks existence of mediafiles in the DB", func() {
@@ -92,6 +222,74 @@ var _ = Describe("MediaRepository", func() {
Expect(mf.PlayCount).To(Equal(int64(1)))
})
+ Describe("AverageRating", func() {
+ var raw *mediaFileRepository
+
+ BeforeEach(func() {
+ raw = mr.(*mediaFileRepository)
+ })
+
+ It("returns 0 when no ratings exist", func() {
+ newID := id.NewRandom()
+ Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/no-rating.mp3"})).To(Succeed())
+
+ mf, err := mr.Get(newID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.AverageRating).To(Equal(0.0))
+
+ _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
+ })
+
+ It("returns the user's rating as average when only one user rated", func() {
+ newID := id.NewRandom()
+ Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/single-rating.mp3"})).To(Succeed())
+ Expect(mr.SetRating(5, newID)).To(Succeed())
+
+ mf, err := mr.Get(newID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.AverageRating).To(Equal(5.0))
+
+ _, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
+ _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
+ })
+
+ It("calculates average across multiple users", func() {
+ newID := id.NewRandom()
+ Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/multi-rating.mp3"})).To(Succeed())
+
+ Expect(mr.SetRating(3, newID)).To(Succeed())
+
+ user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
+ user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder())
+ Expect(user2Repo.SetRating(5, newID)).To(Succeed())
+
+ mf, err := mr.Get(newID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.AverageRating).To(Equal(4.0))
+
+ _, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
+ _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
+ })
+
+ It("excludes zero ratings from average calculation", func() {
+ newID := id.NewRandom()
+ Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/zero-excluded.mp3"})).To(Succeed())
+
+ Expect(mr.SetRating(4, newID)).To(Succeed())
+
+ user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
+ user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder())
+ Expect(user2Repo.SetRating(0, newID)).To(Succeed())
+
+ mf, err := mr.Get(newID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.AverageRating).To(Equal(4.0))
+
+ _, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
+ _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
+ })
+ })
+
It("preserves play date if and only if provided date is older", func() {
id := "incplay.playdate"
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
@@ -131,4 +329,456 @@ var _ = Describe("MediaRepository", func() {
Expect(mf.PlayCount).To(Equal(int64(1)))
})
})
+
+ Context("Sort options", func() {
+ Context("recently_added sort", func() {
+ var testMediaFiles []model.MediaFile
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+
+ // Create test media files with specific timestamps
+ testMediaFiles = []model.MediaFile{
+ {
+ ID: id.NewRandom(),
+ LibraryID: 1,
+ Title: "Old Song",
+ Path: "test/old.mp3",
+ },
+ {
+ ID: id.NewRandom(),
+ LibraryID: 1,
+ Title: "Middle Song",
+ Path: "test/middle.mp3",
+ },
+ {
+ ID: id.NewRandom(),
+ LibraryID: 1,
+ Title: "New Song",
+ Path: "test/new.mp3",
+ },
+ }
+
+ // Insert test data first
+ for i := range testMediaFiles {
+ Expect(mr.Put(&testMediaFiles[i])).To(Succeed())
+ }
+
+ // Then manually update timestamps using direct SQL to bypass the repository logic
+ db := GetDBXBuilder()
+
+ // Set specific timestamps for testing
+ oldTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
+ middleTime := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)
+ newTime := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
+
+ // Update "Old Song": created long ago, updated recently
+ _, err := db.Update("media_file",
+ map[string]any{
+ "created_at": oldTime,
+ "updated_at": newTime,
+ },
+ dbx.HashExp{"id": testMediaFiles[0].ID}).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Update "Middle Song": created and updated at the same middle time
+ _, err = db.Update("media_file",
+ map[string]any{
+ "created_at": middleTime,
+ "updated_at": middleTime,
+ },
+ dbx.HashExp{"id": testMediaFiles[1].ID}).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Update "New Song": created recently, updated long ago
+ _, err = db.Update("media_file",
+ map[string]any{
+ "created_at": newTime,
+ "updated_at": oldTime,
+ },
+ dbx.HashExp{"id": testMediaFiles[2].ID}).Execute()
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ AfterEach(func() {
+ // Clean up test data
+ for _, mf := range testMediaFiles {
+ _ = mr.Delete(mf.ID)
+ }
+ })
+
+ When("RecentlyAddedByModTime is false", func() {
+ var testRepo model.MediaFileRepository
+
+ BeforeEach(func() {
+ conf.Server.RecentlyAddedByModTime = false
+ // Create repository AFTER setting config
+ ctx := log.NewContext(GinkgoT().Context())
+ ctx = request.WithUser(ctx, model.User{ID: "userid"})
+ testRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
+ })
+
+ It("sorts by created_at", func() {
+ // Get results sorted by recently_added (should use created_at)
+ results, err := testRepo.GetAll(model.QueryOptions{
+ Sort: "recently_added",
+ Order: "desc",
+ Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(3))
+
+ // Verify sorting by created_at (newest first in descending order)
+ Expect(results[0].Title).To(Equal("New Song")) // created 2022
+ Expect(results[1].Title).To(Equal("Middle Song")) // created 2021
+ Expect(results[2].Title).To(Equal("Old Song")) // created 2020
+ })
+
+ It("sorts in ascending order when specified", func() {
+ // Get results sorted by recently_added in ascending order
+ results, err := testRepo.GetAll(model.QueryOptions{
+ Sort: "recently_added",
+ Order: "asc",
+ Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(3))
+
+ // Verify sorting by created_at (oldest first)
+ Expect(results[0].Title).To(Equal("Old Song")) // created 2020
+ Expect(results[1].Title).To(Equal("Middle Song")) // created 2021
+ Expect(results[2].Title).To(Equal("New Song")) // created 2022
+ })
+ })
+
+ When("RecentlyAddedByModTime is true", func() {
+ var testRepo model.MediaFileRepository
+
+ BeforeEach(func() {
+ conf.Server.RecentlyAddedByModTime = true
+ // Create repository AFTER setting config
+ ctx := log.NewContext(GinkgoT().Context())
+ ctx = request.WithUser(ctx, model.User{ID: "userid"})
+ testRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
+ })
+
+ It("sorts by updated_at", func() {
+ // Get results sorted by recently_added (should use updated_at)
+ results, err := testRepo.GetAll(model.QueryOptions{
+ Sort: "recently_added",
+ Order: "desc",
+ Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(3))
+
+ // Verify sorting by updated_at (newest first in descending order)
+ Expect(results[0].Title).To(Equal("Old Song")) // updated 2022
+ Expect(results[1].Title).To(Equal("Middle Song")) // updated 2021
+ Expect(results[2].Title).To(Equal("New Song")) // updated 2020
+ })
+ })
+
+ })
+ })
+
+ Context("Filters", func() {
+ var mfWithoutAnnotation model.MediaFile
+
+ BeforeEach(func() {
+ mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "test/no-annotation.mp3", Title: "No Annotation"}
+ Expect(mr.Put(&mfWithoutAnnotation)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ _ = mr.Delete(mfWithoutAnnotation.ID)
+ })
+
+ Describe("starred", func() {
+ It("false includes items without annotations", func() {
+ res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": "false"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ files := res.(model.MediaFiles)
+
+ var found bool
+ for _, f := range files {
+ if f.ID == mfWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "MediaFile without annotation should be included in starred=false filter")
+ })
+
+ It("true excludes items without annotations", func() {
+ res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": "true"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ files := res.(model.MediaFiles)
+
+ for _, f := range files {
+ Expect(f.ID).ToNot(Equal(mfWithoutAnnotation.ID))
+ }
+ })
+ })
+
+ Describe("path", func() {
+ It("matches files whose path starts with the given prefix", func() {
+ res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"path": "test/"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ files := res.(model.MediaFiles)
+
+ var found bool
+ for _, f := range files {
+ Expect(f.Path).To(HavePrefix("test/"))
+ if f.ID == mfWithoutAnnotation.ID {
+ found = true
+ }
+ }
+ Expect(found).To(BeTrue(), "MediaFile with matching path prefix should be included")
+ })
+
+ It("excludes files whose path does not start with the given prefix", func() {
+ res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"path": "no-such-prefix/"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ files := res.(model.MediaFiles)
+ Expect(files).To(BeEmpty())
+ })
+ })
+ })
+
+ Describe("Search", func() {
+ Context("text search", func() {
+ It("finds media files by title", func() {
+ results, err := mr.Search("Antenna", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2
+ for _, result := range results {
+ Expect(result.Title).To(Equal("Antenna"))
+ }
+ })
+
+ It("finds media files case insensitively", func() {
+ results, err := mr.Search("antenna", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(3))
+ for _, result := range results {
+ Expect(result.Title).To(Equal("Antenna"))
+ }
+ })
+
+ It("returns empty result when no matches found", func() {
+ results, err := mr.Search("nonexistent", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+ })
+
+ Context("MBID search", func() {
+ var mediaFileWithMBID model.MediaFile
+ var raw *mediaFileRepository
+
+ BeforeEach(func() {
+ raw = mr.(*mediaFileRepository)
+ // Create a test media file with MBID
+ mediaFileWithMBID = model.MediaFile{
+ ID: "test-mbid-mediafile",
+ Title: "Test MBID MediaFile",
+ MbzRecordingID: "550e8400-e29b-41d4-a716-446655440020", // Valid UUID v4
+ MbzReleaseTrackID: "550e8400-e29b-41d4-a716-446655440021", // Valid UUID v4
+ LibraryID: 1,
+ Path: "test/path/test.mp3",
+ }
+
+ // Insert the test media file into the database
+ err := mr.Put(&mediaFileWithMBID)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ AfterEach(func() {
+ // Clean up test data using direct SQL
+ _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": mediaFileWithMBID.ID}))
+ })
+
+ It("finds media file by mbz_recording_id", func() {
+ results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
+ Expect(results[0].Title).To(Equal("Test MBID MediaFile"))
+ })
+
+ It("finds media file by mbz_release_track_id", func() {
+ results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
+ Expect(results[0].Title).To(Equal("Test MBID MediaFile"))
+ })
+
+ It("returns empty result when MBID is not found", func() {
+ results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+
+ It("missing media files are never returned by search", func() {
+ // Create a missing media file with MBID
+ missingMediaFile := model.MediaFile{
+ ID: "test-missing-mbid-mediafile",
+ Title: "Test Missing MBID MediaFile",
+ MbzRecordingID: "550e8400-e29b-41d4-a716-446655440022",
+ LibraryID: 1,
+ Path: "test/path/missing.mp3",
+ Missing: true,
+ }
+
+ err := mr.Put(&missingMediaFile)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Search never returns missing media files (hardcoded behavior)
+ results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+
+ // Clean up
+ _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID}))
+ })
+ })
+ })
+
+ Describe("FindByPaths", func() {
+ // Test fixtures for Unicode and case-sensitivity tests
+ var testFiles []model.MediaFile
+
+ BeforeEach(func() {
+ testFiles = []model.MediaFile{
+ {ID: "findpath-1", LibraryID: 1, Path: "artist/Album/track.mp3", Title: "Track"},
+ {ID: "findpath-2", LibraryID: 1, Path: "artist/Album/UPPER.mp3", Title: "Upper"},
+ // Fullwidth uppercase: ACROSS (U+FF21 U+FF23 U+FF32 U+FF2F U+FF33 U+FF33)
+ {ID: "findpath-3", LibraryID: 1, Path: "plex/02 - ACROSS.flac", Title: "Fullwidth"},
+ // French diacritic: è (U+00E8, can decompose to e + combining grave)
+ {ID: "findpath-4", LibraryID: 1, Path: "artist/Michèle/song.mp3", Title: "French"},
+ }
+ for _, mf := range testFiles {
+ Expect(mr.Put(&mf)).To(Succeed())
+ }
+ })
+
+ AfterEach(func() {
+ for _, mf := range testFiles {
+ _ = mr.Delete(mf.ID)
+ }
+ })
+
+ It("finds files by exact path", func() {
+ results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].ID).To(Equal("findpath-1"))
+ })
+
+ It("finds files case-insensitively for ASCII characters (NOCASE)", func() {
+ // SQLite's COLLATE NOCASE handles ASCII case-insensitivity
+ results, err := mr.FindByPaths([]string{"1:ARTIST/ALBUM/TRACK.MP3"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].ID).To(Equal("findpath-1"))
+ })
+
+ It("finds fullwidth characters only with exact case match (SQLite NOCASE limitation)", func() {
+ // SQLite's NOCASE does NOT handle fullwidth uppercase/lowercase equivalence
+ // The DB has fullwidth uppercase ACROSS, searching with exact match should work
+ results, err := mr.FindByPaths([]string{"1:plex/02 - ACROSS.flac"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].ID).To(Equal("findpath-3"))
+
+ // Searching with fullwidth lowercase across should NOT match
+ // (this is the SQLite limitation that requires exact matching for non-ASCII)
+ results, err = mr.FindByPaths([]string{"1:plex/02 - across.flac"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+
+ It("returns multiple files when querying multiple paths", func() {
+ results, err := mr.FindByPaths([]string{
+ "1:artist/Album/track.mp3",
+ "1:artist/Album/UPPER.mp3",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+ })
+
+ It("returns empty slice for non-existent paths", func() {
+ results, err := mr.FindByPaths([]string{"1:nonexistent/path.mp3"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+
+ It("returns empty slice for empty input", func() {
+ results, err := mr.FindByPaths([]string{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+
+ It("handles library-qualified paths correctly", func() {
+ // Library 1 should find the file
+ results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+
+ // Library 2 should NOT find it (file is in library 1)
+ results, err = mr.FindByPaths([]string{"2:artist/Album/track.mp3"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+ })
+
+ Describe("wrapMediaFileCursor", func() {
+ It("does not panic when the cursor yields a dbMediaFile with nil MediaFile", func() {
+ // Simulate what queryWithStableResults does on the rows.Err() path:
+ // it yields a zero-value dbMediaFile (where MediaFile is nil) with an error.
+ dbErr := fmt.Errorf("database is locked")
+ cursor := func(yield func(dbMediaFile, error) bool) {
+ var empty dbMediaFile // MediaFile pointer is nil
+ yield(empty, dbErr)
+ }
+
+ // wrapMediaFileCursor should handle the nil MediaFile without panicking
+ wrappedCursor := wrapMediaFileCursor(cursor)
+ var gotErr error
+ Expect(func() {
+ for _, err := range wrappedCursor {
+ gotErr = err
+ }
+ }).ToNot(Panic())
+ Expect(gotErr).To(HaveOccurred())
+ Expect(gotErr.Error()).To(ContainSubstring("unexpected nil mediafile"))
+ Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
+ })
+
+ It("yields mediafiles from a valid cursor", func() {
+ mf := &model.MediaFile{ID: "mf1", Title: "Test"}
+ cursor := func(yield func(dbMediaFile, error) bool) {
+ yield(dbMediaFile{MediaFile: mf}, nil)
+ }
+
+ wrappedCursor := wrapMediaFileCursor(cursor)
+ var mediafiles []model.MediaFile
+ for m, err := range wrappedCursor {
+ Expect(err).ToNot(HaveOccurred())
+ mediafiles = append(mediafiles, m)
+ }
+ Expect(mediafiles).To(HaveLen(1))
+ Expect(mediafiles[0].ID).To(Equal("mf1"))
+ })
+ })
})
diff --git a/persistence/persistence.go b/persistence/persistence.go
index 2536b9c35..83211bdd5 100644
--- a/persistence/persistence.go
+++ b/persistence/persistence.go
@@ -9,7 +9,7 @@ import (
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/utils/chain"
+ "github.com/navidrome/navidrome/utils/run"
"github.com/pocketbase/dbx"
)
@@ -89,7 +89,15 @@ func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepos
return NewScrobbleBufferRepository(ctx, s.getDBXBuilder())
}
-func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
+func (s *SQLStore) Scrobble(ctx context.Context) model.ScrobbleRepository {
+ return NewScrobbleRepository(ctx, s.getDBXBuilder())
+}
+
+func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository {
+ return NewPluginRepository(ctx, s.getDBXBuilder())
+}
+
+func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository {
switch m.(type) {
case model.User:
return s.User(ctx).(model.ResourceRepository)
@@ -113,6 +121,8 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
return s.Share(ctx).(model.ResourceRepository)
case model.Tag:
return s.Tag(ctx).(model.ResourceRepository)
+ case model.Plugin:
+ return s.Plugin(ctx).(model.ResourceRepository)
}
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
return nil
@@ -157,7 +167,7 @@ func (s *SQLStore) WithTxImmediate(block func(tx model.DataStore) error, scope .
}, scope...)
}
-func (s *SQLStore) GC(ctx context.Context) error {
+func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error {
trace := func(ctx context.Context, msg string, f func() error) func() error {
return func() error {
start := time.Now()
@@ -167,11 +177,17 @@ func (s *SQLStore) GC(ctx context.Context) error {
}
}
- err := chain.RunSequentially(
- trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty() }),
+ // If libraryIDs are provided, scope operations to those libraries where possible
+ scoped := len(libraryIDs) > 0
+ if scoped {
+ log.Debug(ctx, "GC: Running selective garbage collection", "libraryIDs", libraryIDs)
+ }
+
+ err := run.Sequentially(
+ trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty(libraryIDs...) }),
trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }),
trace(ctx, "mark missing artists", func() error { return s.Artist(ctx).(*artistRepository).markMissing() }),
- trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty() }),
+ trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty(libraryIDs...) }),
trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }),
trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }),
trace(ctx, "clean media file annotations", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations() }),
diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go
index 43e4c292b..abc5c4b6a 100644
--- a/persistence/persistence_suite_test.go
+++ b/persistence/persistence_suite_test.go
@@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
+ "github.com/Masterminds/squirrel"
_ "github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
@@ -33,56 +34,107 @@ func mf(mf model.MediaFile) model.MediaFile {
mf.Tags = model.Tags{}
mf.LibraryID = 1
mf.LibraryPath = "music" // Default folder
+ mf.LibraryName = "Music Library"
mf.Participants = model.Participants{
model.RoleArtist: model.ParticipantList{
model.Participant{Artist: model.Artist{ID: mf.ArtistID, Name: mf.Artist}},
},
}
+ if mf.Lyrics == "" {
+ mf.Lyrics = "[]"
+ }
return mf
}
func al(al model.Album) model.Album {
al.LibraryID = 1
+ al.LibraryPath = "music"
+ al.LibraryName = "Music Library"
al.Discs = model.Discs{}
al.Tags = model.Tags{}
al.Participants = model.Participants{}
return al
}
+func alWithTags(a model.Album, tags model.Tags) model.Album {
+ a = al(a)
+ a.Tags = tags
+ return a
+}
+
var (
- artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
- artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
- testArtists = model.Artists{
+ artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
+ artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
+ artistCJK = model.Artist{ID: "4", Name: "シートベルツ", SortArtistName: "Seatbelts", OrderArtistName: "seatbelts"}
+ artistPunctuation = model.Artist{ID: "5", Name: "The Roots", OrderArtistName: "roots"}
+ testArtists = model.Artists{
artistKraftwerk,
artistBeatles,
+ artistCJK,
+ artistPunctuation,
}
)
var (
- albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
- albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
- albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
- testAlbums = model.Albums{
+ albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
+ albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
+ albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("kraft/radio/radio.mp3"), SongCount: 2})
+ albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("test/multi/disc1/track1.mp3"), SongCount: 4})
+ albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1})
+ albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019},
+ model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}})
+ albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("roots/things/track1.mp3"), SongCount: 1})
+ testAlbums = model.Albums{
albumSgtPeppers,
albumAbbeyRoad,
albumRadioactivity,
+ albumMultiDisc,
+ albumCJK,
+ albumWithVersion,
+ albumPunctuation,
}
)
var (
- songDayInALife = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("/beatles/1/sgt/a day.mp3")})
- songComeTogether = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("/beatles/1/come together.mp3")})
- songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("/kraft/radio/radio.mp3")})
+ songDayInALife = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("beatles/1/sgt/a day.mp3")})
+ songComeTogether = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("beatles/1/come together.mp3")})
+ songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("kraft/radio/radio.mp3")})
songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
AlbumID: "103",
- Path: p("/kraft/radio/antenna.mp3"),
- RGAlbumGain: 1.0, RGAlbumPeak: 2.0, RGTrackGain: 3.0, RGTrackPeak: 4.0,
+ Path: p("kraft/radio/antenna.mp3"),
+ RGAlbumGain: new(1.0), RGAlbumPeak: new(2.0), RGTrackGain: new(3.0), RGTrackPeak: new(4.0),
})
- testSongs = model.MediaFiles{
+ songAntennaWithLyrics = mf(model.MediaFile{
+ ID: "1005",
+ Title: "Antenna",
+ ArtistID: "2",
+ Artist: "Kraftwerk",
+ AlbumID: "103",
+ Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`,
+ })
+ songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
+ // Multi-disc album tracks (intentionally out of order to test sorting)
+ songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("seatbelts/cowboy-bebop/track1.mp3")})
+ songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("beatles/2/come together.mp3")})
+ songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("roots/things/track1.mp3")})
+ testSongs = model.MediaFiles{
songDayInALife,
songComeTogether,
songRadioactivity,
songAntenna,
+ songAntennaWithLyrics,
+ songAntenna2,
+ songDisc2Track11,
+ songDisc1Track01,
+ songDisc2Track01,
+ songDisc1Track02,
+ songCJK,
+ songVersioned,
+ songPunctuation,
}
)
@@ -101,15 +153,14 @@ var (
var (
adminUser = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true}
regularUser = model.User{ID: "2222", UserName: "regular-user", Name: "Regular User", Email: "regular@example.com"}
- testUsers = model.Users{adminUser, regularUser}
+ thirdUser = model.User{ID: "3333", UserName: "third-user", Name: "Third User", Email: "third@example.com"}
+ testUsers = model.Users{adminUser, regularUser, thirdUser}
)
func p(path string) string {
return filepath.FromSlash(path)
}
-// Initialize test DB
-// TODO Load this data setup from file(s)
var _ = BeforeSuite(func() {
conn := GetDBXBuilder()
ctx := log.NewContext(context.TODO())
@@ -123,19 +174,17 @@ var _ = BeforeSuite(func() {
}
}
- //gr := NewGenreRepository(ctx, conn)
- //for i := range testGenres {
- // g := testGenres[i]
- // err := gr.Put(&g)
- // if err != nil {
- // panic(err)
- // }
- //}
+ // Associate users with library 1 (default test library)
+ for i := range testUsers {
+ err := ur.SetUserLibraries(testUsers[i].ID, []int{1})
+ if err != nil {
+ panic(err)
+ }
+ }
alr := NewAlbumRepository(ctx, conn).(*albumRepository)
for i := range testAlbums {
- a := testAlbums[i]
- err := alr.Put(&a)
+ err := alr.Put(new(testAlbums[i]))
if err != nil {
panic(err)
}
@@ -143,8 +192,37 @@ var _ = BeforeSuite(func() {
arr := NewArtistRepository(ctx, conn)
for i := range testArtists {
- a := testArtists[i]
- err := arr.Put(&a)
+ err := arr.Put(new(testArtists[i]))
+ if err != nil {
+ panic(err)
+ }
+ }
+
+ // Associate artists with library 1 (default test library)
+ lr := NewLibraryRepository(ctx, conn)
+ for i := range testArtists {
+ err := lr.AddArtist(1, testArtists[i].ID)
+ if err != nil {
+ panic(err)
+ }
+ }
+
+ // Populate album_artists based on the AlbumArtistID relationships in testAlbums
+ artistIDs := map[string]bool{}
+ for _, a := range testArtists {
+ artistIDs[a.ID] = true
+ }
+ for i := range testAlbums {
+ a := testAlbums[i]
+ if a.AlbumArtistID == "" || !artistIDs[a.AlbumArtistID] {
+ continue
+ }
+ _, err := alr.executeSQL(squirrel.Insert("album_artists").SetMap(map[string]any{
+ "album_id": a.ID,
+ "artist_id": a.AlbumArtistID,
+ "role": "artist",
+ "sub_role": "",
+ }))
if err != nil {
panic(err)
}
@@ -160,8 +238,7 @@ var _ = BeforeSuite(func() {
rar := NewRadioRepository(ctx, conn)
for i := range testRadios {
- r := testRadios[i]
- err := rar.Put(&r)
+ err := rar.Put(new(testRadios[i]))
if err != nil {
panic(err)
}
@@ -175,9 +252,9 @@ var _ = BeforeSuite(func() {
Public: true,
SongCount: 2,
}
- plsBest.AddTracks([]string{"1001", "1003"})
+ plsBest.AddMediaFilesByID([]string{"1001", "1003"})
plsCool = model.Playlist{Name: "Cool", OwnerID: "userid", OwnerName: "userid"}
- plsCool.AddTracks([]string{"1004"})
+ plsCool.AddMediaFilesByID([]string{"1004"})
testPlaylists = []*model.Playlist{&plsBest, &plsCool}
pr := NewPlaylistRepository(ctx, conn)
@@ -192,7 +269,13 @@ var _ = BeforeSuite(func() {
if err := arr.SetStar(true, artistBeatles.ID); err != nil {
panic(err)
}
- ar, _ := arr.Get(artistBeatles.ID)
+ ar, err := arr.Get(artistBeatles.ID)
+ if err != nil {
+ panic(err)
+ }
+ if ar == nil {
+ panic("artist not found after SetStar")
+ }
artistBeatles.Starred = true
artistBeatles.StarredAt = ar.StarredAt
testArtists[1] = artistBeatles
@@ -204,6 +287,9 @@ var _ = BeforeSuite(func() {
if err != nil {
panic(err)
}
+ if al == nil {
+ panic("album not found after SetStar")
+ }
albumRadioactivity.Starred = true
albumRadioactivity.StarredAt = al.StarredAt
testAlbums[2] = albumRadioactivity
diff --git a/persistence/player_repository.go b/persistence/player_repository.go
index 73c820753..353b0444f 100644
--- a/persistence/player_repository.go
+++ b/persistence/player_repository.go
@@ -62,18 +62,6 @@ func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBu
return s.Where(r.addRestriction())
}
-func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer {
- s := And{}
- if len(sql) > 0 {
- s = append(s, sql[0])
- }
- u := loggedUser(r.ctx)
- if u.IsAdmin {
- return s
- }
- return append(s, Eq{"user_id": u.ID})
-}
-
func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[string]int64, error) {
sel := r.newSelect(options...).
Columns(
@@ -103,14 +91,14 @@ func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
-func (r *playerRepository) Read(id string) (interface{}, error) {
+func (r *playerRepository) Read(id string) (any, error) {
sel := r.newRestSelect().Where(Eq{"player.id": id})
var res model.Player
err := r.queryOne(sel, &res)
return &res, err
}
-func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
+func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
sel := r.newRestSelect(r.parseRestOptions(r.ctx, options...))
res := model.Players{}
err := r.queryAll(sel, &res)
@@ -121,16 +109,20 @@ func (r *playerRepository) EntityName() string {
return "player"
}
-func (r *playerRepository) NewInstance() interface{} {
+func (r *playerRepository) NewInstance() any {
return &model.Player{}
}
+// isPermitted authorizes creating a new record, based on the owner declared in the request body.
+// This is only safe for inserts: there is no stored row yet, and a non-admin may only create a
+// player they own. Updates must not use this (the body owner is attacker-controlled); they go
+// through updateOwned, which authorizes against the persisted user_id in the WHERE clause.
func (r *playerRepository) isPermitted(p *model.Player) bool {
u := loggedUser(r.ctx)
return u.IsAdmin || p.UserId == u.ID
}
-func (r *playerRepository) Save(entity interface{}) (string, error) {
+func (r *playerRepository) Save(entity any) (string, error) {
t := entity.(*model.Player)
if !r.isPermitted(t) {
return "", rest.ErrPermissionDenied
@@ -142,26 +134,14 @@ func (r *playerRepository) Save(entity interface{}) (string, error) {
return id, err
}
-func (r *playerRepository) Update(id string, entity interface{}, cols ...string) error {
+func (r *playerRepository) Update(id string, entity any, cols ...string) error {
t := entity.(*model.Player)
t.ID = id
- if !r.isPermitted(t) {
- return rest.ErrPermissionDenied
- }
- _, err := r.put(id, t, cols...)
- if errors.Is(err, model.ErrNotFound) {
- return rest.ErrNotFound
- }
- return err
+ return r.updateOwned(id, t, cols...)
}
func (r *playerRepository) Delete(id string) error {
- filter := r.addRestriction(And{Eq{"player.id": id}})
- err := r.delete(filter)
- if errors.Is(err, model.ErrNotFound) {
- return rest.ErrNotFound
- }
- return err
+ return r.deleteOwned(id)
}
var _ model.PlayerRepository = (*playerRepository)(nil)
diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go
index f6c669493..b7085a1fb 100644
--- a/persistence/player_repository_test.go
+++ b/persistence/player_repository_test.go
@@ -110,33 +110,43 @@ var _ = Describe("PlayerRepository", func() {
})
Describe("Delete", func() {
- DescribeTable("item type", func(player model.Player) {
- err := repo.Delete(player.ID)
+ It("deletes a player owned by the current user", func() {
+ err := repo.Delete(userPlayer.ID)
Expect(err).To(BeNil())
- isReal := player.UserId != ""
- canDelete := admin || player.UserId == userPlayer.UserId
-
count, err := repo.Count()
Expect(err).To(BeNil())
+ Expect(count).To(Equal(baseCount - 1))
- if isReal && canDelete {
- Expect(count).To(Equal(baseCount - 1))
- } else {
- Expect(count).To(Equal(baseCount))
- }
+ _, err = repo.Get(userPlayer.ID)
+ Expect(err).To(Equal(model.ErrNotFound))
+ })
- item, err := repo.Get(player.ID)
- if !isReal || canDelete {
+ It("does not delete another user's player when not admin", func() {
+ err := repo.Delete(otherPlayer.ID)
+
+ if admin {
+ // Admins may delete any player.
+ Expect(err).To(BeNil())
+ Expect(repo.Count()).To(Equal(baseCount - 1))
+ _, err = repo.Get(otherPlayer.ID)
Expect(err).To(Equal(model.ErrNotFound))
} else {
- Expect(*item).To(Equal(player))
+ // The ownership-restricted delete matches no owned row, so it reports
+ // permission-denied and leaves the other user's player untouched.
+ Expect(err).To(Equal(rest.ErrPermissionDenied))
+ Expect(repo.Count()).To(Equal(baseCount))
+ item, err := repo.Get(otherPlayer.ID)
+ Expect(err).To(BeNil())
+ Expect(*item).To(Equal(otherPlayer))
}
- },
- Entry("same user", userPlayer),
- Entry("other item", otherPlayer),
- Entry("fake item", model.Player{}),
- )
+ })
+
+ It("returns not-found for a nonexistent player", func() {
+ err := repo.Delete("i don't exist")
+ Expect(err).To(Equal(rest.ErrNotFound))
+ Expect(repo.Count()).To(Equal(baseCount))
+ })
})
Describe("Read", func() {
@@ -215,9 +225,12 @@ var _ = Describe("PlayerRepository", func() {
clone.MaxBitRate = 10000
err := repo.Update(clone.ID, &clone, "ip")
- if clone.UserId == "" {
+ if player.UserId == "" {
Expect(err).To(HaveOccurred())
} else if !admin && player.Username == adminPlayer1.Username {
+ // A non-admin cannot target another user's player: the ownership-restricted
+ // update matches no owned row, so it reports permission-denied rather than
+ // touching it.
Expect(err).To(Equal(rest.ErrPermissionDenied))
clone.IP = player.IP
} else {
@@ -244,4 +257,86 @@ var _ = Describe("PlayerRepository", func() {
Entry("admin context", true, players, adminPlayer1, regularPlayer),
Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1),
)
+
+ Describe("Ownership enforcement (cross-tenant write protection)", func() {
+ var regularRepo *playerRepository
+
+ BeforeEach(func() {
+ ctx := log.NewContext(context.TODO())
+ ctx = request.WithUser(ctx, regularUser)
+ regularRepo = NewPlayerRepository(ctx, database).(*playerRepository)
+ })
+
+ It("does not let a regular user hijack another user's player by spoofing userId in the body", func() {
+ // Attacker (regularUser) targets the victim's (adminUser) player by URL id,
+ // but sets userId in the body to their own id to try to pass the permission check.
+ spoofed := model.Player{
+ ID: adminPlayer1.ID,
+ Name: "HIJACKED",
+ UserId: regularUser.ID, // attacker's own id, spoofed in the body
+ MaxBitRate: 1,
+ }
+
+ // The ownership-restricted update matches no row owned by the attacker, so the write
+ // targets nothing and reports permission-denied rather than overwriting the victim's row.
+ err := regularRepo.Update(adminPlayer1.ID, &spoofed, "name", "user_id", "max_bit_rate")
+ Expect(err).To(Equal(rest.ErrPermissionDenied))
+
+ // The victim's player must remain untouched.
+ stored, err := adminRepo.Get(adminPlayer1.ID)
+ Expect(err).To(BeNil())
+ Expect(*stored).To(Equal(adminPlayer1))
+ })
+
+ It("does not let a regular user reassign their own player to another user", func() {
+ // Owner updates their own player but tries to give it away to the admin. The update
+ // succeeds for the other fields, but user_id is never written, so ownership stays put.
+ reassign := regularPlayer
+ reassign.UserId = adminUser.ID
+ reassign.Name = "given-away"
+
+ err := regularRepo.Update(regularPlayer.ID, &reassign, "name", "user_id")
+ Expect(err).To(BeNil())
+
+ // Ownership must not have changed.
+ stored, err := adminRepo.Get(regularPlayer.ID)
+ Expect(err).To(BeNil())
+ Expect(stored.UserId).To(Equal(regularUser.ID))
+ })
+
+ It("does not let an admin reassign a player to another user", func() {
+ // Even an admin cannot change a player's owner via update.
+ reassign := regularPlayer
+ reassign.UserId = adminUser.ID
+ reassign.Name = "admin-renamed"
+
+ err := adminRepo.Update(regularPlayer.ID, &reassign, "name", "user_id")
+ Expect(err).To(BeNil())
+
+ // The name change applies, but ownership must not have moved.
+ stored, err := adminRepo.Get(regularPlayer.ID)
+ Expect(err).To(BeNil())
+ Expect(stored.Name).To(Equal("admin-renamed"))
+ Expect(stored.UserId).To(Equal(regularUser.ID))
+ })
+
+ It("lets the owner update their own player", func() {
+ update := regularPlayer
+ update.Name = "renamed-by-owner"
+
+ err := regularRepo.Update(regularPlayer.ID, &update, "name")
+ Expect(err).To(BeNil())
+
+ stored, err := adminRepo.Get(regularPlayer.ID)
+ Expect(err).To(BeNil())
+ Expect(stored.Name).To(Equal("renamed-by-owner"))
+ Expect(stored.UserId).To(Equal(regularUser.ID))
+ })
+
+ It("returns not found when updating a nonexistent player", func() {
+ ghost := model.Player{ID: "does-not-exist", Name: "ghost", UserId: regularUser.ID}
+ err := regularRepo.Update("does-not-exist", &ghost, "name")
+ Expect(err).To(Equal(rest.ErrNotFound))
+ })
+ })
})
diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go
index 743eca470..4152505d2 100644
--- a/persistence/playlist_repository.go
+++ b/persistence/playlist_repository.go
@@ -11,10 +11,8 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
- "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/criteria"
"github.com/pocketbase/dbx"
)
@@ -61,14 +59,14 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
return r
}
-func playlistFilter(_ string, value interface{}) Sqlizer {
+func playlistFilter(_ string, value any) Sqlizer {
return Or{
substringFilter("playlist.name", value),
substringFilter("playlist.comment", value),
}
}
-func smartPlaylistFilter(string, interface{}) Sqlizer {
+func smartPlaylistFilter(string, any) Sqlizer {
return Or{
Eq{"rules": ""},
Eq{"rules": nil},
@@ -96,31 +94,20 @@ func (r *playlistRepository) Exists(id string) (bool, error) {
}
func (r *playlistRepository) Delete(id string) error {
- usr := loggedUser(r.ctx)
- if !usr.IsAdmin {
- pls, err := r.Get(id)
- if err != nil {
- return err
- }
- if pls.OwnerID != usr.ID {
- return rest.ErrPermissionDenied
- }
- }
return r.delete(And{Eq{"id": id}, r.userFilter()})
}
-func (r *playlistRepository) Put(p *model.Playlist) error {
+func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error {
pls := dbPlaylist{Playlist: *p}
+ if len(cols) > 0 {
+ if pls.ID == "" {
+ return errors.New("playlist id is required for partial update")
+ }
+ _, err := r.put(pls.ID, pls, cols...)
+ return err
+ }
if pls.ID == "" {
pls.CreatedAt = time.Now()
- } else {
- ok, err := r.Exists(pls.ID)
- if err != nil {
- return err
- }
- if !ok {
- return model.ErrNotAuthorized
- }
}
pls.UpdatedAt = time.Now()
@@ -132,7 +119,6 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
if p.IsSmartPlaylist() {
// Do not update tracks at this point, as it may take a long time and lock the DB, breaking the scan process
- //r.refreshSmartPlaylist(p)
return nil
}
// Only update tracks if they were specified
@@ -161,7 +147,7 @@ func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist, incl
log.Error(r.ctx, "Error loading playlist tracks ", "playlist", pls.Name, "id", pls.ID, err)
return nil, err
}
- pls.Tracks = tracks
+ pls.SetTracks(tracks)
return pls, nil
}
@@ -197,93 +183,30 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli
return playlists, err
}
+func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) {
+ sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}).
+ Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id").
+ Where(And{Eq{"playlist_tracks.media_file_id": mediaFileId}, r.userFilter()})
+ var res []dbPlaylist
+ err := r.queryAll(sel, &res)
+ if err != nil {
+ if errors.Is(err, model.ErrNotFound) {
+ return model.Playlists{}, nil
+ }
+ return nil, err
+ }
+ playlists := make(model.Playlists, len(res))
+ for i, p := range res {
+ playlists[i] = p.Playlist
+ }
+ return playlists, nil
+}
+
func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder {
return r.newSelect(options...).Join("user on user.id = owner_id").
Columns(r.tableName+".*", "user.user_name as owner_name")
}
-func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
- // Only refresh if it is a smart playlist and was not refreshed within the interval provided by the refresh delay config
- if !pls.IsSmartPlaylist() || (pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay) {
- return false
- }
-
- // Never refresh other users' playlists
- usr := loggedUser(r.ctx)
- if pls.OwnerID != usr.ID {
- log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID)
- return false
- }
-
- log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID)
- start := time.Now()
-
- // Remove old tracks
- del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID})
- _, err := r.executeSQL(del)
- if err != nil {
- log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
- return false
- }
-
- // Re-populate playlist based on Smart Playlist criteria
- rules := *pls.Rules
-
- // If the playlist depends on other playlists, recursively refresh them first
- childPlaylistIds := rules.ChildPlaylistIds()
- for _, id := range childPlaylistIds {
- childPls, err := r.Get(id)
- if err != nil {
- log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err)
- return false
- }
- r.refreshSmartPlaylist(childPls)
- }
-
- sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
- From("media_file").LeftJoin("annotation on (" +
- "annotation.item_id = media_file.id" +
- " AND annotation.item_type = 'media_file'" +
- " AND annotation.user_id = '" + userId(r.ctx) + "')")
- sq = r.addCriteria(sq, rules)
- insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
- _, err = r.executeSQL(insSql)
- if err != nil {
- log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
- return false
- }
-
- // Update playlist stats
- err = r.refreshCounters(pls)
- if err != nil {
- log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err)
- return false
- }
-
- // Update when the playlist was last refreshed (for cache purposes)
- updSql := Update(r.tableName).Set("evaluated_at", time.Now()).Where(Eq{"id": pls.ID})
- _, err = r.executeSQL(updSql)
- if err != nil {
- log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err)
- return false
- }
-
- log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start))
-
- return true
-}
-
-func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) SelectBuilder {
- sql = sql.Where(c)
- if c.Limit > 0 {
- sql = sql.Limit(uint64(c.Limit)).Offset(uint64(c.Offset))
- }
- if order := c.OrderBy(); order != "" {
- sql = sql.OrderBy(order)
- }
- return sql
-}
-
func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error {
ids := make([]string, len(tracks))
for i := range tracks {
@@ -293,10 +216,6 @@ func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) er
}
func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []string) error {
- if !r.isWritable(playlistId) {
- return rest.ErrPermissionDenied
- }
-
// Remove old tracks
del := Delete("playlist_tracks").Where(Eq{"playlist_id": playlistId})
_, err := r.executeSQL(del)
@@ -360,6 +279,8 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error {
}
func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.PlaylistTracks, error) {
+ sel = r.applyLibraryFilter(sel, "f")
+ userID := loggedUser(r.ctx).ID
tracksQuery := sel.
Columns(
"coalesce(starred, 0) as starred",
@@ -367,14 +288,16 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
"coalesce(play_count, 0) as play_count",
"play_date",
"coalesce(rating, 0) as rating",
+ "rated_at",
"f.*",
"playlist_tracks.*",
"library.path as library_path",
+ "library.name as library_name",
).
LeftJoin("annotation on (" +
"annotation.item_id = media_file_id" +
" AND annotation.item_type = 'media_file'" +
- " AND annotation.user_id = '" + userId(r.ctx) + "')").
+ " AND annotation.user_id = '" + userID + "')").
Join("media_file f on f.id = media_file_id").
Join("library on f.library_id = library.id").
Where(Eq{"playlist_id": id})
@@ -390,11 +313,11 @@ func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error)
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
-func (r *playlistRepository) Read(id string) (interface{}, error) {
+func (r *playlistRepository) Read(id string) (any, error) {
return r.Get(id)
}
-func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
+func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
@@ -402,14 +325,13 @@ func (r *playlistRepository) EntityName() string {
return "playlist"
}
-func (r *playlistRepository) NewInstance() interface{} {
+func (r *playlistRepository) NewInstance() any {
return &model.Playlist{}
}
-func (r *playlistRepository) Save(entity interface{}) (string, error) {
+func (r *playlistRepository) Save(entity any) (string, error) {
pls := entity.(*model.Playlist)
- pls.OwnerID = loggedUser(r.ctx).ID
- pls.ID = "" // Make sure we don't override an existing playlist
+ pls.ID = "" // Force new creation
err := r.Put(pls)
if err != nil {
return "", err
@@ -417,26 +339,11 @@ func (r *playlistRepository) Save(entity interface{}) (string, error) {
return pls.ID, err
}
-func (r *playlistRepository) Update(id string, entity interface{}, cols ...string) error {
+func (r *playlistRepository) Update(id string, entity any, cols ...string) error {
pls := dbPlaylist{Playlist: *entity.(*model.Playlist)}
- current, err := r.Get(id)
- if err != nil {
- return err
- }
- usr := loggedUser(r.ctx)
- if !usr.IsAdmin {
- // Only the owner can update the playlist
- if current.OwnerID != usr.ID {
- return rest.ErrPermissionDenied
- }
- // Regular users can't change the ownership of a playlist
- if pls.OwnerID != "" && pls.OwnerID != usr.ID {
- return rest.ErrPermissionDenied
- }
- }
pls.ID = id
pls.UpdatedAt = time.Now()
- _, err = r.put(id, pls, append(cols, "updatedAt")...)
+ _, err := r.put(id, pls, append(cols, "updatedAt")...)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
@@ -476,23 +383,31 @@ func (r *playlistRepository) removeOrphans() error {
return nil
}
+// renumber updates the position of all tracks in the playlist to be sequential starting from 1, ordered by their
+// current position. This is needed after removing orphan tracks, to ensure there are no gaps in the track numbering.
+// The two-step approach (negate then reassign via CTE) avoids UNIQUE constraint violations on (playlist_id, id).
func (r *playlistRepository) renumber(id string) error {
- var ids []string
- sq := Select("media_file_id").From("playlist_tracks").Where(Eq{"playlist_id": id}).OrderBy("id")
- err := r.queryAllSlice(sq, &ids)
+ // Step 1: Negate all IDs to clear the positive ID space
+ _, err := r.executeSQL(Expr(
+ `UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id > 0`, id))
if err != nil {
return err
}
- return r.updatePlaylist(id, ids)
-}
-
-func (r *playlistRepository) isWritable(playlistId string) bool {
- usr := loggedUser(r.ctx)
- if usr.IsAdmin {
- return true
+ // Step 2: Assign new sequential positive IDs using UPDATE...FROM with a CTE.
+ // The CTE is fully materialized before the UPDATE begins, avoiding self-referencing issues.
+ // ORDER BY id DESC restores original order since IDs are now negative.
+ _, err = r.executeSQL(Expr(
+ `WITH new_ids AS (
+ SELECT rowid as rid, ROW_NUMBER() OVER (ORDER BY id DESC) as new_id
+ FROM playlist_tracks WHERE playlist_id = ?
+ )
+ UPDATE playlist_tracks SET id = new_ids.new_id
+ FROM new_ids
+ WHERE playlist_tracks.rowid = new_ids.rid AND playlist_tracks.playlist_id = ?`, id, id))
+ if err != nil {
+ return err
}
- pls, err := r.Get(playlistId)
- return err == nil && pls.OwnerID == usr.ID
+ return r.refreshCounters(&model.Playlist{ID: id})
}
var _ model.PlaylistRepository = (*playlistRepository)(nil)
diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go
index 5a82964c9..cfabd0983 100644
--- a/persistence/playlist_repository_test.go
+++ b/persistence/playlist_repository_test.go
@@ -1,13 +1,8 @@
package persistence
import (
- "context"
- "time"
-
- "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -17,7 +12,7 @@ var _ = Describe("PlaylistRepository", func() {
var repo model.PlaylistRepository
BeforeEach(func() {
- ctx := log.NewContext(context.TODO())
+ ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(ctx, GetDBXBuilder())
})
@@ -79,13 +74,13 @@ var _ = Describe("PlaylistRepository", func() {
It("Put/Exists/Delete", func() {
By("saves the playlist to the DB")
newPls := model.Playlist{Name: "Great!", OwnerID: "userid"}
- newPls.AddTracks([]string{"1004", "1003"})
+ newPls.AddMediaFilesByID([]string{"1004", "1003"})
By("saves the playlist to the DB")
Expect(repo.Put(&newPls)).To(BeNil())
By("adds repeated songs to a playlist and keeps the order")
- newPls.AddTracks([]string{"1004"})
+ newPls.AddMediaFilesByID([]string{"1004"})
Expect(repo.Put(&newPls)).To(BeNil())
saved, _ := repo.GetWithTracks(newPls.ID, true, false)
Expect(saved.Tracks).To(HaveLen(3))
@@ -112,96 +107,91 @@ var _ = Describe("PlaylistRepository", func() {
})
})
- Context("Smart Playlists", func() {
- var rules *criteria.Criteria
- BeforeEach(func() {
- rules = &criteria.Criteria{
- Expression: criteria.All{
- criteria.Contains{"title": "love"},
- },
+ Describe("GetPlaylists", func() {
+ It("returns playlists for a track", func() {
+ pls, err := repo.GetPlaylists(songRadioactivity.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls).To(HaveLen(1))
+ Expect(pls[0].ID).To(Equal(plsBest.ID))
+ })
+
+ It("returns empty when none", func() {
+ pls, err := repo.GetPlaylists("9999")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls).To(HaveLen(0))
+ })
+ })
+
+ Describe("Track Deletion and Renumbering", func() {
+ var testPlaylistID string
+
+ AfterEach(func() {
+ if testPlaylistID != "" {
+ Expect(repo.Delete(testPlaylistID)).To(BeNil())
+ testPlaylistID = ""
}
})
- Context("valid rules", func() {
- Specify("Put/Get", func() {
- newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
- Expect(repo.Put(&newPls)).To(Succeed())
- savedPls, err := repo.Get(newPls.ID)
- Expect(err).ToNot(HaveOccurred())
- Expect(savedPls.Rules).To(Equal(rules))
- })
+ // helper to get track positions and media file IDs
+ getTrackInfo := func(playlistID string) (ids []string, mediaFileIDs []string) {
+ pls, err := repo.GetWithTracks(playlistID, false, false)
+ Expect(err).ToNot(HaveOccurred())
+ for _, t := range pls.Tracks {
+ ids = append(ids, t.ID)
+ mediaFileIDs = append(mediaFileIDs, t.MediaFileID)
+ }
+ return
+ }
+
+ It("renumbers correctly after deleting a track from the middle", func() {
+ By("creating a playlist with 4 tracks")
+ newPls := model.Playlist{Name: "Renumber Test Middle", OwnerID: "userid"}
+ newPls.AddMediaFilesByID([]string{"1001", "1002", "1003", "1004"})
+ Expect(repo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ By("deleting the second track (position 2)")
+ tracksRepo := repo.Tracks(newPls.ID, false)
+ Expect(tracksRepo.Delete("2")).To(Succeed())
+
+ By("verifying remaining tracks are renumbered sequentially")
+ ids, mediaFileIDs := getTrackInfo(newPls.ID)
+ Expect(ids).To(Equal([]string{"1", "2", "3"}))
+ Expect(mediaFileIDs).To(Equal([]string{"1001", "1003", "1004"}))
})
- Context("invalid rules", func() {
- It("fails to Put it in the DB", func() {
- rules = &criteria.Criteria{
- // This is invalid because "contains" cannot have multiple fields
- Expression: criteria.All{
- criteria.Contains{"genre": "Hardcore", "filetype": "mp3"},
- },
- }
- newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
- Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression")))
- })
+ It("renumbers correctly after deleting the first track", func() {
+ By("creating a playlist with 3 tracks")
+ newPls := model.Playlist{Name: "Renumber Test First", OwnerID: "userid"}
+ newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"})
+ Expect(repo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ By("deleting the first track (position 1)")
+ tracksRepo := repo.Tracks(newPls.ID, false)
+ Expect(tracksRepo.Delete("1")).To(Succeed())
+
+ By("verifying remaining tracks are renumbered sequentially")
+ ids, mediaFileIDs := getTrackInfo(newPls.ID)
+ Expect(ids).To(Equal([]string{"1", "2"}))
+ Expect(mediaFileIDs).To(Equal([]string{"1002", "1003"}))
})
- // TODO Validate these tests
- XContext("child smart playlists", func() {
- When("refresh day has expired", func() {
- It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
- conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
+ It("renumbers correctly after deleting the last track", func() {
+ By("creating a playlist with 3 tracks")
+ newPls := model.Playlist{Name: "Renumber Test Last", OwnerID: "userid"}
+ newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"})
+ Expect(repo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
- nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
- Expect(repo.Put(&nestedPls)).To(Succeed())
+ By("deleting the last track (position 3)")
+ tracksRepo := repo.Tracks(newPls.ID, false)
+ Expect(tracksRepo.Delete("3")).To(Succeed())
- parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
- Expression: criteria.All{
- criteria.InPlaylist{"id": nestedPls.ID},
- },
- }}
- Expect(repo.Put(&parentPls)).To(Succeed())
-
- nestedPlsRead, err := repo.Get(nestedPls.ID)
- Expect(err).ToNot(HaveOccurred())
-
- _, err = repo.GetWithTracks(parentPls.ID, true, false)
- Expect(err).ToNot(HaveOccurred())
-
- // Check that the nested playlist was refreshed by parent get by verifying evaluatedAt is updated since first nestedPls get
- nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
- Expect(err).ToNot(HaveOccurred())
-
- Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally(">", *nestedPlsRead.EvaluatedAt))
- })
- })
-
- When("refresh day has not expired", func() {
- It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
- conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
-
- nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
- Expect(repo.Put(&nestedPls)).To(Succeed())
-
- parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
- Expression: criteria.All{
- criteria.InPlaylist{"id": nestedPls.ID},
- },
- }}
- Expect(repo.Put(&parentPls)).To(Succeed())
-
- nestedPlsRead, err := repo.Get(nestedPls.ID)
- Expect(err).ToNot(HaveOccurred())
-
- _, err = repo.GetWithTracks(parentPls.ID, true, false)
- Expect(err).ToNot(HaveOccurred())
-
- // Check that the nested playlist was not refreshed by parent get by verifying evaluatedAt is not updated since first nestedPls get
- nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
- Expect(err).ToNot(HaveOccurred())
-
- Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
- })
- })
+ By("verifying remaining tracks are renumbered sequentially")
+ ids, mediaFileIDs := getTrackInfo(newPls.ID)
+ Expect(ids).To(Equal([]string{"1", "2"}))
+ Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"}))
})
})
})
diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go
index d33bd5113..1a7062cc2 100644
--- a/persistence/playlist_track_repository.go
+++ b/persistence/playlist_track_repository.go
@@ -47,14 +47,15 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
p.db = r.db
p.tableName = "playlist_tracks"
p.registerModel(&model.PlaylistTrack{}, map[string]filterFunc{
- "missing": booleanFilter,
+ "missing": booleanFilter,
+ "library_id": libraryIdFilter,
})
p.setSortMappings(
map[string]string{
"id": "playlist_tracks.id",
"artist": "order_artist_name",
"album_artist": "order_album_artist_name",
- "album": "order_album_name, order_album_artist_name",
+ "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
"title": "order_title",
// To make sure these fields will be whitelisted
"duration": "duration",
@@ -83,26 +84,28 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er
return r.count(query, r.parseRestOptions(r.ctx, options...))
}
-func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
+func (r *playlistTrackRepository) Read(id string) (any, error) {
+ userID := loggedUser(r.ctx).ID
sel := r.newSelect().
LeftJoin("annotation on ("+
"annotation.item_id = media_file_id"+
" AND annotation.item_type = 'media_file'"+
- " AND annotation.user_id = '"+userId(r.ctx)+"')").
+ " AND annotation.user_id = '"+userID+"')").
Columns(
"coalesce(starred, 0) as starred",
"coalesce(play_count, 0) as play_count",
"coalesce(rating, 0) as rating",
"starred_at",
"play_date",
+ "rated_at",
"f.*",
"playlist_tracks.*",
).
Join("media_file f on f.id = media_file_id").
- Where(And{Eq{"playlist_id": r.playlistId}, Eq{"id": id}})
+ Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}})
var trk dbPlaylistTrack
err := r.queryOne(sel, &trk)
- return trk.PlaylistTrack.MediaFile, err
+ return trk.PlaylistTrack, err
}
func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) {
@@ -125,7 +128,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]
return ids, nil
}
-func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
+func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
@@ -133,19 +136,11 @@ func (r *playlistTrackRepository) EntityName() string {
return "playlist_tracks"
}
-func (r *playlistTrackRepository) NewInstance() interface{} {
+func (r *playlistTrackRepository) NewInstance() any {
return &model.PlaylistTrack{}
}
-func (r *playlistTrackRepository) isTracksEditable() bool {
- return r.playlistRepo.isWritable(r.playlistId) && !r.playlist.IsSmartPlaylist()
-}
-
func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
- if !r.isTracksEditable() {
- return 0, rest.ErrPermissionDenied
- }
-
if len(mediaFileIds) > 0 {
log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds)
} else {
@@ -193,22 +188,7 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) {
return r.addMediaFileIds(clauses)
}
-// Get ids from all current tracks
-func (r *playlistTrackRepository) getTracks() ([]string, error) {
- all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id")
- var ids []string
- err := r.queryAllSlice(all, &ids)
- if err != nil {
- log.Error(r.ctx, "Error querying current tracks from playlist", "playlistId", r.playlistId, err)
- return nil, err
- }
- return ids, nil
-}
-
func (r *playlistTrackRepository) Delete(ids ...string) error {
- if !r.isTracksEditable() {
- return rest.ErrPermissionDenied
- }
err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}})
if err != nil {
return err
@@ -218,9 +198,6 @@ func (r *playlistTrackRepository) Delete(ids ...string) error {
}
func (r *playlistTrackRepository) DeleteAll() error {
- if !r.isTracksEditable() {
- return rest.ErrPermissionDenied
- }
err := r.delete(Eq{"playlist_id": r.playlistId})
if err != nil {
return err
@@ -229,16 +206,45 @@ func (r *playlistTrackRepository) DeleteAll() error {
return r.playlistRepo.renumber(r.playlistId)
}
+// Reorder moves a track from pos to newPos, shifting other tracks accordingly.
func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
- if !r.isTracksEditable() {
- return rest.ErrPermissionDenied
+ if pos == newPos {
+ return nil
}
- ids, err := r.getTracks()
+ pid := r.playlistId
+
+ // Step 1: Move the source track out of the way (temporary sentinel value)
+ _, err := r.executeSQL(Expr(
+ `UPDATE playlist_tracks SET id = -999999 WHERE playlist_id = ? AND id = ?`, pid, pos))
if err != nil {
return err
}
- newOrder := slice.Move(ids, pos-1, newPos-1)
- return r.playlistRepo.updatePlaylist(r.playlistId, newOrder)
+
+ // Step 2: Shift the affected range using negative values to avoid unique constraint violations
+ if pos < newPos {
+ _, err = r.executeSQL(Expr(
+ `UPDATE playlist_tracks SET id = -(id - 1) WHERE playlist_id = ? AND id > ? AND id <= ?`,
+ pid, pos, newPos))
+ } else {
+ _, err = r.executeSQL(Expr(
+ `UPDATE playlist_tracks SET id = -(id + 1) WHERE playlist_id = ? AND id >= ? AND id < ?`,
+ pid, newPos, pos))
+ }
+ if err != nil {
+ return err
+ }
+
+ // Step 3: Flip the shifted range back to positive
+ _, err = r.executeSQL(Expr(
+ `UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0 AND id != -999999`, pid))
+ if err != nil {
+ return err
+ }
+
+ // Step 4: Place the source track at its new position
+ _, err = r.executeSQL(Expr(
+ `UPDATE playlist_tracks SET id = ? WHERE playlist_id = ? AND id = -999999`, newPos, pid))
+ return err
}
var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil)
diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go
index fe42dd7fc..ba69ec746 100644
--- a/persistence/playqueue_repository.go
+++ b/persistence/playqueue_repository.go
@@ -2,6 +2,7 @@ package persistence
import (
"context"
+ "errors"
"strings"
"time"
@@ -27,7 +28,7 @@ func NewPlayQueueRepository(ctx context.Context, db dbx.Builder) model.PlayQueue
type playQueue struct {
ID string `structs:"id"`
UserID string `structs:"user_id"`
- Current string `structs:"current"`
+ Current int `structs:"current"`
Position int64 `structs:"position"`
ChangedBy string `structs:"changed_by"`
Items string `structs:"items"`
@@ -35,22 +36,39 @@ type playQueue struct {
UpdatedAt time.Time `structs:"updated_at"`
}
-func (r *playQueueRepository) Store(q *model.PlayQueue) error {
+func (r *playQueueRepository) Store(q *model.PlayQueue, colNames ...string) error {
u := loggedUser(r.ctx)
- err := r.clearPlayQueue(q.UserID)
- if err != nil {
- log.Error(r.ctx, "Error deleting previous playqueue", "user", u.UserName, err)
+
+ // Always find existing playqueue for this user
+ existingQueue, err := r.Retrieve(q.UserID)
+ if err != nil && !errors.Is(err, model.ErrNotFound) {
+ log.Error(r.ctx, "Error retrieving existing playqueue", "user", u.UserName, err)
return err
}
- if len(q.Items) == 0 {
- return nil
+
+ // Use existing ID if found, otherwise keep the provided ID (which may be empty for new records)
+ if !errors.Is(err, model.ErrNotFound) && existingQueue.ID != "" {
+ q.ID = existingQueue.ID
}
+
+ // When no specific columns are provided, we replace the whole queue
+ if len(colNames) == 0 {
+ err := r.clearPlayQueue(q.UserID)
+ if err != nil {
+ log.Error(r.ctx, "Error deleting previous playqueue", "user", u.UserName, err)
+ return err
+ }
+ if len(q.Items) == 0 {
+ return nil
+ }
+ }
+
pq := r.fromModel(q)
if pq.ID == "" {
pq.CreatedAt = time.Now()
}
pq.UpdatedAt = time.Now()
- _, err = r.put(pq.ID, pq)
+ _, err = r.put(pq.ID, pq, colNames...)
if err != nil {
log.Error(r.ctx, "Error saving playqueue", "user", u.UserName, err)
return err
@@ -58,12 +76,20 @@ func (r *playQueueRepository) Store(q *model.PlayQueue) error {
return nil
}
+func (r *playQueueRepository) RetrieveWithMediaFiles(userId string) (*model.PlayQueue, error) {
+ sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId})
+ var res playQueue
+ err := r.queryOne(sel, &res)
+ q := r.toModel(&res)
+ q.Items = r.loadTracks(q.Items)
+ return &q, err
+}
+
func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error) {
sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId})
var res playQueue
err := r.queryOne(sel, &res)
- pls := r.toModel(&res)
- return &pls, err
+ return new(r.toModel(&res)), err
}
func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue {
@@ -95,12 +121,11 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue {
UpdatedAt: pq.UpdatedAt,
}
if strings.TrimSpace(pq.Items) != "" {
- tracks := strings.Split(pq.Items, ",")
- for _, t := range tracks {
+ tracks := strings.SplitSeq(pq.Items, ",")
+ for t := range tracks {
q.Items = append(q.Items, model.MediaFile{ID: t})
}
}
- q.Items = r.loadTracks(q.Items)
return q
}
@@ -145,4 +170,8 @@ func (r *playQueueRepository) clearPlayQueue(userId string) error {
return r.delete(Eq{"user_id": userId})
}
+func (r *playQueueRepository) Clear(userId string) error {
+ return r.clearPlayQueue(userId)
+}
+
var _ model.PlayQueueRepository = (*playQueueRepository)(nil)
diff --git a/persistence/playqueue_repository_test.go b/persistence/playqueue_repository_test.go
index a370e1162..2bcc88fd0 100644
--- a/persistence/playqueue_repository_test.go
+++ b/persistence/playqueue_repository_test.go
@@ -5,6 +5,7 @@ import (
"time"
"github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
@@ -18,38 +19,296 @@ var _ = Describe("PlayQueueRepository", func() {
var ctx context.Context
BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
ctx = log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlayQueueRepository(ctx, GetDBXBuilder())
})
- Describe("PlayQueues", func() {
+ Describe("Store", func() {
+ It("stores a complete playqueue", func() {
+ expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife)
+ Expect(repo.Store(expected)).To(Succeed())
+
+ actual, err := repo.RetrieveWithMediaFiles("userid")
+ Expect(err).ToNot(HaveOccurred())
+ AssertPlayQueue(expected, actual)
+ Expect(countPlayQueues(repo, "userid")).To(Equal(1))
+ })
+
+ It("replaces existing playqueue when storing without column names", func() {
+ By("Storing initial playqueue")
+ initial := aPlayQueue("userid", 0, 100, songComeTogether)
+ Expect(repo.Store(initial)).To(Succeed())
+
+ By("Storing replacement playqueue")
+ replacement := aPlayQueue("userid", 1, 200, songDayInALife, songAntenna)
+ Expect(repo.Store(replacement)).To(Succeed())
+
+ actual, err := repo.RetrieveWithMediaFiles("userid")
+ Expect(err).ToNot(HaveOccurred())
+ AssertPlayQueue(replacement, actual)
+ Expect(countPlayQueues(repo, "userid")).To(Equal(1))
+ })
+
+ It("clears playqueue when storing empty items", func() {
+ By("Storing initial playqueue")
+ initial := aPlayQueue("userid", 0, 100, songComeTogether)
+ Expect(repo.Store(initial)).To(Succeed())
+
+ By("Storing empty playqueue")
+ empty := aPlayQueue("userid", 0, 0)
+ Expect(repo.Store(empty)).To(Succeed())
+
+ By("Verifying playqueue is cleared")
+ _, err := repo.Retrieve("userid")
+ Expect(err).To(MatchError(model.ErrNotFound))
+ })
+
+ It("updates only current field when specified", func() {
+ By("Storing initial playqueue")
+ initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife)
+ Expect(repo.Store(initial)).To(Succeed())
+
+ By("Getting the existing playqueue to obtain its ID")
+ existing, err := repo.Retrieve("userid")
+ Expect(err).ToNot(HaveOccurred())
+
+ By("Updating only current field")
+ update := &model.PlayQueue{
+ ID: existing.ID, // Use existing ID for partial update
+ UserID: "userid",
+ Current: 1,
+ ChangedBy: "test-update",
+ }
+ Expect(repo.Store(update, "current")).To(Succeed())
+
+ By("Verifying only current was updated")
+ actual, err := repo.Retrieve("userid")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(actual.Current).To(Equal(1))
+ Expect(actual.Position).To(Equal(int64(100))) // Should remain unchanged
+ Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged
+ })
+
+ It("updates only position field when specified", func() {
+ By("Storing initial playqueue")
+ initial := aPlayQueue("userid", 1, 100, songComeTogether, songDayInALife)
+ Expect(repo.Store(initial)).To(Succeed())
+
+ By("Getting the existing playqueue to obtain its ID")
+ existing, err := repo.Retrieve("userid")
+ Expect(err).ToNot(HaveOccurred())
+
+ By("Updating only position field")
+ update := &model.PlayQueue{
+ ID: existing.ID, // Use existing ID for partial update
+ UserID: "userid",
+ Position: 500,
+ ChangedBy: "test-update",
+ }
+ Expect(repo.Store(update, "position")).To(Succeed())
+
+ By("Verifying only position was updated")
+ actual, err := repo.Retrieve("userid")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(actual.Position).To(Equal(int64(500)))
+ Expect(actual.Current).To(Equal(1)) // Should remain unchanged
+ Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged
+ })
+
+ It("updates multiple specified fields", func() {
+ By("Storing initial playqueue")
+ initial := aPlayQueue("userid", 0, 100, songComeTogether)
+ Expect(repo.Store(initial)).To(Succeed())
+
+ By("Getting the existing playqueue to obtain its ID")
+ existing, err := repo.Retrieve("userid")
+ Expect(err).ToNot(HaveOccurred())
+
+ By("Updating current and position fields")
+ update := &model.PlayQueue{
+ ID: existing.ID, // Use existing ID for partial update
+ UserID: "userid",
+ Current: 1,
+ Position: 300,
+ ChangedBy: "test-update",
+ }
+ Expect(repo.Store(update, "current", "position")).To(Succeed())
+
+ By("Verifying both fields were updated")
+ actual, err := repo.Retrieve("userid")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(actual.Current).To(Equal(1))
+ Expect(actual.Position).To(Equal(int64(300)))
+ Expect(actual.Items).To(HaveLen(1)) // Should remain unchanged
+ })
+
+ It("preserves existing data when updating with empty items list and column names", func() {
+ By("Storing initial playqueue")
+ initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife)
+ Expect(repo.Store(initial)).To(Succeed())
+
+ By("Getting the existing playqueue to obtain its ID")
+ existing, err := repo.Retrieve("userid")
+ Expect(err).ToNot(HaveOccurred())
+
+ By("Updating only position with empty items")
+ update := &model.PlayQueue{
+ ID: existing.ID, // Use existing ID for partial update
+ UserID: "userid",
+ Position: 200,
+ ChangedBy: "test-update",
+ Items: []model.MediaFile{}, // Empty items
+ }
+ Expect(repo.Store(update, "position")).To(Succeed())
+
+ By("Verifying items are preserved")
+ actual, err := repo.Retrieve("userid")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(actual.Position).To(Equal(int64(200)))
+ Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged
+ })
+
+ It("ensures only one record per user by reusing existing record ID", func() {
+ By("Storing initial playqueue")
+ initial := aPlayQueue("userid", 0, 100, songComeTogether)
+ Expect(repo.Store(initial)).To(Succeed())
+ initialCount := countPlayQueues(repo, "userid")
+ Expect(initialCount).To(Equal(1))
+
+ By("Storing another playqueue with different ID but same user")
+ different := aPlayQueue("userid", 1, 200, songDayInALife)
+ different.ID = "different-id" // Force a different ID
+ Expect(repo.Store(different)).To(Succeed())
+
+ By("Verifying only one record exists for the user")
+ finalCount := countPlayQueues(repo, "userid")
+ Expect(finalCount).To(Equal(1))
+
+ By("Verifying the record was updated, not duplicated")
+ actual, err := repo.Retrieve("userid")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(actual.Current).To(Equal(1)) // Should be updated value
+ Expect(actual.Position).To(Equal(int64(200))) // Should be updated value
+ Expect(actual.Items).To(HaveLen(1)) // Should be new items
+ Expect(actual.Items[0].ID).To(Equal(songDayInALife.ID))
+ })
+
+ It("ensures only one record per user even with partial updates", func() {
+ By("Storing initial playqueue")
+ initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife)
+ Expect(repo.Store(initial)).To(Succeed())
+ initialCount := countPlayQueues(repo, "userid")
+ Expect(initialCount).To(Equal(1))
+
+ By("Storing partial update with different ID but same user")
+ partialUpdate := &model.PlayQueue{
+ ID: "completely-different-id", // Use a completely different ID
+ UserID: "userid",
+ Current: 1,
+ ChangedBy: "test-partial",
+ }
+ Expect(repo.Store(partialUpdate, "current")).To(Succeed())
+
+ By("Verifying only one record still exists for the user")
+ finalCount := countPlayQueues(repo, "userid")
+ Expect(finalCount).To(Equal(1))
+
+ By("Verifying the existing record was updated with new current value")
+ actual, err := repo.Retrieve("userid")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(actual.Current).To(Equal(1)) // Should be updated value
+ Expect(actual.Position).To(Equal(int64(100))) // Should remain unchanged
+ Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged
+ })
+ })
+
+ Describe("Retrieve", func() {
It("returns notfound error if there's no playqueue for the user", func() {
_, err := repo.Retrieve("user999")
Expect(err).To(MatchError(model.ErrNotFound))
})
- It("stores and retrieves the playqueue for the user", func() {
+ It("retrieves the playqueue with only track IDs (no full MediaFile data)", func() {
By("Storing a playqueue for the user")
- expected := aPlayQueue("userid", songDayInALife.ID, 123, songComeTogether, songDayInALife)
+ expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife)
Expect(repo.Store(expected)).To(Succeed())
actual, err := repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
- AssertPlayQueue(expected, actual)
+ // Basic playqueue properties should match
+ Expect(actual.ID).To(Equal(expected.ID))
+ Expect(actual.UserID).To(Equal(expected.UserID))
+ Expect(actual.Current).To(Equal(expected.Current))
+ Expect(actual.Position).To(Equal(expected.Position))
+ Expect(actual.ChangedBy).To(Equal(expected.ChangedBy))
+ Expect(actual.Items).To(HaveLen(len(expected.Items)))
- By("Storing a new playqueue for the same user")
+ // Items should only contain IDs, not full MediaFile data
+ for i, item := range actual.Items {
+ Expect(item.ID).To(Equal(expected.Items[i].ID))
+ // These fields should be empty since we're not loading full MediaFiles
+ Expect(item.Title).To(BeEmpty())
+ Expect(item.Path).To(BeEmpty())
+ Expect(item.Album).To(BeEmpty())
+ Expect(item.Artist).To(BeEmpty())
+ }
+ })
- another := aPlayQueue("userid", songRadioactivity.ID, 321, songAntenna, songRadioactivity)
- Expect(repo.Store(another)).To(Succeed())
+ It("returns items with IDs even when some tracks don't exist in the DB", func() {
+ // Add a new song to the DB
+ newSong := songRadioactivity
+ newSong.ID = "temp-track"
+ newSong.Path = "/new-path"
+ mfRepo := NewMediaFileRepository(ctx, GetDBXBuilder())
- actual, err = repo.Retrieve("userid")
+ Expect(mfRepo.Put(&newSong)).To(Succeed())
+
+ // Create a playqueue with the new song
+ pq := aPlayQueue("userid", 0, 0, newSong, songAntenna)
+ Expect(repo.Store(pq)).To(Succeed())
+
+ // Delete the new song from the database
+ Expect(mfRepo.Delete("temp-track")).To(Succeed())
+
+ // Retrieve the playqueue with Retrieve method
+ actual, err := repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
- AssertPlayQueue(another, actual)
- Expect(countPlayQueues(repo, "userid")).To(Equal(1))
+ // The playqueue should still contain both track IDs (including the deleted one)
+ Expect(actual.Items).To(HaveLen(2))
+ Expect(actual.Items[0].ID).To(Equal("temp-track"))
+ Expect(actual.Items[1].ID).To(Equal(songAntenna.ID))
+
+ // Items should only contain IDs, no other data
+ for _, item := range actual.Items {
+ Expect(item.Title).To(BeEmpty())
+ Expect(item.Path).To(BeEmpty())
+ Expect(item.Album).To(BeEmpty())
+ Expect(item.Artist).To(BeEmpty())
+ }
+ })
+ })
+
+ Describe("RetrieveWithMediaFiles", func() {
+ It("returns notfound error if there's no playqueue for the user", func() {
+ _, err := repo.RetrieveWithMediaFiles("user999")
+ Expect(err).To(MatchError(model.ErrNotFound))
+ })
+
+ It("retrieves the playqueue with full MediaFile data", func() {
+ By("Storing a playqueue for the user")
+
+ expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife)
+ Expect(repo.Store(expected)).To(Succeed())
+
+ actual, err := repo.RetrieveWithMediaFiles("userid")
+ Expect(err).ToNot(HaveOccurred())
+
+ AssertPlayQueue(expected, actual)
})
It("does not return tracks if they don't exist in the DB", func() {
@@ -62,11 +321,11 @@ var _ = Describe("PlayQueueRepository", func() {
Expect(mfRepo.Put(&newSong)).To(Succeed())
// Create a playqueue with the new song
- pq := aPlayQueue("userid", newSong.ID, 0, newSong, songAntenna)
+ pq := aPlayQueue("userid", 0, 0, newSong, songAntenna)
Expect(repo.Store(pq)).To(Succeed())
// Retrieve the playqueue
- actual, err := repo.Retrieve("userid")
+ actual, err := repo.RetrieveWithMediaFiles("userid")
Expect(err).ToNot(HaveOccurred())
// The playqueue should contain both tracks
@@ -76,7 +335,7 @@ var _ = Describe("PlayQueueRepository", func() {
Expect(mfRepo.Delete("temp-track")).To(Succeed())
// Retrieve the playqueue
- actual, err = repo.Retrieve("userid")
+ actual, err = repo.RetrieveWithMediaFiles("userid")
Expect(err).ToNot(HaveOccurred())
// The playqueue should not contain the deleted track
@@ -84,6 +343,59 @@ var _ = Describe("PlayQueueRepository", func() {
Expect(actual.Items[0].ID).To(Equal(songAntenna.ID))
})
})
+
+ Describe("Clear", func() {
+ It("clears an existing playqueue", func() {
+ By("Storing a playqueue")
+ expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife)
+ Expect(repo.Store(expected)).To(Succeed())
+
+ By("Verifying playqueue exists")
+ _, err := repo.Retrieve("userid")
+ Expect(err).ToNot(HaveOccurred())
+
+ By("Clearing the playqueue")
+ Expect(repo.Clear("userid")).To(Succeed())
+
+ By("Verifying playqueue is cleared")
+ _, err = repo.Retrieve("userid")
+ Expect(err).To(MatchError(model.ErrNotFound))
+ })
+
+ It("does not error when clearing non-existent playqueue", func() {
+ // Clear should not error even if no playqueue exists
+ Expect(repo.Clear("nonexistent-user")).To(Succeed())
+ })
+
+ It("only clears the specified user's playqueue", func() {
+ By("Creating users in the database to avoid foreign key constraints")
+ userRepo := NewUserRepository(ctx, GetDBXBuilder())
+ user1 := &model.User{ID: "user1", UserName: "user1", Name: "User 1", Email: "user1@test.com"}
+ user2 := &model.User{ID: "user2", UserName: "user2", Name: "User 2", Email: "user2@test.com"}
+ Expect(userRepo.Put(user1)).To(Succeed())
+ Expect(userRepo.Put(user2)).To(Succeed())
+
+ By("Storing playqueues for two users")
+ user1Queue := aPlayQueue("user1", 0, 100, songComeTogether)
+ user2Queue := aPlayQueue("user2", 1, 200, songDayInALife)
+ Expect(repo.Store(user1Queue)).To(Succeed())
+ Expect(repo.Store(user2Queue)).To(Succeed())
+
+ By("Clearing only user1's playqueue")
+ Expect(repo.Clear("user1")).To(Succeed())
+
+ By("Verifying user1's playqueue is cleared")
+ _, err := repo.Retrieve("user1")
+ Expect(err).To(MatchError(model.ErrNotFound))
+
+ By("Verifying user2's playqueue still exists")
+ actual, err := repo.Retrieve("user2")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(actual.UserID).To(Equal("user2"))
+ Expect(actual.Current).To(Equal(1))
+ Expect(actual.Position).To(Equal(int64(200)))
+ })
+ })
})
func countPlayQueues(repo model.PlayQueueRepository, userId string) int {
@@ -107,7 +419,7 @@ func AssertPlayQueue(expected, actual *model.PlayQueue) {
}
}
-func aPlayQueue(userId, current string, position int64, items ...model.MediaFile) *model.PlayQueue {
+func aPlayQueue(userId string, current int, position int64, items ...model.MediaFile) *model.PlayQueue {
createdAt := time.Now()
updatedAt := createdAt.Add(time.Minute)
return &model.PlayQueue{
diff --git a/persistence/plugin_cleanup.go b/persistence/plugin_cleanup.go
new file mode 100644
index 000000000..0202726e4
--- /dev/null
+++ b/persistence/plugin_cleanup.go
@@ -0,0 +1,86 @@
+package persistence
+
+import (
+ "github.com/pocketbase/dbx"
+)
+
+// cleanupPluginUserReferences removes a user ID from all plugins' users JSON arrays
+// and auto-disables plugins that lose their only permitted user (when users permission is required).
+// This is called from userRepository.Delete() to maintain referential integrity.
+func cleanupPluginUserReferences(db dbx.Builder, userID string) error {
+ // SQLite JSON function: json_remove removes the element at the path where user matches.
+ // We use a subquery with json_each to find and remove the user ID from the array.
+ // This updates all plugins where the users array contains the given user ID.
+ _, err := db.NewQuery(`
+ UPDATE plugin
+ SET users = (
+ SELECT json_group_array(value)
+ FROM json_each(plugin.users)
+ WHERE value != {:userID}
+ ),
+ updated_at = CURRENT_TIMESTAMP
+ WHERE users IS NOT NULL
+ AND users != ''
+ AND EXISTS (SELECT 1 FROM json_each(plugin.users) WHERE value = {:userID})
+ `).Bind(dbx.Params{"userID": userID}).Execute()
+ if err != nil {
+ return err
+ }
+
+ // Auto-disable plugins that:
+ // 1. Are currently enabled
+ // 2. Require users permission (manifest has permissions.users)
+ // 3. Don't have allUsers enabled
+ // 4. Now have an empty users array after cleanup
+ //
+ // The manifest check uses JSON path to see if permissions.users exists.
+ _, err = db.NewQuery(`
+ UPDATE plugin
+ SET enabled = false,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE enabled = true
+ AND all_users = false
+ AND json_extract(manifest, '$.permissions.users') IS NOT NULL
+ AND (users IS NULL OR users = '' OR users = '[]' OR json_array_length(users) = 0)
+ `).Execute()
+ return err
+}
+
+// cleanupPluginLibraryReferences removes a library ID from all plugins' libraries JSON arrays
+// and auto-disables plugins that lose their only permitted library (when library permission is required).
+// This is called from libraryRepository.Delete() to maintain referential integrity.
+func cleanupPluginLibraryReferences(db dbx.Builder, libraryID int) error {
+ // SQLite JSON function: we filter out the library ID from the array.
+ // Libraries are stored as integers in the JSON array.
+ _, err := db.NewQuery(`
+ UPDATE plugin
+ SET libraries = (
+ SELECT json_group_array(value)
+ FROM json_each(plugin.libraries)
+ WHERE CAST(value AS INTEGER) != {:libraryID}
+ ),
+ updated_at = CURRENT_TIMESTAMP
+ WHERE libraries IS NOT NULL
+ AND libraries != ''
+ AND EXISTS (SELECT 1 FROM json_each(plugin.libraries) WHERE CAST(value AS INTEGER) = {:libraryID})
+ `).Bind(dbx.Params{"libraryID": libraryID}).Execute()
+ if err != nil {
+ return err
+ }
+
+ // Auto-disable plugins that:
+ // 1. Are currently enabled
+ // 2. Require library permission (manifest has permissions.library)
+ // 3. Don't have allLibraries enabled
+ // 4. Now have an empty libraries array after cleanup
+ _, err = db.NewQuery(`
+ UPDATE plugin
+ SET enabled = false,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE enabled = true
+ AND all_libraries = false
+ AND json_extract(manifest, '$.permissions.library') IS NOT NULL
+ AND (libraries IS NULL OR libraries = '' OR libraries = '[]' OR json_array_length(libraries) = 0)
+ `).Execute()
+ return err
+}
diff --git a/persistence/plugin_cleanup_test.go b/persistence/plugin_cleanup_test.go
new file mode 100644
index 000000000..bfe6d60ca
--- /dev/null
+++ b/persistence/plugin_cleanup_test.go
@@ -0,0 +1,263 @@
+package persistence
+
+import (
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Plugin Cleanup", func() {
+ var pluginRepo model.PluginRepository
+ var userRepo model.UserRepository
+ var libraryRepo model.LibraryRepository
+
+ BeforeEach(func() {
+ ctx := GinkgoT().Context()
+ ctx = request.WithUser(ctx, model.User{ID: "admin", UserName: "admin", IsAdmin: true})
+ db := GetDBXBuilder()
+ pluginRepo = NewPluginRepository(ctx, db)
+ userRepo = NewUserRepository(ctx, db)
+ libraryRepo = NewLibraryRepository(ctx, db)
+
+ // Clean up any existing plugins
+ all, _ := pluginRepo.GetAll()
+ for _, p := range all {
+ _ = pluginRepo.Delete(p.ID)
+ }
+ })
+
+ AfterEach(func() {
+ // Clean up after tests
+ all, _ := pluginRepo.GetAll()
+ for _, p := range all {
+ _ = pluginRepo.Delete(p.ID)
+ }
+ })
+
+ Describe("cleanupPluginUserReferences", func() {
+ It("removes user ID from plugin users array", func() {
+ // Create a plugin with multiple users
+ plugin := &model.Plugin{
+ ID: "test-plugin",
+ Path: "/plugins/test.wasm",
+ Manifest: `{"name":"test"}`,
+ SHA256: "abc123",
+ Users: `["user1","user2","user3"]`,
+ Enabled: true,
+ }
+ Expect(pluginRepo.Put(plugin)).To(Succeed())
+
+ // Clean up user2 reference
+ db := GetDBXBuilder()
+ Expect(cleanupPluginUserReferences(db, "user2")).To(Succeed())
+
+ // Verify user2 was removed
+ updated, err := pluginRepo.Get("test-plugin")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updated.Users).To(Equal(`["user1","user3"]`))
+ Expect(updated.Enabled).To(BeTrue()) // Still has users, should remain enabled
+ })
+
+ It("auto-disables plugin when last permitted user is removed", func() {
+ // Create a plugin that requires users permission with only one user
+ plugin := &model.Plugin{
+ ID: "user-plugin",
+ Path: "/plugins/user.wasm",
+ Manifest: `{"name":"user-plugin","permissions":{"users":{}}}`,
+ SHA256: "def456",
+ Users: `["only-user"]`,
+ AllUsers: false,
+ Enabled: true,
+ }
+ Expect(pluginRepo.Put(plugin)).To(Succeed())
+
+ // Remove the only user
+ db := GetDBXBuilder()
+ Expect(cleanupPluginUserReferences(db, "only-user")).To(Succeed())
+
+ // Verify plugin was auto-disabled
+ updated, err := pluginRepo.Get("user-plugin")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updated.Users).To(Equal(`[]`))
+ Expect(updated.Enabled).To(BeFalse())
+ })
+
+ It("does not disable plugin when allUsers is true", func() {
+ plugin := &model.Plugin{
+ ID: "all-users-plugin",
+ Path: "/plugins/all.wasm",
+ Manifest: `{"name":"all-users","permissions":{"users":{}}}`,
+ SHA256: "ghi789",
+ Users: `["user1"]`,
+ AllUsers: true,
+ Enabled: true,
+ }
+ Expect(pluginRepo.Put(plugin)).To(Succeed())
+
+ // Remove the user (but allUsers is true)
+ db := GetDBXBuilder()
+ Expect(cleanupPluginUserReferences(db, "user1")).To(Succeed())
+
+ // Plugin should still be enabled because allUsers is true
+ updated, err := pluginRepo.Get("all-users-plugin")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updated.Enabled).To(BeTrue())
+ })
+
+ It("does not affect plugins without users permission requirement", func() {
+ plugin := &model.Plugin{
+ ID: "no-users-perm",
+ Path: "/plugins/noperm.wasm",
+ Manifest: `{"name":"no-perm"}`, // No permissions.users in manifest
+ SHA256: "jkl012",
+ Users: `["user1"]`,
+ Enabled: true,
+ }
+ Expect(pluginRepo.Put(plugin)).To(Succeed())
+
+ // Remove the user
+ db := GetDBXBuilder()
+ Expect(cleanupPluginUserReferences(db, "user1")).To(Succeed())
+
+ // Plugin should still be enabled (no users permission requirement)
+ updated, err := pluginRepo.Get("no-users-perm")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updated.Users).To(Equal(`[]`))
+ Expect(updated.Enabled).To(BeTrue())
+ })
+ })
+
+ Describe("cleanupPluginLibraryReferences", func() {
+ It("removes library ID from plugin libraries array", func() {
+ // Create a plugin with multiple libraries
+ plugin := &model.Plugin{
+ ID: "lib-plugin",
+ Path: "/plugins/lib.wasm",
+ Manifest: `{"name":"lib-plugin"}`,
+ SHA256: "mno345",
+ Libraries: `[1,2,3]`,
+ Enabled: true,
+ }
+ Expect(pluginRepo.Put(plugin)).To(Succeed())
+
+ // Clean up library 2 reference
+ db := GetDBXBuilder()
+ Expect(cleanupPluginLibraryReferences(db, 2)).To(Succeed())
+
+ // Verify library 2 was removed
+ updated, err := pluginRepo.Get("lib-plugin")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updated.Libraries).To(Equal(`[1,3]`))
+ })
+
+ It("auto-disables plugin when last permitted library is removed", func() {
+ // Create a plugin that requires library permission with only one library
+ plugin := &model.Plugin{
+ ID: "lib-only-plugin",
+ Path: "/plugins/libonly.wasm",
+ Manifest: `{"name":"lib-only","permissions":{"library":{}}}`,
+ SHA256: "pqr678",
+ Libraries: `[99]`,
+ AllLibraries: false,
+ Enabled: true,
+ }
+ Expect(pluginRepo.Put(plugin)).To(Succeed())
+
+ // Remove the only library
+ db := GetDBXBuilder()
+ Expect(cleanupPluginLibraryReferences(db, 99)).To(Succeed())
+
+ // Verify plugin was auto-disabled
+ updated, err := pluginRepo.Get("lib-only-plugin")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updated.Libraries).To(Equal(`[]`))
+ Expect(updated.Enabled).To(BeFalse())
+ })
+
+ It("does not disable plugin when allLibraries is true", func() {
+ plugin := &model.Plugin{
+ ID: "all-libs-plugin",
+ Path: "/plugins/alllibs.wasm",
+ Manifest: `{"name":"all-libs","permissions":{"library":{}}}`,
+ SHA256: "stu901",
+ Libraries: `[1]`,
+ AllLibraries: true,
+ Enabled: true,
+ }
+ Expect(pluginRepo.Put(plugin)).To(Succeed())
+
+ // Remove the library (but allLibraries is true)
+ db := GetDBXBuilder()
+ Expect(cleanupPluginLibraryReferences(db, 1)).To(Succeed())
+
+ // Plugin should still be enabled
+ updated, err := pluginRepo.Get("all-libs-plugin")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updated.Enabled).To(BeTrue())
+ })
+ })
+
+ Describe("User Delete integration", func() {
+ It("cleans up plugin references when user is deleted", func() {
+ // Create a test user
+ user := &model.User{
+ ID: "test-delete-user",
+ UserName: "plugin-cleanup-test-user",
+ IsAdmin: false,
+ }
+ user.NewPassword = "password123"
+ Expect(userRepo.Put(user)).To(Succeed())
+
+ // Create a plugin referencing this user
+ plugin := &model.Plugin{
+ ID: "user-ref-plugin",
+ Path: "/plugins/userref.wasm",
+ Manifest: `{"name":"user-ref"}`,
+ SHA256: "xyz123",
+ Users: `["test-delete-user","other-user"]`,
+ Enabled: true,
+ }
+ Expect(pluginRepo.Put(plugin)).To(Succeed())
+
+ // Delete the user
+ Expect(userRepo.Delete("test-delete-user")).To(Succeed())
+
+ // Verify user was removed from plugin
+ updated, err := pluginRepo.Get("user-ref-plugin")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updated.Users).To(Equal(`["other-user"]`))
+ })
+ })
+
+ Describe("Library Delete integration", func() {
+ It("cleans up plugin references when library is deleted", func() {
+ // Create a test library (ID > 1 since ID 1 cannot be deleted)
+ library := &model.Library{
+ ID: 99,
+ Name: "Test Library",
+ Path: "/tmp/test-lib",
+ }
+ Expect(libraryRepo.Put(library)).To(Succeed())
+
+ // Create a plugin referencing this library
+ plugin := &model.Plugin{
+ ID: "lib-ref-plugin",
+ Path: "/plugins/libref.wasm",
+ Manifest: `{"name":"lib-ref"}`,
+ SHA256: "abc789",
+ Libraries: `[99,1]`,
+ Enabled: true,
+ }
+ Expect(pluginRepo.Put(plugin)).To(Succeed())
+
+ // Delete the library
+ Expect(libraryRepo.Delete(99)).To(Succeed())
+
+ // Verify library was removed from plugin
+ updated, err := pluginRepo.Get("lib-ref-plugin")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updated.Libraries).To(Equal(`[1]`))
+ })
+ })
+})
diff --git a/persistence/plugin_repository.go b/persistence/plugin_repository.go
new file mode 100644
index 000000000..35c32de91
--- /dev/null
+++ b/persistence/plugin_repository.go
@@ -0,0 +1,171 @@
+package persistence
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ . "github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/pocketbase/dbx"
+)
+
+type pluginRepository struct {
+ sqlRepository
+}
+
+func NewPluginRepository(ctx context.Context, db dbx.Builder) model.PluginRepository {
+ r := &pluginRepository{}
+ r.ctx = ctx
+ r.db = db
+ r.registerModel(&model.Plugin{}, map[string]filterFunc{
+ "id": idFilter("plugin"),
+ "enabled": booleanFilter,
+ })
+ return r
+}
+
+func (r *pluginRepository) isPermitted() bool {
+ user := loggedUser(r.ctx)
+ return user.IsAdmin
+}
+
+func (r *pluginRepository) ClearErrors() error {
+ if !r.isPermitted() {
+ return rest.ErrPermissionDenied
+ }
+ _, err := r.db.NewQuery("UPDATE plugin SET last_error = '' WHERE last_error != ''").Execute()
+ return err
+}
+
+func (r *pluginRepository) CountAll(options ...model.QueryOptions) (int64, error) {
+ if !r.isPermitted() {
+ return 0, rest.ErrPermissionDenied
+ }
+ sql := r.newSelect()
+ return r.count(sql, options...)
+}
+
+func (r *pluginRepository) Delete(id string) error {
+ if !r.isPermitted() {
+ return rest.ErrPermissionDenied
+ }
+ return r.delete(Eq{"id": id})
+}
+
+func (r *pluginRepository) Get(id string) (*model.Plugin, error) {
+ if !r.isPermitted() {
+ return nil, rest.ErrPermissionDenied
+ }
+ sel := r.newSelect().Where(Eq{"id": id}).Columns("*")
+ res := model.Plugin{}
+ err := r.queryOne(sel, &res)
+ return &res, err
+}
+
+func (r *pluginRepository) GetAll(options ...model.QueryOptions) (model.Plugins, error) {
+ if !r.isPermitted() {
+ return nil, rest.ErrPermissionDenied
+ }
+ sel := r.newSelect(options...).Columns("*")
+ res := model.Plugins{}
+ err := r.queryAll(sel, &res)
+ return res, err
+}
+
+func (r *pluginRepository) Put(plugin *model.Plugin) error {
+ if !r.isPermitted() {
+ return rest.ErrPermissionDenied
+ }
+
+ plugin.UpdatedAt = time.Now()
+
+ if plugin.ID == "" {
+ return errors.New("plugin ID cannot be empty")
+ }
+
+ // Upsert using INSERT ... ON CONFLICT for atomic operation
+ _, err := r.db.NewQuery(`
+ INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, allow_write_access, enabled, last_error, sha256, created_at, updated_at)
+ VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:allow_write_access}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
+ ON CONFLICT(id) DO UPDATE SET
+ path = excluded.path,
+ manifest = excluded.manifest,
+ config = excluded.config,
+ users = excluded.users,
+ all_users = excluded.all_users,
+ libraries = excluded.libraries,
+ all_libraries = excluded.all_libraries,
+ allow_write_access = excluded.allow_write_access,
+ enabled = excluded.enabled,
+ last_error = excluded.last_error,
+ sha256 = excluded.sha256,
+ updated_at = excluded.updated_at
+ `).Bind(dbx.Params{
+ "id": plugin.ID,
+ "path": plugin.Path,
+ "manifest": plugin.Manifest,
+ "config": plugin.Config,
+ "users": plugin.Users,
+ "all_users": plugin.AllUsers,
+ "libraries": plugin.Libraries,
+ "all_libraries": plugin.AllLibraries,
+ "allow_write_access": plugin.AllowWriteAccess,
+ "enabled": plugin.Enabled,
+ "last_error": plugin.LastError,
+ "sha256": plugin.SHA256,
+ "created_at": time.Now(),
+ "updated_at": plugin.UpdatedAt,
+ }).Execute()
+ return err
+}
+
+func (r *pluginRepository) Count(options ...rest.QueryOptions) (int64, error) {
+ return r.CountAll(r.parseRestOptions(r.ctx, options...))
+}
+
+func (r *pluginRepository) EntityName() string {
+ return "plugin"
+}
+
+func (r *pluginRepository) NewInstance() any {
+ return &model.Plugin{}
+}
+
+func (r *pluginRepository) Read(id string) (any, error) {
+ return r.Get(id)
+}
+
+func (r *pluginRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
+ return r.GetAll(r.parseRestOptions(r.ctx, options...))
+}
+
+func (r *pluginRepository) Save(entity any) (string, error) {
+ p := entity.(*model.Plugin)
+ if !r.isPermitted() {
+ return "", rest.ErrPermissionDenied
+ }
+ err := r.Put(p)
+ if errors.Is(err, model.ErrNotFound) {
+ return "", rest.ErrNotFound
+ }
+ return p.ID, err
+}
+
+func (r *pluginRepository) Update(id string, entity any, cols ...string) error {
+ p := entity.(*model.Plugin)
+ p.ID = id
+ if !r.isPermitted() {
+ return rest.ErrPermissionDenied
+ }
+ err := r.Put(p)
+ if errors.Is(err, model.ErrNotFound) {
+ return rest.ErrNotFound
+ }
+ return err
+}
+
+var _ model.PluginRepository = (*pluginRepository)(nil)
+var _ rest.Repository = (*pluginRepository)(nil)
+var _ rest.Persistable = (*pluginRepository)(nil)
diff --git a/persistence/plugin_repository_test.go b/persistence/plugin_repository_test.go
new file mode 100644
index 000000000..dc68b0892
--- /dev/null
+++ b/persistence/plugin_repository_test.go
@@ -0,0 +1,251 @@
+package persistence
+
+import (
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("PluginRepository", func() {
+ var repo model.PluginRepository
+
+ Describe("Admin User", func() {
+ BeforeEach(func() {
+ ctx := GinkgoT().Context()
+ ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
+ repo = NewPluginRepository(ctx, GetDBXBuilder())
+
+ // Clean up any existing plugins
+ all, _ := repo.GetAll()
+ for _, p := range all {
+ _ = repo.Delete(p.ID)
+ }
+ })
+
+ AfterEach(func() {
+ // Clean up after tests
+ all, _ := repo.GetAll()
+ for _, p := range all {
+ _ = repo.Delete(p.ID)
+ }
+ })
+
+ Describe("CountAll", func() {
+ It("returns 0 when no plugins exist", func() {
+ Expect(repo.CountAll()).To(Equal(int64(0)))
+ })
+
+ It("returns the number of plugins in the DB", func() {
+ _ = repo.Put(&model.Plugin{ID: "test-plugin-1", Path: "/plugins/test1.wasm", Manifest: "{}", SHA256: "abc123"})
+ _ = repo.Put(&model.Plugin{ID: "test-plugin-2", Path: "/plugins/test2.wasm", Manifest: "{}", SHA256: "def456"})
+
+ Expect(repo.CountAll()).To(Equal(int64(2)))
+ })
+ })
+
+ Describe("Delete", func() {
+ It("deletes existing item", func() {
+ plugin := &model.Plugin{ID: "to-delete", Path: "/plugins/delete.wasm", Manifest: "{}", SHA256: "hash"}
+ _ = repo.Put(plugin)
+
+ err := repo.Delete(plugin.ID)
+ Expect(err).To(BeNil())
+
+ _, err = repo.Get(plugin.ID)
+ Expect(err).To(MatchError(model.ErrNotFound))
+ })
+ })
+
+ Describe("Get", func() {
+ It("returns an existing item", func() {
+ plugin := &model.Plugin{ID: "test-get", Path: "/plugins/test.wasm", Manifest: `{"name":"test"}`, SHA256: "hash123"}
+ _ = repo.Put(plugin)
+
+ res, err := repo.Get(plugin.ID)
+ Expect(err).To(BeNil())
+ Expect(res.ID).To(Equal(plugin.ID))
+ Expect(res.Path).To(Equal(plugin.Path))
+ Expect(res.Manifest).To(Equal(plugin.Manifest))
+ })
+
+ It("errors when missing", func() {
+ _, err := repo.Get("notanid")
+ Expect(err).To(MatchError(model.ErrNotFound))
+ })
+ })
+
+ Describe("GetAll", func() {
+ It("returns all items from the DB", func() {
+ _ = repo.Put(&model.Plugin{ID: "plugin-a", Path: "/plugins/a.wasm", Manifest: "{}", SHA256: "hash1"})
+ _ = repo.Put(&model.Plugin{ID: "plugin-b", Path: "/plugins/b.wasm", Manifest: "{}", SHA256: "hash2"})
+
+ all, err := repo.GetAll()
+ Expect(err).To(BeNil())
+ Expect(all).To(HaveLen(2))
+ })
+
+ It("supports pagination", func() {
+ _ = repo.Put(&model.Plugin{ID: "plugin-1", Path: "/plugins/1.wasm", Manifest: "{}", SHA256: "h1"})
+ _ = repo.Put(&model.Plugin{ID: "plugin-2", Path: "/plugins/2.wasm", Manifest: "{}", SHA256: "h2"})
+ _ = repo.Put(&model.Plugin{ID: "plugin-3", Path: "/plugins/3.wasm", Manifest: "{}", SHA256: "h3"})
+
+ page1, err := repo.GetAll(model.QueryOptions{Max: 2, Offset: 0, Sort: "id"})
+ Expect(err).To(BeNil())
+ Expect(page1).To(HaveLen(2))
+
+ page2, err := repo.GetAll(model.QueryOptions{Max: 2, Offset: 2, Sort: "id"})
+ Expect(err).To(BeNil())
+ Expect(page2).To(HaveLen(1))
+ })
+ })
+
+ Describe("Put", func() {
+ It("successfully creates a new plugin", func() {
+ plugin := &model.Plugin{
+ ID: "new-plugin",
+ Path: "/plugins/new.wasm",
+ Manifest: `{"name":"new","version":"1.0"}`,
+ Config: `{"setting":"value"}`,
+ SHA256: "sha256hash",
+ Enabled: false,
+ }
+
+ err := repo.Put(plugin)
+ Expect(err).To(BeNil())
+
+ saved, err := repo.Get(plugin.ID)
+ Expect(err).To(BeNil())
+ Expect(saved.Path).To(Equal(plugin.Path))
+ Expect(saved.Manifest).To(Equal(plugin.Manifest))
+ Expect(saved.Config).To(Equal(plugin.Config))
+ Expect(saved.Enabled).To(BeFalse())
+ Expect(saved.CreatedAt).NotTo(BeZero())
+ Expect(saved.UpdatedAt).NotTo(BeZero())
+ })
+
+ It("successfully updates an existing plugin", func() {
+ plugin := &model.Plugin{
+ ID: "update-plugin",
+ Path: "/plugins/update.wasm",
+ Manifest: `{"name":"test"}`,
+ SHA256: "original",
+ Enabled: false,
+ }
+ _ = repo.Put(plugin)
+
+ plugin.Enabled = true
+ plugin.Config = `{"new":"config"}`
+ plugin.SHA256 = "updated"
+ err := repo.Put(plugin)
+ Expect(err).To(BeNil())
+
+ saved, err := repo.Get(plugin.ID)
+ Expect(err).To(BeNil())
+ Expect(saved.Enabled).To(BeTrue())
+ Expect(saved.Config).To(Equal(`{"new":"config"}`))
+ Expect(saved.SHA256).To(Equal("updated"))
+ })
+
+ It("stores and retrieves last_error", func() {
+ plugin := &model.Plugin{
+ ID: "error-plugin",
+ Path: "/plugins/error.wasm",
+ Manifest: "{}",
+ SHA256: "hash",
+ LastError: "failed to load: missing export",
+ }
+ err := repo.Put(plugin)
+ Expect(err).To(BeNil())
+
+ saved, err := repo.Get(plugin.ID)
+ Expect(err).To(BeNil())
+ Expect(saved.LastError).To(Equal("failed to load: missing export"))
+ })
+
+ It("fails when ID is empty", func() {
+ plugin := &model.Plugin{
+ Path: "/plugins/noid.wasm",
+ Manifest: "{}",
+ SHA256: "hash",
+ }
+ err := repo.Put(plugin)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("ID cannot be empty"))
+ })
+ })
+
+ Describe("ClearErrors", func() {
+ It("clears last_error on all plugins with errors", func() {
+ _ = repo.Put(&model.Plugin{ID: "ok-plugin", Path: "/plugins/ok.wasm", Manifest: "{}", SHA256: "h1"})
+ _ = repo.Put(&model.Plugin{ID: "err-plugin-1", Path: "/plugins/e1.wasm", Manifest: "{}", SHA256: "h2", LastError: "incompatible version"})
+ _ = repo.Put(&model.Plugin{ID: "err-plugin-2", Path: "/plugins/e2.wasm", Manifest: "{}", SHA256: "h3", LastError: "missing export"})
+
+ err := repo.ClearErrors()
+ Expect(err).To(BeNil())
+
+ all, err := repo.GetAll()
+ Expect(err).To(BeNil())
+ for _, p := range all {
+ Expect(p.LastError).To(BeEmpty(), "plugin %s should have no error", p.ID)
+ }
+ })
+
+ It("succeeds when no plugins have errors", func() {
+ _ = repo.Put(&model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"})
+
+ err := repo.ClearErrors()
+ Expect(err).To(BeNil())
+ })
+ })
+ })
+
+ Describe("Regular User", func() {
+ BeforeEach(func() {
+ ctx := GinkgoT().Context()
+ ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false})
+ repo = NewPluginRepository(ctx, GetDBXBuilder())
+ })
+
+ Describe("CountAll", func() {
+ It("fails to count items", func() {
+ _, err := repo.CountAll()
+ Expect(err).To(Equal(rest.ErrPermissionDenied))
+ })
+ })
+
+ Describe("Delete", func() {
+ It("fails to delete items", func() {
+ err := repo.Delete("any-id")
+ Expect(err).To(Equal(rest.ErrPermissionDenied))
+ })
+ })
+
+ Describe("Get", func() {
+ It("fails to get items", func() {
+ _, err := repo.Get("any-id")
+ Expect(err).To(Equal(rest.ErrPermissionDenied))
+ })
+ })
+
+ Describe("GetAll", func() {
+ It("fails to get all items", func() {
+ _, err := repo.GetAll()
+ Expect(err).To(Equal(rest.ErrPermissionDenied))
+ })
+ })
+
+ Describe("Put", func() {
+ It("fails to create/update item", func() {
+ err := repo.Put(&model.Plugin{
+ ID: "user-create",
+ Path: "/plugins/create.wasm",
+ Manifest: "{}",
+ SHA256: "hash",
+ })
+ Expect(err).To(Equal(rest.ErrPermissionDenied))
+ })
+ })
+ })
+})
diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go
index cf253d06b..a073643db 100644
--- a/persistence/radio_repository.go
+++ b/persistence/radio_repository.go
@@ -58,34 +58,20 @@ func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, e
return res, err
}
-func (r *radioRepository) Put(radio *model.Radio) error {
+func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error {
if !r.isPermitted() {
return rest.ErrPermissionDenied
}
- var values map[string]interface{}
-
radio.UpdatedAt = time.Now()
-
if radio.ID == "" {
radio.CreatedAt = time.Now()
radio.ID = id.NewRandom()
- values, _ = toSQLArgs(*radio)
- } else {
- values, _ = toSQLArgs(*radio)
- update := Update(r.tableName).Where(Eq{"id": radio.ID}).SetMap(values)
- count, err := r.executeSQL(update)
-
- if err != nil {
- return err
- } else if count > 0 {
- return nil
- }
}
-
- values["created_at"] = time.Now()
- insert := Insert(r.tableName).SetMap(values)
- _, err := r.executeSQL(insert)
+ if len(colsToUpdate) > 0 {
+ colsToUpdate = append(colsToUpdate, "UpdatedAt")
+ }
+ _, err := r.put(radio.ID, radio, colsToUpdate...)
return err
}
@@ -97,19 +83,19 @@ func (r *radioRepository) EntityName() string {
return "radio"
}
-func (r *radioRepository) NewInstance() interface{} {
+func (r *radioRepository) NewInstance() any {
return &model.Radio{}
}
-func (r *radioRepository) Read(id string) (interface{}, error) {
+func (r *radioRepository) Read(id string) (any, error) {
return r.Get(id)
}
-func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
+func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
-func (r *radioRepository) Save(entity interface{}) (string, error) {
+func (r *radioRepository) Save(entity any) (string, error) {
t := entity.(*model.Radio)
if !r.isPermitted() {
return "", rest.ErrPermissionDenied
@@ -121,7 +107,7 @@ func (r *radioRepository) Save(entity interface{}) (string, error) {
return t.ID, err
}
-func (r *radioRepository) Update(id string, entity interface{}, cols ...string) error {
+func (r *radioRepository) Update(id string, entity any, cols ...string) error {
t := entity.(*model.Radio)
t.ID = id
if !r.isPermitted() {
diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go
index 88a31ac49..05628ca41 100644
--- a/persistence/radio_repository_test.go
+++ b/persistence/radio_repository_test.go
@@ -11,10 +11,6 @@ import (
. "github.com/onsi/gomega"
)
-var (
- NewId string = "123-456-789"
-)
-
var _ = Describe("RadioRepository", func() {
var repo model.RadioRepository
@@ -34,8 +30,7 @@ var _ = Describe("RadioRepository", func() {
}
for i := range testRadios {
- r := testRadios[i]
- err := repo.Put(&r)
+ err := repo.Put(new(testRadios[i]))
if err != nil {
panic(err)
}
@@ -140,7 +135,7 @@ var _ = Describe("RadioRepository", func() {
It("returns an existing item", func() {
res, err := repo.Get(radioWithHomePage.ID)
- Expect(err).To((BeNil()))
+ Expect(err).To(BeNil())
Expect(res.ID).To(Equal(radioWithHomePage.ID))
})
diff --git a/persistence/scrobble_buffer_repository.go b/persistence/scrobble_buffer_repository.go
index d0f88903e..3cfb836bf 100644
--- a/persistence/scrobble_buffer_repository.go
+++ b/persistence/scrobble_buffer_repository.go
@@ -51,7 +51,7 @@ func (r *scrobbleBufferRepository) UserIDs(service string) ([]string, error) {
}
func (r *scrobbleBufferRepository) Enqueue(service, userId, mediaFileId string, playTime time.Time) error {
- ins := Insert(r.tableName).SetMap(map[string]interface{}{
+ ins := Insert(r.tableName).SetMap(map[string]any{
"id": id.NewRandom(),
"user_id": userId,
"service": service,
diff --git a/persistence/scrobble_buffer_repository_test.go b/persistence/scrobble_buffer_repository_test.go
index 6962ea7c6..edf59ce49 100644
--- a/persistence/scrobble_buffer_repository_test.go
+++ b/persistence/scrobble_buffer_repository_test.go
@@ -24,7 +24,7 @@ var _ = Describe("ScrobbleBufferRepository", func() {
id := id.NewRandom()
ids = append(ids, id)
- ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]interface{}{
+ ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]any{
"id": id,
"user_id": userId,
"service": service,
@@ -152,7 +152,7 @@ var _ = Describe("ScrobbleBufferRepository", func() {
Expect(err).ToNot(HaveOccurred())
Expect(entry).ToNot(BeNil())
- Expect(entry.EnqueueTime).To(BeTemporally("~", now))
+ Expect(entry.EnqueueTime).To(BeTemporally("~", now, 100*time.Millisecond))
Expect(entry.MediaFileID).To(Equal(fileId))
Expect(entry.PlayTime).To(BeTemporally("==", playTime))
},
diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go
new file mode 100644
index 000000000..219a48198
--- /dev/null
+++ b/persistence/scrobble_repository.go
@@ -0,0 +1,34 @@
+package persistence
+
+import (
+ "context"
+ "time"
+
+ . "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/model"
+ "github.com/pocketbase/dbx"
+)
+
+type scrobbleRepository struct {
+ sqlRepository
+}
+
+func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRepository {
+ r := &scrobbleRepository{}
+ r.ctx = ctx
+ r.db = db
+ r.tableName = "scrobbles"
+ return r
+}
+
+func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime time.Time) error {
+ userID := loggedUser(r.ctx).ID
+ values := map[string]any{
+ "media_file_id": mediaFileID,
+ "user_id": userID,
+ "submission_time": submissionTime.Unix(),
+ }
+ insert := Insert(r.tableName).SetMap(values)
+ _, err := r.executeSQL(insert)
+ return err
+}
diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go
new file mode 100644
index 000000000..d43848d03
--- /dev/null
+++ b/persistence/scrobble_repository_test.go
@@ -0,0 +1,84 @@
+package persistence
+
+import (
+ "context"
+ "time"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/id"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
+)
+
+var _ = Describe("ScrobbleRepository", func() {
+ var repo model.ScrobbleRepository
+ var rawRepo sqlRepository
+ var ctx context.Context
+ var fileID string
+ var userID string
+
+ BeforeEach(func() {
+ fileID = id.NewRandom()
+ userID = id.NewRandom()
+ ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true})
+ db := GetDBXBuilder()
+ repo = NewScrobbleRepository(ctx, db)
+
+ rawRepo = sqlRepository{
+ ctx: ctx,
+ tableName: "scrobbles",
+ db: db,
+ }
+ })
+
+ AfterEach(func() {
+ _, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute()
+ _, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute()
+ _, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute()
+ })
+
+ Describe("RecordScrobble", func() {
+ It("records a scrobble event", func() {
+ submissionTime := time.Now().UTC()
+
+ // Insert User
+ _, err := rawRepo.db.Insert("user", dbx.Params{
+ "id": userID,
+ "user_name": "user",
+ "password": "pw",
+ "created_at": time.Now(),
+ "updated_at": time.Now(),
+ }).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Insert MediaFile
+ _, err = rawRepo.db.Insert("media_file", dbx.Params{
+ "id": fileID,
+ "path": "path",
+ "created_at": time.Now(),
+ "updated_at": time.Now(),
+ }).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ err = repo.RecordScrobble(fileID, submissionTime)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify insertion
+ var scrobble struct {
+ MediaFileID string `db:"media_file_id"`
+ UserID string `db:"user_id"`
+ SubmissionTime int64 `db:"submission_time"`
+ }
+ err = rawRepo.db.Select("*").From("scrobbles").
+ Where(dbx.HashExp{"media_file_id": fileID, "user_id": userID}).
+ One(&scrobble)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(scrobble.MediaFileID).To(Equal(fileID))
+ Expect(scrobble.UserID).To(Equal(userID))
+ Expect(scrobble.SubmissionTime).To(Equal(submissionTime.Unix()))
+ })
+ })
+})
diff --git a/persistence/share_repository.go b/persistence/share_repository.go
index abe1ea6e6..89dc19e19 100644
--- a/persistence/share_repository.go
+++ b/persistence/share_repository.go
@@ -31,20 +31,17 @@ func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareReposito
}
func (r *shareRepository) Delete(id string) error {
- err := r.delete(Eq{"id": id})
- if errors.Is(err, model.ErrNotFound) {
- return rest.ErrNotFound
- }
- return err
+ return r.deleteOwned(id)
}
func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuilder {
return r.newSelect(options...).Join("user u on u.id = share.user_id").
- Columns("share.*", "user_name as username")
+ Columns("share.*", "user_name as username").
+ Where(r.addRestriction())
}
func (r *shareRepository) Exists(id string) (bool, error) {
- return r.exists(Eq{"id": id})
+ return r.exists(r.addRestriction(And{Eq{"id": id}}))
}
func (r *shareRepository) Get(id string) (*model.Share, error) {
@@ -95,7 +92,7 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
return err
case "album":
albumRepo := NewAlbumRepository(r.ctx, r.db)
- share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"id": ids})})
+ share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album.id": ids})})
if err != nil {
return err
}
@@ -138,20 +135,17 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles {
return sorted
}
-func (r *shareRepository) Update(id string, entity interface{}, cols ...string) error {
+func (r *shareRepository) Update(id string, entity any, cols ...string) error {
s := entity.(*model.Share)
- // TODO Validate record
s.ID = id
s.UpdatedAt = time.Now()
- cols = append(cols, "updated_at")
- _, err := r.put(id, s, cols...)
- if errors.Is(err, model.ErrNotFound) {
- return rest.ErrNotFound
+ if len(cols) > 0 {
+ cols = append(cols, "updated_at")
}
- return err
+ return r.updateOwned(id, s, cols...)
}
-func (r *shareRepository) Save(entity interface{}) (string, error) {
+func (r *shareRepository) Save(entity any) (string, error) {
s := entity.(*model.Share)
// TODO Validate record
u := loggedUser(r.ctx)
@@ -179,18 +173,18 @@ func (r *shareRepository) EntityName() string {
return "share"
}
-func (r *shareRepository) NewInstance() interface{} {
+func (r *shareRepository) NewInstance() any {
return &model.Share{}
}
-func (r *shareRepository) Read(id string) (interface{}, error) {
+func (r *shareRepository) Read(id string) (any, error) {
sel := r.selectShare().Where(Eq{"share.id": id})
var res model.Share
err := r.queryOne(sel, &res)
return &res, err
}
-func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
+func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
sq := r.selectShare(r.parseRestOptions(r.ctx, options...))
res := model.Shares{}
err := r.queryAll(sq, &res)
diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go
new file mode 100644
index 000000000..0b3ece598
--- /dev/null
+++ b/persistence/share_repository_test.go
@@ -0,0 +1,395 @@
+package persistence
+
+import (
+ "context"
+ "time"
+
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("ShareRepository", func() {
+ var repo model.ShareRepository
+ var ctx context.Context
+ var adminUser = model.User{ID: "admin", UserName: "admin", IsAdmin: true}
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
+ repo = NewShareRepository(ctx, GetDBXBuilder())
+
+ // Insert the admin user into the database (required for foreign key constraint)
+ ur := NewUserRepository(ctx, GetDBXBuilder())
+ err := ur.Put(&adminUser)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Clean up shares
+ db := GetDBXBuilder()
+ _, err = db.NewQuery("DELETE FROM share").Execute()
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ Describe("Headless Access", func() {
+ Context("Repository creation and basic operations", func() {
+ It("should create repository successfully with no user context", func() {
+ // Create repository with no user context (headless)
+ headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
+ Expect(headlessRepo).ToNot(BeNil())
+ })
+
+ It("should handle GetAll for headless processes", func() {
+ // Create a simple share directly in database
+ shareID := "headless-test-share"
+ _, err := GetDBXBuilder().NewQuery(`
+ INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
+ VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
+ `).Bind(map[string]any{
+ "id": shareID,
+ "user": adminUser.ID,
+ "desc": "Headless Test Share",
+ "type": "song",
+ "ids": "song-1",
+ "created": time.Now(),
+ "updated": time.Now(),
+ }).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Headless process should see all shares
+ headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
+ shares, err := headlessRepo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+
+ found := false
+ for _, s := range shares {
+ if s.ID == shareID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Headless process should see all shares")
+ })
+
+ It("should handle individual share retrieval for headless processes", func() {
+ // Create a simple share
+ shareID := "headless-get-share"
+ _, err := GetDBXBuilder().NewQuery(`
+ INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
+ VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
+ `).Bind(map[string]any{
+ "id": shareID,
+ "user": adminUser.ID,
+ "desc": "Headless Get Share",
+ "type": "song",
+ "ids": "song-2",
+ "created": time.Now(),
+ "updated": time.Now(),
+ }).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Headless process should be able to get the share
+ headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
+ share, err := headlessRepo.Get(shareID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(share.ID).To(Equal(shareID))
+ Expect(share.Description).To(Equal("Headless Get Share"))
+ })
+ })
+ })
+
+ Describe("SQL ambiguity fix verification", func() {
+ It("should handle share operations without SQL ambiguity errors", func() {
+ // This test verifies that the loadMedia function doesn't cause SQL ambiguity
+ // The key fix was using "album.id" instead of "id" in the album query filters
+
+ // Create a share that would trigger the loadMedia function
+ shareID := "sql-test-share"
+ _, err := GetDBXBuilder().NewQuery(`
+ INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
+ VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
+ `).Bind(map[string]any{
+ "id": shareID,
+ "user": adminUser.ID,
+ "desc": "SQL Test Share",
+ "type": "album",
+ "ids": "non-existent-album", // Won't find albums, but shouldn't cause SQL errors
+ "created": time.Now(),
+ "updated": time.Now(),
+ }).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // The Get operation should work without SQL ambiguity errors
+ // even if no albums are found
+ share, err := repo.Get(shareID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(share.ID).To(Equal(shareID))
+ // Albums array should be empty since we used non-existent album ID
+ Expect(share.Albums).To(BeEmpty())
+ })
+ })
+
+ Describe("Ownership Checks", func() {
+ var ownerUser = model.User{ID: "2222", UserName: "regular-user"}
+ var otherUser = model.User{ID: "3333", UserName: "third-user"}
+
+ insertShare := func(shareID, userID string) {
+ _, err := GetDBXBuilder().NewQuery(`
+ INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
+ VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
+ `).Bind(map[string]any{
+ "id": shareID,
+ "user": userID,
+ "desc": "Test Share",
+ "type": "media_file",
+ "ids": "1001",
+ "created": time.Now(),
+ "updated": time.Now(),
+ }).Execute()
+ Expect(err).ToNot(HaveOccurred())
+ }
+
+ Describe("Delete", func() {
+ It("allows a non-admin user to delete their own share", func() {
+ insertShare("own-share-del", ownerUser.ID)
+ ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
+ repo := NewShareRepository(ctx, GetDBXBuilder())
+ err := repo.(rest.Persistable).Delete("own-share-del")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("denies a non-admin user from deleting another user's share", func() {
+ insertShare("other-share-del", ownerUser.ID)
+ ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser)
+ repo := NewShareRepository(ctx, GetDBXBuilder())
+ err := repo.(rest.Persistable).Delete("other-share-del")
+ Expect(err).To(Equal(rest.ErrPermissionDenied))
+
+ // The share was not deleted: the owner can still read it.
+ ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
+ ownerRepo := NewShareRepository(ownerCtx, GetDBXBuilder())
+ _, err = ownerRepo.(rest.Repository).Read("other-share-del")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("allows an admin to delete any user's share", func() {
+ insertShare("admin-del-share", ownerUser.ID)
+ ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
+ repo := NewShareRepository(ctx, GetDBXBuilder())
+ err := repo.(rest.Persistable).Delete("admin-del-share")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("allows headless context (no user) to delete a share", func() {
+ insertShare("headless-del-share", ownerUser.ID)
+ repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
+ err := repo.(rest.Persistable).Delete("headless-del-share")
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ Describe("Update", func() {
+ It("allows a non-admin user to update their own share", func() {
+ insertShare("own-share-upd", ownerUser.ID)
+ ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
+ repo := NewShareRepository(ctx, GetDBXBuilder())
+ err := repo.(rest.Persistable).Update("own-share-upd", &model.Share{Description: "Updated"}, "description")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("denies a non-admin user from updating another user's share", func() {
+ insertShare("other-share-upd", ownerUser.ID)
+ ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser)
+ repo := NewShareRepository(ctx, GetDBXBuilder())
+ err := repo.(rest.Persistable).Update("other-share-upd", &model.Share{Description: "Hacked"}, "description")
+ Expect(err).To(Equal(rest.ErrPermissionDenied))
+ })
+
+ It("allows an admin to update any user's share", func() {
+ insertShare("admin-upd-share", ownerUser.ID)
+ ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
+ repo := NewShareRepository(ctx, GetDBXBuilder())
+ err := repo.(rest.Persistable).Update("admin-upd-share", &model.Share{Description: "Admin Updated"}, "description")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("allows headless context (no user) to update a share", func() {
+ insertShare("headless-upd-share", ownerUser.ID)
+ repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
+ err := repo.(rest.Persistable).Update("headless-upd-share", &model.Share{Description: "Headless"}, "description")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("returns not found when updating a nonexistent share", func() {
+ ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
+ repo := NewShareRepository(ctx, GetDBXBuilder())
+ err := repo.(rest.Persistable).Update("does-not-exist", &model.Share{Description: "Ghost"}, "description")
+ Expect(err).To(Equal(rest.ErrNotFound))
+ })
+
+ It("updates all columns when no specific columns are given", func() {
+ insertShare("all-cols-share", ownerUser.ID)
+ ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
+ repo := NewShareRepository(ctx, GetDBXBuilder())
+ // No cols: the update must write every column, not just updated_at.
+ err := repo.(rest.Persistable).Update("all-cols-share",
+ &model.Share{Description: "All Updated", MaxBitRate: 192, ResourceType: "album", ResourceIDs: "2002"})
+ Expect(err).ToNot(HaveOccurred())
+
+ got, err := repo.(rest.Repository).Read("all-cols-share")
+ Expect(err).ToNot(HaveOccurred())
+ share := got.(*model.Share)
+ Expect(share.Description).To(Equal("All Updated"))
+ Expect(share.MaxBitRate).To(Equal(192))
+ Expect(share.ResourceType).To(Equal("album"))
+ })
+
+ It("does not let an owner reassign their share to another user", func() {
+ insertShare("reassign-share", ownerUser.ID)
+ ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
+ repo := NewShareRepository(ctx, GetDBXBuilder())
+ err := repo.(rest.Persistable).Update("reassign-share",
+ &model.Share{UserID: otherUser.ID, Description: "Given away"}, "user_id", "description")
+ Expect(err).ToNot(HaveOccurred())
+
+ // Ownership must not have moved, even though user_id was passed in the body and cols.
+ got, err := repo.(rest.Repository).Read("reassign-share")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.(*model.Share).UserID).To(Equal(ownerUser.ID))
+ })
+ })
+
+ Describe("Read scoping", func() {
+ BeforeEach(func() {
+ // Persist owner/other users so the JOIN in selectShare resolves.
+ ur := NewUserRepository(ctx, GetDBXBuilder())
+ Expect(ur.Put(&ownerUser)).To(Succeed())
+ Expect(ur.Put(&otherUser)).To(Succeed())
+
+ insertShare("share-owner-1", ownerUser.ID)
+ insertShare("share-owner-2", ownerUser.ID)
+ insertShare("share-other-1", otherUser.ID)
+ })
+
+ Context("non-admin user", func() {
+ var nonAdminRepo model.ShareRepository
+ var nonAdminRest rest.Repository
+
+ BeforeEach(func() {
+ nonAdminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
+ nonAdminRepo = NewShareRepository(nonAdminCtx, GetDBXBuilder())
+ nonAdminRest = nonAdminRepo.(rest.Repository)
+ })
+
+ It("GetAll returns only own shares", func() {
+ shares, err := nonAdminRepo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ ids := make([]string, len(shares))
+ for i, s := range shares {
+ ids[i] = s.ID
+ }
+ Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2"))
+ })
+
+ It("ReadAll returns only own shares", func() {
+ res, err := nonAdminRest.ReadAll()
+ Expect(err).ToNot(HaveOccurred())
+ shares := res.(model.Shares)
+ ids := make([]string, len(shares))
+ for i, s := range shares {
+ ids[i] = s.ID
+ }
+ Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2"))
+ })
+
+ It("Get returns own share", func() {
+ s, err := nonAdminRepo.Get("share-owner-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(s.ID).To(Equal("share-owner-1"))
+ })
+
+ It("Get returns ErrNotFound for another user's share", func() {
+ _, err := nonAdminRepo.Get("share-other-1")
+ Expect(err).To(MatchError(model.ErrNotFound))
+ })
+
+ It("Read returns ErrNotFound for another user's share", func() {
+ _, err := nonAdminRest.Read("share-other-1")
+ Expect(err).To(MatchError(model.ErrNotFound))
+ })
+
+ It("Exists returns true for own share", func() {
+ exists, err := nonAdminRepo.Exists("share-owner-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(exists).To(BeTrue())
+ })
+
+ It("Exists returns false for another user's share", func() {
+ exists, err := nonAdminRepo.Exists("share-other-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(exists).To(BeFalse())
+ })
+
+ It("CountAll counts only own shares", func() {
+ count, err := nonAdminRepo.CountAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(BeNumerically("==", 2))
+ })
+
+ It("Count (rest) counts only own shares", func() {
+ count, err := nonAdminRest.Count()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(BeNumerically("==", 2))
+ })
+ })
+
+ Context("admin user", func() {
+ It("GetAll returns all shares", func() {
+ adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
+ adminRepo := NewShareRepository(adminCtx, GetDBXBuilder())
+ shares, err := adminRepo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ ids := make([]string, len(shares))
+ for i, s := range shares {
+ ids[i] = s.ID
+ }
+ Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2", "share-other-1"))
+ })
+
+ It("CountAll counts all shares", func() {
+ adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
+ adminRepo := NewShareRepository(adminCtx, GetDBXBuilder())
+ count, err := adminRepo.CountAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(BeNumerically("==", 3))
+ })
+ })
+
+ Context("headless context (public share route)", func() {
+ It("GetAll returns all shares", func() {
+ headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
+ shares, err := headlessRepo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(shares).To(HaveLen(3))
+ })
+
+ It("Get returns another user's share", func() {
+ headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
+ s, err := headlessRepo.Get("share-other-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(s.ID).To(Equal("share-other-1"))
+ })
+
+ It("Exists returns true for any share", func() {
+ headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
+ exists, err := headlessRepo.Exists("share-other-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(exists).To(BeTrue())
+ })
+ })
+ })
+ })
+})
diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go
new file mode 100644
index 000000000..54f316152
--- /dev/null
+++ b/persistence/smart_playlist_repository.go
@@ -0,0 +1,203 @@
+package persistence
+
+import (
+ "time"
+
+ . "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+)
+
+// PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated
+// based on their rules. The main method is refreshSmartPlaylist, which evaluates the criteria and updates the playlist
+// tracks accordingly. It also handles refreshing dependent playlists when a smart playlist references other playlists
+// in its criteria. To optimize performance, it only refreshes when necessary based on the last evaluated time and
+// configured refresh delay.
+
+// refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly.
+func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
+ usr := loggedUser(r.ctx)
+ if !r.shouldRefreshSmartPlaylist(pls, usr) {
+ return false
+ }
+
+ log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID)
+ start := time.Now()
+
+ del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID})
+ if _, err := r.executeSQL(del); err != nil {
+ log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
+ return false
+ }
+
+ rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr))
+
+ if !r.refreshChildPlaylists(pls, rulesSQL) {
+ return false
+ }
+
+ if err := r.resolvePercentageLimit(pls, &rulesSQL, usr.ID); err != nil {
+ return false
+ }
+
+ sq := r.buildSmartPlaylistQuery(pls, rulesSQL, usr.ID)
+ sq, err := r.addCriteria(sq, rulesSQL)
+ if err != nil {
+ log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
+ return false
+ }
+
+ insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
+ if _, err = r.executeSQL(insSql); err != nil {
+ log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
+ return false
+ }
+
+ if err = r.refreshCounters(pls); err != nil {
+ log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err)
+ return false
+ }
+
+ now := time.Now()
+ updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID})
+ if _, err = r.executeSQL(updSql); err != nil {
+ log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err)
+ return false
+ }
+ pls.EvaluatedAt = &now
+
+ log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start))
+ return true
+}
+
+// shouldRefreshSmartPlaylist determines if a smart playlist needs to be refreshed based on its type, last evaluated
+// time, and ownership.
+func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr *model.User) bool {
+ if !pls.IsSmartPlaylist() {
+ return false
+ }
+ if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay {
+ return false
+ }
+ if pls.OwnerID != usr.ID {
+ log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID)
+ return false
+ }
+ return true
+}
+
+// refreshChildPlaylists handles refreshing any child playlists that are referenced in the smart playlist criteria.
+// Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort.
+func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool {
+ childPlaylistIds := rulesSQL.ChildPlaylistIds()
+ if len(childPlaylistIds) == 0 {
+ return true
+ }
+
+ childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}})
+ if err != nil {
+ log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err)
+ return false
+ }
+
+ found := make(map[string]struct{}, len(childPlaylists))
+ for i := range childPlaylists {
+ found[childPlaylists[i].ID] = struct{}{}
+ r.refreshSmartPlaylist(&childPlaylists[i])
+ }
+ for _, id := range childPlaylistIds {
+ if _, ok := found[id]; !ok {
+ log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID)
+ }
+ }
+ return true
+}
+
+// resolvePercentageLimit calculates the actual limit for a smart playlist criteria that uses a percentage-based limit.
+func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQL *smartPlaylistCriteria, userID string) error {
+ if !rulesSQL.IsPercentageLimit() {
+ return nil
+ }
+
+ exprJoins := rulesSQL.ExpressionJoins()
+ countSq := Select("count(*) as count").From("media_file")
+ countSq = r.addMediaFileAnnotationJoin(countSq, userID)
+ countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, userID)
+ countSq = r.applyLibraryFilter(countSq, "media_file")
+
+ cond, err := rulesSQL.Where()
+ if err != nil {
+ log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
+ return err
+ }
+ countSq = countSq.Where(cond)
+
+ var res struct{ Count int64 }
+ if err = r.queryOne(countSq, &res); err != nil {
+ log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err)
+ return err
+ }
+
+ rulesSQL.ResolveLimit(res.Count)
+ log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rulesSQL.LimitPercent, "totalMatching", res.Count, "resolvedLimit", rulesSQL.Limit)
+ return nil
+}
+
+// buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria,
+// including necessary joins for annotations and library filtering.
+func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder {
+ orderBy := rulesSQL.OrderBy()
+ sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
+ From("media_file")
+ sq = r.addMediaFileAnnotationJoin(sq, userID)
+
+ requiredJoins := rulesSQL.RequiredJoins()
+ sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, userID)
+ sq = r.applyLibraryFilter(sq, "media_file")
+ return sq
+}
+
+// addMediaFileAnnotationJoin adds a left join to the annotation table for media files, filtering by user ID to include
+// user-specific annotations in the smart playlist criteria evaluation.
+func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID string) SelectBuilder {
+ return sq.LeftJoin("annotation on ("+
+ "annotation.item_id = media_file.id"+
+ " AND annotation.item_type = 'media_file'"+
+ " AND annotation.user_id = ?)", userID)
+}
+
+// addSmartPlaylistAnnotationJoins adds left joins to the annotation table for albums and artists as needed based on
+// the smart playlist criteria, filtering by user ID to include user-specific annotations in the evaluation.
+func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder {
+ if joins.has(smartPlaylistJoinAlbumAnnotation) {
+ sq = sq.LeftJoin("annotation AS album_annotation ON ("+
+ "album_annotation.item_id = media_file.album_id"+
+ " AND album_annotation.item_type = 'album'"+
+ " AND album_annotation.user_id = ?)", userID)
+ }
+ if joins.has(smartPlaylistJoinArtistAnnotation) {
+ sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
+ "artist_annotation.item_id = media_file.artist_id"+
+ " AND artist_annotation.item_type = 'artist'"+
+ " AND artist_annotation.user_id = ?)", userID)
+ }
+ return sq
+}
+
+// addCriteria applies the where conditions, limit, offset, and order by clauses to the SQL query based on the
+// smart playlist criteria.
+func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) {
+ cond, err := cSQL.Where()
+ if err != nil {
+ return sql, err
+ }
+ sql = sql.Where(cond)
+ if cSQL.Criteria.Limit > 0 {
+ sql = sql.Limit(uint64(cSQL.Criteria.Limit)).Offset(uint64(cSQL.Criteria.Offset))
+ }
+ if order := cSQL.OrderBy(); order != "" {
+ sql = sql.OrderBy(order)
+ }
+ return sql, nil
+}
diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go
new file mode 100644
index 000000000..7bc705385
--- /dev/null
+++ b/persistence/smart_playlist_repository_test.go
@@ -0,0 +1,596 @@
+package persistence
+
+import (
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/criteria"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
+)
+
+var _ = Describe("PlaylistRepository - Smart Playlists", func() {
+ var repo model.PlaylistRepository
+
+ BeforeEach(func() {
+ ctx := log.NewContext(GinkgoT().Context())
+ ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
+ repo = NewPlaylistRepository(ctx, GetDBXBuilder())
+ })
+
+ Context("Smart Playlists", func() {
+ var rules *criteria.Criteria
+ BeforeEach(func() {
+ rules = &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.Contains{"title": "love"},
+ },
+ }
+ })
+ Context("valid rules", func() {
+ Specify("Put/Get", func() {
+ newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&newPls)).To(Succeed())
+ DeferCleanup(func() { _ = repo.Delete(newPls.ID) })
+
+ savedPls, err := repo.Get(newPls.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(savedPls.Rules).To(Equal(rules))
+ })
+ })
+
+ Context("invalid rules", func() {
+ It("fails to Put it in the DB", func() {
+ rules = &criteria.Criteria{
+ // This is invalid because "contains" cannot have multiple fields
+ Expression: criteria.All{
+ criteria.Contains{"genre": "Hardcore", "filetype": "mp3"},
+ },
+ }
+ newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression")))
+ })
+ })
+
+ Context("child smart playlists", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ })
+
+ When("refresh delay has expired", func() {
+ It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
+
+ childRules := &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.Contains{"title": "Day"},
+ },
+ }
+ nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules}
+ Expect(repo.Put(&nestedPls)).To(Succeed())
+ DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
+
+ parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.InPlaylist{"id": nestedPls.ID},
+ },
+ }}
+ Expect(repo.Put(&parentPls)).To(Succeed())
+ DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
+
+ // Nested playlist has not been evaluated yet
+ nestedPlsRead, err := repo.Get(nestedPls.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(nestedPlsRead.EvaluatedAt).To(BeNil())
+
+ // Getting parent with refresh should recursively refresh the nested playlist
+ pls, err := repo.GetWithTracks(parentPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.EvaluatedAt).ToNot(BeNil())
+ Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
+
+ // Parent should have tracks from the nested playlist
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
+
+ // Nested playlist should now have been refreshed (EvaluatedAt set)
+ nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
+ Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
+ })
+ })
+
+ When("refresh delay has not expired", func() {
+ It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
+ conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
+ childEvaluatedAt := time.Now().Add(-30 * time.Minute)
+
+ childRules := &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.Contains{"title": "Day"},
+ },
+ }
+ nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt}
+ Expect(repo.Put(&nestedPls)).To(Succeed())
+ DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
+
+ // Parent has no EvaluatedAt, so it WILL refresh, but the child should not
+ parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.InPlaylist{"id": nestedPls.ID},
+ },
+ }}
+ Expect(repo.Put(&parentPls)).To(Succeed())
+ DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
+
+ nestedPlsRead, err := repo.Get(nestedPls.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Getting parent with refresh should NOT recursively refresh the nested playlist
+ parent, err := repo.GetWithTracks(parentPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Parent should have been refreshed (its EvaluatedAt was nil)
+ Expect(parent.EvaluatedAt).ToNot(BeNil())
+ Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
+
+ // Nested playlist should NOT have been refreshed (still within delay window)
+ nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second))
+ Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
+ })
+ })
+ })
+ })
+
+ Describe("Playlist Track Sorting", func() {
+ var testPlaylistID string
+
+ AfterEach(func() {
+ if testPlaylistID != "" {
+ Expect(repo.Delete(testPlaylistID)).To(BeNil())
+ testPlaylistID = ""
+ }
+ })
+
+ It("sorts tracks correctly by album (disc and track number)", func() {
+ By("creating a playlist with multi-disc album tracks in arbitrary order")
+ newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"}
+ // Add tracks in intentionally scrambled order
+ newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"})
+ Expect(repo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ By("retrieving tracks sorted by album")
+ tracksRepo := repo.Tracks(newPls.ID, false)
+ tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"})
+ Expect(err).ToNot(HaveOccurred())
+
+ By("verifying tracks are sorted by disc number then track number")
+ Expect(tracks).To(HaveLen(4))
+ // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11
+ Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1
+ Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2
+ Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1
+ Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
+ })
+ })
+
+ Describe("Smart Playlists with Album/Artist Annotation Criteria", func() {
+ var testPlaylistID string
+
+ AfterEach(func() {
+ if testPlaylistID != "" {
+ _ = repo.Delete(testPlaylistID)
+ testPlaylistID = ""
+ }
+ })
+
+ It("matches tracks from starred albums using albumLoved", func() {
+ // albumRadioactivity (ID "103") is starred in test fixtures
+ // Songs in album 103: 1003, 1004, 1005, 1006
+ rules := &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.Is{"albumLoved": true},
+ },
+ }
+ newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
+ pls, err := repo.GetWithTracks(newPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ trackIDs := make([]string, len(pls.Tracks))
+ for i, t := range pls.Tracks {
+ trackIDs[i] = t.MediaFileID
+ }
+ Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006"))
+ })
+
+ It("matches tracks from starred artists using artistLoved", func() {
+ // artistBeatles (ID "3") is starred in test fixtures
+ // Songs with ArtistID "3": 1001, 1002, 3002
+ rules := &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.Is{"artistLoved": true},
+ },
+ }
+ newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
+ pls, err := repo.GetWithTracks(newPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ trackIDs := make([]string, len(pls.Tracks))
+ for i, t := range pls.Tracks {
+ trackIDs[i] = t.MediaFileID
+ }
+ Expect(trackIDs).To(ConsistOf("1001", "1002", "3002"))
+ })
+
+ It("matches tracks with combined album and artist criteria", func() {
+ // albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006)
+ // artistLoved=true → songs with artist 3 (1001, 1002)
+ // Using Any: union of both sets
+ rules := &criteria.Criteria{
+ Expression: criteria.Any{
+ criteria.Is{"albumLoved": true},
+ criteria.Is{"artistLoved": true},
+ },
+ }
+ newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
+ pls, err := repo.GetWithTracks(newPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ trackIDs := make([]string, len(pls.Tracks))
+ for i, t := range pls.Tracks {
+ trackIDs[i] = t.MediaFileID
+ }
+ Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002"))
+ })
+
+ It("returns no tracks when no albums/artists match", func() {
+ // No album has rating 5 in fixtures
+ rules := &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.Is{"albumRating": 5},
+ },
+ }
+ newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
+ pls, err := repo.GetWithTracks(newPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(pls.Tracks).To(BeEmpty())
+ })
+
+ It("matches loved tracks when loved value is a string in nested group (issue #4826)", func() {
+ // songComeTogether (ID "1002") is starred in test fixtures
+ rules := &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.Any{
+ criteria.Is{"loved": "true"},
+ },
+ },
+ }
+ newPls := model.Playlist{Name: "String Loved Nested", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
+ pls, err := repo.GetWithTracks(newPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ trackIDs := make([]string, len(pls.Tracks))
+ for i, t := range pls.Tracks {
+ trackIDs[i] = t.MediaFileID
+ }
+ Expect(trackIDs).To(ContainElement("1002"))
+ Expect(len(pls.Tracks)).To(BeNumerically(">=", 1))
+ })
+
+ It("returns same results for string and bool loved values (issue #4826)", func() {
+ boolRules := &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.Any{
+ criteria.Is{"loved": true},
+ },
+ },
+ }
+ boolPls := model.Playlist{Name: "Bool Loved", OwnerID: "userid", Rules: boolRules}
+ Expect(repo.Put(&boolPls)).To(Succeed())
+ DeferCleanup(func() { _ = repo.Delete(boolPls.ID) })
+
+ stringRules := &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.Any{
+ criteria.Is{"loved": "true"},
+ },
+ },
+ }
+ stringPls := model.Playlist{Name: "String Loved", OwnerID: "userid", Rules: stringRules}
+ Expect(repo.Put(&stringPls)).To(Succeed())
+ testPlaylistID = stringPls.ID
+
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
+ boolResult, err := repo.GetWithTracks(boolPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+ stringResult, err := repo.GetWithTracks(stringPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ boolIDs := make([]string, len(boolResult.Tracks))
+ for i, t := range boolResult.Tracks {
+ boolIDs[i] = t.MediaFileID
+ }
+ stringIDs := make([]string, len(stringResult.Tracks))
+ for i, t := range stringResult.Tracks {
+ stringIDs[i] = t.MediaFileID
+ }
+ Expect(stringIDs).To(ConsistOf(boolIDs))
+ })
+ })
+
+ Describe("Smart Playlists with Tag Criteria", func() {
+ var mfRepo model.MediaFileRepository
+ var testPlaylistID string
+ var songWithGrouping, songWithoutGrouping model.MediaFile
+
+ BeforeEach(func() {
+ ctx := log.NewContext(GinkgoT().Context())
+ ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
+ mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
+
+ // Register 'grouping' as a valid tag for smart playlists
+ criteria.AddTagNames([]string{"grouping"})
+
+ // Create a song with the grouping tag
+ songWithGrouping = model.MediaFile{
+ ID: "test-grouping-1",
+ Title: "Song With Grouping",
+ Artist: "Test Artist",
+ ArtistID: "1",
+ Album: "Test Album",
+ AlbumID: "101",
+ Path: "test/grouping/song1.mp3",
+ Tags: model.Tags{
+ "grouping": []string{"My Crate"},
+ },
+ Participants: model.Participants{},
+ LibraryID: 1,
+ Lyrics: "[]",
+ }
+ Expect(mfRepo.Put(&songWithGrouping)).To(Succeed())
+
+ // Create a song without the grouping tag
+ songWithoutGrouping = model.MediaFile{
+ ID: "test-grouping-2",
+ Title: "Song Without Grouping",
+ Artist: "Test Artist",
+ ArtistID: "1",
+ Album: "Test Album",
+ AlbumID: "101",
+ Path: "test/grouping/song2.mp3",
+ Tags: model.Tags{},
+ Participants: model.Participants{},
+ LibraryID: 1,
+ Lyrics: "[]",
+ }
+ Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ if testPlaylistID != "" {
+ _ = repo.Delete(testPlaylistID)
+ testPlaylistID = ""
+ }
+ // Clean up test media files
+ _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute()
+ _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute()
+ })
+
+ It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() {
+ By("creating a smart playlist that checks if grouping tag has any value")
+ // This is the workaround for issue #4728: using 'contains' with empty string
+ // generates SQL: value LIKE '%%' which matches any non-empty string
+ rules := &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.Contains{"grouping": ""},
+ },
+ }
+ newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ By("refreshing the smart playlist")
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
+ pls, err := repo.GetWithTracks(newPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ By("verifying only the track with grouping tag is matched")
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID))
+ })
+
+ It("excludes tracks with a tag value using 'notContains' with empty string", func() {
+ By("creating a smart playlist that checks if grouping tag is NOT set")
+ rules := &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.NotContains{"grouping": ""},
+ },
+ }
+ newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ By("refreshing the smart playlist")
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
+ pls, err := repo.GetWithTracks(newPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ By("verifying the track with grouping is NOT in the playlist")
+ for _, track := range pls.Tracks {
+ Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID))
+ }
+
+ By("verifying the track without grouping IS in the playlist")
+ var foundWithoutGrouping bool
+ for _, track := range pls.Tracks {
+ if track.MediaFileID == songWithoutGrouping.ID {
+ foundWithoutGrouping = true
+ break
+ }
+ }
+ Expect(foundWithoutGrouping).To(BeTrue())
+ })
+ })
+
+ Describe("Smart Playlists Library Filtering", func() {
+ var mfRepo model.MediaFileRepository
+ var testPlaylistID string
+ var lib2ID int
+ var restrictedUserID string
+ var uniqueLibPath string
+
+ BeforeEach(func() {
+ db := GetDBXBuilder()
+
+ // Generate unique IDs for this test run
+ uniqueSuffix := time.Now().Format("20060102150405.000")
+ restrictedUserID = "restricted-user-" + uniqueSuffix
+ uniqueLibPath = "/music/lib2-" + uniqueSuffix
+
+ // Create a second library with unique name and path to avoid conflicts with other tests
+ _, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath)
+ Expect(err).ToNot(HaveOccurred())
+ err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Create a restricted user with access only to library 1
+ _, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID)
+ Expect(err).ToNot(HaveOccurred())
+ _, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Create test media files in each library
+ ctx := log.NewContext(GinkgoT().Context())
+ ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
+ mfRepo = NewMediaFileRepository(ctx, db)
+
+ // Song in library 1 (accessible by restricted user)
+ songLib1 := model.MediaFile{
+ ID: "lib1-song",
+ Title: "Song in Lib1",
+ Artist: "Test Artist",
+ ArtistID: "1",
+ Album: "Test Album",
+ AlbumID: "101",
+ Path: "lib1/song.mp3",
+ LibraryID: 1,
+ Participants: model.Participants{},
+ Tags: model.Tags{},
+ Lyrics: "[]",
+ }
+ Expect(mfRepo.Put(&songLib1)).To(Succeed())
+
+ // Song in library 2 (NOT accessible by restricted user)
+ songLib2 := model.MediaFile{
+ ID: "lib2-song",
+ Title: "Song in Lib2",
+ Artist: "Test Artist",
+ ArtistID: "1",
+ Album: "Test Album",
+ AlbumID: "101",
+ Path: "lib2/song.mp3",
+ LibraryID: lib2ID,
+ Participants: model.Participants{},
+ Tags: model.Tags{},
+ Lyrics: "[]",
+ }
+ Expect(mfRepo.Put(&songLib2)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ db := GetDBXBuilder()
+ if testPlaylistID != "" {
+ _ = repo.Delete(testPlaylistID)
+ testPlaylistID = ""
+ }
+ // Clean up test data
+ _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute()
+ _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute()
+ _, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute()
+ _, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute()
+ _, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID)
+ })
+
+ It("should only include tracks from libraries the user has access to (issue #4738)", func() {
+ db := GetDBXBuilder()
+ ctx := log.NewContext(GinkgoT().Context())
+
+ // Create the smart playlist as the restricted user
+ restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false}
+ ctx = request.WithUser(ctx, restrictedUser)
+ restrictedRepo := NewPlaylistRepository(ctx, db)
+
+ // Create a smart playlist that matches all songs
+ rules := &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.Gt{"playCount": -1}, // Matches everything
+ },
+ }
+ newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules}
+ Expect(restrictedRepo.Put(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ By("refreshing the smart playlist")
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
+ pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ By("verifying only the track from library 1 is in the playlist")
+ var foundLib1Song, foundLib2Song bool
+ for _, track := range pls.Tracks {
+ if track.MediaFileID == "lib1-song" {
+ foundLib1Song = true
+ }
+ if track.MediaFileID == "lib2-song" {
+ foundLib2Song = true
+ }
+ }
+ Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist")
+ Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist")
+
+ By("verifying playlist_tracks table only contains the accessible track")
+ var playlistTracksCount int
+ err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount)
+ Expect(err).ToNot(HaveOccurred())
+ // Count should only include tracks visible to the user (lib1-song)
+ // The count may include other test songs from library 1, but NOT lib2-song
+ var lib2TrackCount int
+ err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks")
+
+ By("verifying SongCount matches visible tracks")
+ Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks")
+ })
+ })
+})
diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go
index daf621ffe..07bd96975 100644
--- a/persistence/sql_annotations.go
+++ b/persistence/sql_annotations.go
@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
+ "strings"
"time"
. "github.com/Masterminds/squirrel"
@@ -15,20 +16,20 @@ import (
const annotationTable = "annotation"
func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder {
- if userId(r.ctx) == invalidUserId {
- return query
+ userID := loggedUser(r.ctx).ID
+ if userID == invalidUserId {
+ return query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
}
query = query.
LeftJoin("annotation on ("+
"annotation.item_id = "+idField+
- // item_ids are unique across different item_types, so the clause below is not needed
- //" AND annotation.item_type = '"+r.tableName+"'"+
- " AND annotation.user_id = '"+userId(r.ctx)+"')").
+ " AND annotation.user_id = '"+userID+"')").
Columns(
"coalesce(starred, 0) as starred",
"coalesce(rating, 0) as rating",
"starred_at",
"play_date",
+ "rated_at",
)
if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" {
query = query.Columns(
@@ -38,26 +39,43 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
query = query.Columns("coalesce(play_count, 0) as play_count")
}
+ query = query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
+
return query
}
+func annotationBoolFilter(field string) func(string, any) Sqlizer {
+ return func(_ string, value any) Sqlizer {
+ v, ok := value.(string)
+ if !ok {
+ return nil
+ }
+ if strings.ToLower(v) == "true" {
+ return Expr(fmt.Sprintf("COALESCE(%s, 0) > 0", field))
+ }
+ return Expr(fmt.Sprintf("COALESCE(%s, 0) = 0", field))
+ }
+}
+
func (r sqlRepository) annId(itemID ...string) And {
+ userID := loggedUser(r.ctx).ID
return And{
- Eq{annotationTable + ".user_id": userId(r.ctx)},
+ Eq{annotationTable + ".user_id": userID},
Eq{annotationTable + ".item_type": r.tableName},
Eq{annotationTable + ".item_id": itemID},
}
}
-func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...string) error {
+func (r sqlRepository) annUpsert(values map[string]any, itemIDs ...string) error {
upd := Update(annotationTable).Where(r.annId(itemIDs...))
for f, v := range values {
upd = upd.Set(f, v)
}
c, err := r.executeSQL(upd)
if c == 0 || errors.Is(err, sql.ErrNoRows) {
+ userID := loggedUser(r.ctx).ID
for _, itemID := range itemIDs {
- values["user_id"] = userId(r.ctx)
+ values["user_id"] = userID
values["item_type"] = r.tableName
values["item_id"] = itemID
ins := Insert(annotationTable).SetMap(values)
@@ -72,11 +90,27 @@ func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...strin
func (r sqlRepository) SetStar(starred bool, ids ...string) error {
starredAt := time.Now()
- return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...)
+ return r.annUpsert(map[string]any{"starred": starred, "starred_at": starredAt}, ids...)
}
func (r sqlRepository) SetRating(rating int, itemID string) error {
- return r.annUpsert(map[string]interface{}{"rating": rating}, itemID)
+ ratedAt := time.Now()
+ err := r.annUpsert(map[string]any{"rating": rating, "rated_at": ratedAt}, itemID)
+ if err != nil {
+ return err
+ }
+ return r.updateAvgRating(itemID)
+}
+
+func (r sqlRepository) updateAvgRating(itemID string) error {
+ upd := Update(r.tableName).
+ Where(Eq{"id": itemID}).
+ Set("average_rating", Expr(
+ "coalesce((select round(avg(rating), 2) from annotation where item_id = ? and item_type = ? and rating > 0), 0)",
+ itemID, r.tableName,
+ ))
+ _, err := r.executeSQL(upd)
+ return err
}
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
@@ -86,8 +120,9 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
c, err := r.executeSQL(upd)
if c == 0 || errors.Is(err, sql.ErrNoRows) {
- values := map[string]interface{}{}
- values["user_id"] = userId(r.ctx)
+ userID := loggedUser(r.ctx).ID
+ values := map[string]any{}
+ values["user_id"] = userID
values["item_type"] = r.tableName
values["item_id"] = itemID
values["play_count"] = 1
@@ -117,7 +152,7 @@ func (r sqlRepository) cleanAnnotations() error {
del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)
if err != nil {
- return fmt.Errorf("error cleaning up annotations: %w", err)
+ return fmt.Errorf("error cleaning up %s annotations: %w", r.tableName, err)
}
if c > 0 {
log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c)
diff --git a/persistence/sql_annotations_test.go b/persistence/sql_annotations_test.go
new file mode 100644
index 000000000..15efc5dc7
--- /dev/null
+++ b/persistence/sql_annotations_test.go
@@ -0,0 +1,153 @@
+package persistence
+
+import (
+ "context"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Annotation Filters", func() {
+ var (
+ albumRepo *albumRepository
+ albumWithoutAnnotation model.Album
+ )
+
+ BeforeEach(func() {
+ ctx := request.WithUser(context.Background(), model.User{ID: "userid", UserName: "johndoe"})
+ albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
+
+ // Create album without any annotation (no star, no rating)
+ albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1}
+ Expect(albumRepo.Put(&albumWithoutAnnotation)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID}))
+ })
+
+ Describe("annotationBoolFilter", func() {
+ DescribeTable("creates correct SQL expressions",
+ func(field, value string, expectedSQL string, expectedArgs []any) {
+ sqlizer := annotationBoolFilter(field)(field, value)
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(Equal(expectedSQL))
+ Expect(args).To(Equal(expectedArgs))
+ },
+ Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []any(nil)),
+ Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []any(nil)),
+ Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []any(nil)),
+ Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []any(nil)),
+ )
+
+ It("returns nil if value is not a string", func() {
+ sqlizer := annotationBoolFilter("starred")("starred", 123)
+ Expect(sqlizer).To(BeNil())
+ })
+ })
+
+ Describe("starredFilter", func() {
+ It("false includes items without annotations", func() {
+ albums, err := albumRepo.GetAll(model.QueryOptions{
+ Filters: annotationBoolFilter("starred")("starred", "false"),
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ var found bool
+ for _, a := range albums {
+ if a.ID == albumWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Item without annotation should be included in starred=false filter")
+ })
+
+ It("true excludes items without annotations", func() {
+ albums, err := albumRepo.GetAll(model.QueryOptions{
+ Filters: annotationBoolFilter("starred")("starred", "true"),
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ for _, a := range albums {
+ Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
+ }
+ })
+ })
+
+ Describe("hasRatingFilter", func() {
+ It("false includes items without annotations", func() {
+ albums, err := albumRepo.GetAll(model.QueryOptions{
+ Filters: annotationBoolFilter("rating")("rating", "false"),
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ var found bool
+ for _, a := range albums {
+ if a.ID == albumWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Item without annotation should be included in has_rating=false filter")
+ })
+
+ It("true excludes items without annotations", func() {
+ albums, err := albumRepo.GetAll(model.QueryOptions{
+ Filters: annotationBoolFilter("rating")("rating", "true"),
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ for _, a := range albums {
+ Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
+ }
+ })
+
+ It("true includes items with rating > 0", func() {
+ // Create album with rating 1
+ ratedAlbum := model.Album{ID: "rated-album", Name: "Rated Album", LibraryID: 1}
+ Expect(albumRepo.Put(&ratedAlbum)).To(Succeed())
+ Expect(albumRepo.SetRating(1, ratedAlbum.ID)).To(Succeed())
+ defer func() {
+ _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": ratedAlbum.ID}))
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": ratedAlbum.ID}))
+ }()
+
+ albums, err := albumRepo.GetAll(model.QueryOptions{
+ Filters: annotationBoolFilter("rating")("rating", "true"),
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ var found bool
+ for _, a := range albums {
+ if a.ID == ratedAlbum.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Album with rating 5 should be included in has_rating=true filter")
+ })
+ })
+
+ It("ignores invalid filter values (not strings)", func() {
+ res, err := albumRepo.ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": 123},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ albums := res.(model.Albums)
+
+ var found bool
+ for _, a := range albums {
+ if a.ID == albumWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Item without annotation should be included when filter is ignored")
+ })
+})
diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go
index 7cc24b6c4..321e790db 100644
--- a/persistence/sql_base_repository.go
+++ b/persistence/sql_base_repository.go
@@ -13,6 +13,7 @@ import (
"time"
. "github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -49,25 +50,39 @@ type sqlRepository struct {
const invalidUserId = "-1"
-func userId(ctx context.Context) string {
- if user, ok := request.UserFrom(ctx); !ok {
- return invalidUserId
- } else {
- return user.ID
- }
-}
-
func loggedUser(ctx context.Context) *model.User {
if user, ok := request.UserFrom(ctx); !ok {
- return &model.User{}
+ return &model.User{ID: invalidUserId}
} else {
return &user
}
}
-func isAdmin(ctx context.Context) bool {
- user := loggedUser(ctx)
- return user.IsAdmin
+// ownerFilter returns the predicate restricting access to rows owned by the logged-in user, for
+// tables with a user_id column. It returns nil for admins and for headless/system contexts (invalid
+// user), meaning "no ownership restriction". Callers should skip the WHERE clause when it is nil.
+//
+// The predicate uses an unqualified user_id, so it only works on queries where that column is
+// unambiguous (no join introducing a second user_id).
+func (r sqlRepository) ownerFilter() Sqlizer {
+ if usr := loggedUser(r.ctx); !usr.IsAdmin && usr.ID != invalidUserId {
+ return Eq{"user_id": usr.ID}
+ }
+ return nil
+}
+
+// addRestriction combines an optional caller predicate with the ownership filter, producing the
+// WHERE clause for owner-scoped reads. For admins and headless contexts ownerFilter() is nil and
+// only the caller's predicate (if any) remains.
+func (r sqlRepository) addRestriction(sql ...Sqlizer) Sqlizer {
+ s := And{}
+ if len(sql) > 0 {
+ s = append(s, sql[0])
+ }
+ if owner := r.ownerFilter(); owner != nil {
+ s = append(s, owner)
+ }
+ return s
}
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
@@ -86,6 +101,10 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun
// which gives precedence to sort tags.
// Ex: order_title => (coalesce(nullif(sort_title,”),order_title) collate nocase)
// To avoid performance issues, indexes should be created for these sort expressions
+//
+// NOTE: if an individual item has spaces, it should be wrapped in parentheses. For example,
+// you should write "(lyrics != '[]')". This prevents the item being split unexpectedly.
+// Without parentheses, "lyrics != '[]'" would be mapped as simply "lyrics"
func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName ...string) {
tn := r.tableName
if len(tableName) > 0 {
@@ -195,10 +214,36 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti
return sq
}
+// libraryIdFilter is a filter function to be added to resources that have a library_id column.
+func libraryIdFilter(_ string, value any) Sqlizer {
+ return Eq{"library_id": value}
+}
+
+// applyLibraryFilter adds library filtering to queries for tables that have a library_id column
+// This ensures users only see content from libraries they have access to
+func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) SelectBuilder {
+ user := loggedUser(r.ctx)
+
+ // If the user is an admin, or the user ID is invalid (e.g., when no user is logged in), skip the library filter
+ if user.IsAdmin || user.ID == invalidUserId {
+ return sq
+ }
+
+ table := r.tableName
+ if len(tableName) > 0 {
+ table = tableName[0]
+ }
+
+ // Get user's accessible library IDs
+ // Use subquery to filter by user's library access
+ return sq.Where(Expr(table+".library_id IN ("+
+ "SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)", user.ID))
+}
+
func (r sqlRepository) seedKey() string {
// Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed
// used in the query. Hashing the user ID and converting it to a hex string will do the trick
- userIDHash := md5.Sum([]byte(userId(r.ctx)))
+ userIDHash := md5.Sum([]byte(loggedUser(r.ctx).ID))
return fmt.Sprintf("%s|%x", r.tableName, userIDHash)
}
@@ -255,7 +300,7 @@ func (r sqlRepository) toSQL(sq Sqlizer) (string, dbx.Params, error) {
return result, params, nil
}
-func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error {
+func (r sqlRepository) queryOne(sq Sqlizer, response any) error {
query, args, err := r.toSQL(sq)
if err != nil {
return err
@@ -302,7 +347,7 @@ func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options ..
}, nil
}
-func (r sqlRepository) queryAll(sq SelectBuilder, response interface{}, options ...model.QueryOptions) error {
+func (r sqlRepository) queryAll(sq SelectBuilder, response any, options ...model.QueryOptions) error {
if len(options) > 0 && options[0].Offset > 0 {
sq = r.optimizePagination(sq, options[0])
}
@@ -321,7 +366,7 @@ func (r sqlRepository) queryAll(sq SelectBuilder, response interface{}, options
}
// queryAllSlice is a helper function to query a single column and return the result in a slice
-func (r sqlRepository) queryAllSlice(sq SelectBuilder, response interface{}) error {
+func (r sqlRepository) queryAllSlice(sq SelectBuilder, response any) error {
query, args, err := r.toSQL(sq)
if err != nil {
return err
@@ -356,6 +401,65 @@ func (r sqlRepository) exists(cond Sqlizer) (bool, error) {
return res.Exist > 0, err
}
+// updateOwned performs an atomic, ownership-restricted update of the row identified by id, for
+// repositories whose table has a user_id column. Non-admins can only update rows they own: the
+// ownership predicate is part of the UPDATE's WHERE clause, so a row owned by another user simply
+// does not match and no write happens. Ownership itself is immutable here: user_id is never written,
+// so no caller (admin included) can reassign a row to a different owner via an update. Unlike put,
+// it never falls through to an INSERT, so a non-matching id never creates a row.
+//
+// When the update matches no row it classifies the failure: if the row exists but is owned by
+// another user it returns rest.ErrPermissionDenied, otherwise rest.ErrNotFound. The write itself is
+// still atomic; the extra lookup happens only on the failure path (count == 0), where no write
+// occurred, so there is no TOCTOU on the update.
+func (r sqlRepository) updateOwned(id string, m any, colsToUpdate ...string) error {
+ values, err := toSQLArgs(m)
+ if err != nil {
+ return fmt.Errorf("error preparing values to write to DB: %w", err)
+ }
+ updateValues := filterUpdateValues(values, id, colsToUpdate...)
+ delete(updateValues, "user_id") // ownership is immutable on update
+ update := Update(r.tableName).Where(r.addRestriction(Eq{"id": id})).SetMap(updateValues)
+ count, err := r.executeSQL(update)
+ if err != nil {
+ return err
+ }
+ if count == 0 {
+ return r.classifyOwnedWriteMiss(id)
+ }
+ return nil
+}
+
+// deleteOwned performs an atomic, ownership-restricted delete of the row identified by id, for
+// repositories whose table has a user_id column. Non-admins can only delete rows they own: the
+// ownership predicate is part of the DELETE's WHERE clause, so a row owned by another user simply
+// does not match and is left untouched. The failure path mirrors updateOwned (see
+// classifyOwnedWriteMiss), so there is no TOCTOU on the delete.
+func (r sqlRepository) deleteOwned(id string) error {
+ count, err := r.executeSQL(Delete(r.tableName).Where(r.addRestriction(Eq{"id": id})))
+ if err != nil {
+ return err
+ }
+ if count == 0 {
+ return r.classifyOwnedWriteMiss(id)
+ }
+ return nil
+}
+
+// classifyOwnedWriteMiss explains why an ownership-filtered write (updateOwned/deleteOwned) matched
+// no row: rest.ErrPermissionDenied if the row exists but is owned by another user, otherwise
+// rest.ErrNotFound. It runs only on the failure path (count == 0), where no write occurred.
+func (r sqlRepository) classifyOwnedWriteMiss(id string) error {
+ exists, err := r.exists(Eq{"id": id})
+ if err != nil {
+ return err
+ }
+ if exists {
+ return rest.ErrPermissionDenied
+ }
+ return rest.ErrNotFound
+}
+
func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) {
countQuery = countQuery.
RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count").
@@ -368,7 +472,7 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
return res.Count, err
}
-func (r sqlRepository) putByMatch(filter Sqlizer, id string, m interface{}, colsToUpdate ...string) (string, error) {
+func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate ...string) (string, error) {
if id != "" {
return r.put(id, m, colsToUpdate...)
}
@@ -382,31 +486,38 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m interface{}, cols
return r.put(res.ID, m, colsToUpdate...)
}
-func (r sqlRepository) put(id string, m interface{}, colsToUpdate ...string) (newId string, err error) {
+// filterUpdateValues selects, from a marshaled column map, the values to write in an UPDATE on the
+// row identified by id: only the requested colsToUpdate (or all columns when none are specified),
+// dropping columns that must never be overwritten on update (created_at, birth_time).
+func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any {
+ updateValues := map[string]any{}
+
+ // This is a map of the columns that need to be updated, if specified
+ c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) {
+ return toSnakeCase(s), struct{}{}
+ })
+ for k, v := range values {
+ if _, found := c2upd[k]; len(c2upd) == 0 || found {
+ updateValues[k] = v
+ }
+ }
+
+ updateValues["id"] = id
+ delete(updateValues, "created_at")
+ // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now
+ // TODO move to mediafile_repository when each repo has its own upsert method
+ delete(updateValues, "birth_time")
+ return updateValues
+}
+
+func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) {
values, err := toSQLArgs(m)
if err != nil {
return "", fmt.Errorf("error preparing values to write to DB: %w", err)
}
// If there's an ID, try to update first
if id != "" {
- updateValues := map[string]interface{}{}
-
- // This is a map of the columns that need to be updated, if specified
- c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) {
- return toSnakeCase(s), struct{}{}
- })
- for k, v := range values {
- if _, found := c2upd[k]; len(c2upd) == 0 || found {
- updateValues[k] = v
- }
- }
-
- updateValues["id"] = id
- delete(updateValues, "created_at")
- // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now
- // TODO move to mediafile_repository when each repo has its own upsert method
- delete(updateValues, "birth_time")
- update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues)
+ update := Update(r.tableName).Where(Eq{"id": id}).SetMap(filterUpdateValues(values, id, colsToUpdate...))
count, err := r.executeSQL(update)
if err != nil {
return "", err
diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go
index 4b380e298..b46e2066b 100644
--- a/persistence/sql_base_repository_test.go
+++ b/persistence/sql_base_repository_test.go
@@ -136,6 +136,10 @@ var _ = Describe("sqlRepository", func() {
})
Describe("buildSortOrder", func() {
+ BeforeEach(func() {
+ r.sortMappings = map[string]string{}
+ })
+
Context("single field", func() {
It("sorts by specified field", func() {
sql := r.buildSortOrder("name", "desc")
@@ -163,6 +167,14 @@ var _ = Describe("sqlRepository", func() {
sql := r.buildSortOrder("name desc, age, status asc", "desc")
Expect(sql).To(Equal("name asc, age desc, status desc"))
})
+ It("handles spaces in mapped field", func() {
+ r.sortMappings = map[string]string{
+ "has_lyrics": "(lyrics != '[]'), updated_at",
+ }
+ sql := r.buildSortOrder("has_lyrics", "desc")
+ Expect(sql).To(Equal("(lyrics != '[]') desc, updated_at desc"))
+ })
+
})
Context("function fields", func() {
It("handles functions with multiple params", func() {
@@ -211,4 +223,62 @@ var _ = Describe("sqlRepository", func() {
Expect(hasher.CurrentSeed(id)).To(Equal("seed"))
})
})
+
+ Describe("applyLibraryFilter", func() {
+ var sq squirrel.SelectBuilder
+
+ BeforeEach(func() {
+ sq = squirrel.Select("*").From("test_table")
+ })
+
+ Context("Admin User", func() {
+ BeforeEach(func() {
+ r.ctx = request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true})
+ })
+
+ It("should not apply library filter for admin users", func() {
+ result := r.applyLibraryFilter(sq)
+ sql, _, _ := result.ToSql()
+ Expect(sql).To(Equal("SELECT * FROM test_table"))
+ })
+ })
+
+ Context("Regular User", func() {
+ BeforeEach(func() {
+ r.ctx = request.WithUser(context.Background(), model.User{ID: "user123", IsAdmin: false})
+ })
+
+ It("should apply library filter for regular users", func() {
+ result := r.applyLibraryFilter(sq)
+ sql, args, _ := result.ToSql()
+ Expect(sql).To(ContainSubstring("IN (SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)"))
+ Expect(args).To(ContainElement("user123"))
+ })
+
+ It("should use custom table name when provided", func() {
+ result := r.applyLibraryFilter(sq, "custom_table")
+ sql, args, _ := result.ToSql()
+ Expect(sql).To(ContainSubstring("custom_table.library_id IN"))
+ Expect(args).To(ContainElement("user123"))
+ })
+ })
+
+ Context("Headless Process (No User Context)", func() {
+ BeforeEach(func() {
+ r.ctx = context.Background() // No user context
+ })
+
+ It("should not apply library filter for headless processes", func() {
+ result := r.applyLibraryFilter(sq)
+ sql, _, _ := result.ToSql()
+ Expect(sql).To(Equal("SELECT * FROM test_table"))
+ })
+
+ It("should not apply library filter even with custom table name", func() {
+ result := r.applyLibraryFilter(sq, "custom_table")
+ sql, _, _ := result.ToSql()
+ Expect(sql).To(Equal("SELECT * FROM test_table"))
+ })
+ })
+ })
})
diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go
index 56645ea21..19f16b231 100644
--- a/persistence/sql_bookmarks.go
+++ b/persistence/sql_bookmarks.go
@@ -15,21 +15,20 @@ import (
const bookmarkTable = "bookmark"
func (r sqlRepository) withBookmark(query SelectBuilder, idField string) SelectBuilder {
- if userId(r.ctx) == invalidUserId {
+ userID := loggedUser(r.ctx).ID
+ if userID == invalidUserId {
return query
}
return query.
LeftJoin("bookmark on (" +
"bookmark.item_id = " + idField +
- // item_ids are unique across different item_types, so the clause below is not needed
- //" AND bookmark.item_type = '" + r.tableName + "'" +
- " AND bookmark.user_id = '" + userId(r.ctx) + "')").
+ " AND bookmark.user_id = '" + userID + "')").
Columns("coalesce(position, 0) as bookmark_position")
}
func (r sqlRepository) bmkID(itemID ...string) And {
return And{
- Eq{bookmarkTable + ".user_id": userId(r.ctx)},
+ Eq{bookmarkTable + ".user_id": loggedUser(r.ctx).ID},
Eq{bookmarkTable + ".item_type": r.tableName},
Eq{bookmarkTable + ".item_id": itemID},
}
@@ -38,7 +37,7 @@ func (r sqlRepository) bmkID(itemID ...string) And {
func (r sqlRepository) bmkUpsert(itemID, comment string, position int64) error {
client, _ := request.ClientFrom(r.ctx)
user, _ := request.UserFrom(r.ctx)
- values := map[string]interface{}{
+ values := map[string]any{
"comment": comment,
"position": position,
"updated_at": time.Now(),
@@ -149,10 +148,10 @@ func (r sqlRepository) cleanBookmarks() error {
del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)
if err != nil {
- return fmt.Errorf("error cleaning up bookmarks: %w", err)
+ return fmt.Errorf("error cleaning up %s bookmarks: %w", r.tableName, err)
}
if c > 0 {
- log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c)
+ log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c, "itemType", r.tableName)
}
return nil
}
diff --git a/persistence/sql_participations.go b/persistence/sql_participations.go
index 006b7063b..38b0203fa 100644
--- a/persistence/sql_participations.go
+++ b/persistence/sql_participations.go
@@ -15,6 +15,13 @@ type participant struct {
SubRole string `json:"subRole,omitempty"`
}
+// flatParticipant represents a flattened participant structure for SQL processing
+type flatParticipant struct {
+ ArtistID string `json:"artist_id"`
+ Role string `json:"role"`
+ SubRole string `json:"sub_role,omitempty"`
+}
+
func marshalParticipants(participants model.Participants) string {
dbParticipants := make(map[model.Role][]participant)
for role, artists := range participants {
@@ -44,8 +51,10 @@ func unmarshalParticipants(data string) (model.Participants, error) {
}
func (r sqlRepository) updateParticipants(itemID string, participants model.Participants) error {
- ids := participants.AllIDs()
- sqd := Delete(r.tableName + "_artists").Where(And{Eq{r.tableName + "_id": itemID}, NotEq{"artist_id": ids}})
+ // Delete all existing participant entries for this item.
+ // This ensures stale role associations are removed when an artist's role changes
+ // (e.g., an artist was both albumartist and composer, but is now only composer).
+ sqd := Delete(r.tableName + "_artists").Where(Eq{r.tableName + "_id": itemID})
_, err := r.executeSQL(sqd)
if err != nil {
return err
@@ -53,22 +62,47 @@ func (r sqlRepository) updateParticipants(itemID string, participants model.Part
if len(participants) == 0 {
return nil
}
- sqi := Insert(r.tableName+"_artists").
- Columns(r.tableName+"_id", "artist_id", "role", "sub_role").
- Suffix(fmt.Sprintf("on conflict (artist_id, %s_id, role, sub_role) do nothing", r.tableName))
+
+ var flatParticipants []flatParticipant
for role, artists := range participants {
for _, artist := range artists {
- sqi = sqi.Values(itemID, artist.ID, role.String(), artist.SubRole)
+ flatParticipants = append(flatParticipants, flatParticipant{
+ ArtistID: artist.ID,
+ Role: role.String(),
+ SubRole: artist.SubRole,
+ })
}
}
- _, err = r.executeSQL(sqi)
+
+ participantsJSON, err := json.Marshal(flatParticipants)
+ if err != nil {
+ return fmt.Errorf("marshaling participants: %w", err)
+ }
+
+ // Build the INSERT query using json_each and INNER JOIN to artist table
+ // to automatically filter out non-existent artist IDs
+ query := fmt.Sprintf(`
+ INSERT INTO %[1]s_artists (%[1]s_id, artist_id, role, sub_role)
+ SELECT ?,
+ json_extract(value, '$.artist_id') as artist_id,
+ json_extract(value, '$.role') as role,
+ COALESCE(json_extract(value, '$.sub_role'), '') as sub_role
+ -- Parse the flat JSON array: [{"artist_id": "id", "role": "role", "sub_role": "subRole"}]
+ FROM json_each(?) -- Iterate through each array element
+ -- CRITICAL: Only insert records for artists that actually exist in the database
+ JOIN artist ON artist.id = json_extract(value, '$.artist_id') -- Filter out non-existent artist IDs via INNER JOIN
+ -- Handle duplicate insertions gracefully (e.g., if called multiple times)
+ ON CONFLICT (artist_id, %[1]s_id, role, sub_role) DO NOTHING -- Ignore duplicates
+ `, r.tableName)
+
+ _, err = r.executeSQL(Expr(query, itemID, string(participantsJSON)))
return err
}
func (r *sqlRepository) getParticipants(m *model.MediaFile) (model.Participants, error) {
ar := NewArtistRepository(r.ctx, r.db)
ids := m.Participants.AllIDs()
- artists, err := ar.GetAll(model.QueryOptions{Filters: Eq{"id": ids}})
+ artists, err := ar.GetAll(model.QueryOptions{Filters: Eq{"artist.id": ids}})
if err != nil {
return nil, fmt.Errorf("getting participants: %w", err)
}
diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go
index 6be368b00..1dcabcec6 100644
--- a/persistence/sql_restful.go
+++ b/persistence/sql_restful.go
@@ -1,6 +1,7 @@
package persistence
import (
+ "cmp"
"context"
"fmt"
"reflect"
@@ -45,7 +46,7 @@ func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.Query
continue
}
// Default to a "starts with" filter
- filters = append(filters, startsWithFilter(f, v))
+ filters = append(filters, Like{f: fmt.Sprintf("%s%%", v)})
}
return filters
}
@@ -90,8 +91,10 @@ func eqFilter(field string, value any) Sqlizer {
return Eq{field: value}
}
-func startsWithFilter(field string, value any) Sqlizer {
- return Like{field: fmt.Sprintf("%s%%", value)}
+func startsWithFilter(field string) func(string, any) Sqlizer {
+ return func(_ string, value any) Sqlizer {
+ return Like{field: fmt.Sprintf("%s%%", value)}
+ }
}
func containsFilter(field string) func(string, any) Sqlizer {
@@ -105,8 +108,14 @@ func booleanFilter(field string, value any) Sqlizer {
return Eq{field: v == "true"}
}
-func fullTextFilter(tableName string) func(string, any) Sqlizer {
- return func(field string, value any) Sqlizer { return fullTextExpr(tableName, value.(string)) }
+func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer {
+ return func(field string, value any) Sqlizer {
+ v := strings.ToLower(value.(string))
+ return cmp.Or[Sqlizer](
+ mbidExpr(tableName, v, mbidFields...),
+ getSearchStrategy(tableName, v),
+ )
+ }
}
func substringFilter(field string, value any) Sqlizer {
diff --git a/persistence/sql_restful_test.go b/persistence/sql_restful_test.go
index 20cc31a36..32f418b8e 100644
--- a/persistence/sql_restful_test.go
+++ b/persistence/sql_restful_test.go
@@ -2,9 +2,12 @@ package persistence
import (
"context"
+ "strings"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -23,11 +26,13 @@ var _ = Describe("sqlRestful", func() {
Expect(r.parseRestFilters(context.Background(), options)).To(BeNil())
})
- It(`returns nil if tries a filter with fullTextExpr("'")`, func() {
+ It(`returns nil if tries a filter with legacySearchExpr("'")`, func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Search.Backend = "legacy"
r.filterMappings = map[string]filterFunc{
"name": fullTextFilter("table"),
}
- options.Filters = map[string]interface{}{"name": "'"}
+ options.Filters = map[string]any{"name": "'"}
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
})
@@ -37,33 +42,217 @@ var _ = Describe("sqlRestful", func() {
return nil
},
}
- options.Filters = map[string]interface{}{"name": "joe"}
+ options.Filters = map[string]any{"name": "joe"}
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
})
It("returns a '=' condition for 'id' filter", func() {
- options.Filters = map[string]interface{}{"id": "123"}
+ options.Filters = map[string]any{"id": "123"}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
})
It("returns a 'in' condition for multiples 'id' filters", func() {
- options.Filters = map[string]interface{}{"id": []string{"123", "456"}}
+ options.Filters = map[string]any{"id": []string{"123", "456"}}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
})
It("returns a 'like' condition for other filters", func() {
- options.Filters = map[string]interface{}{"name": "joe"}
+ options.Filters = map[string]any{"name": "joe"}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
})
It("uses the custom filter", func() {
r.filterMappings = map[string]filterFunc{
- "test": func(field string, value interface{}) squirrel.Sqlizer {
+ "test": func(field string, value any) squirrel.Sqlizer {
return squirrel.Gt{field: value}
},
}
- options.Filters = map[string]interface{}{"test": 100}
+ options.Filters = map[string]any{"test": 100}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
})
})
+
+ Describe("fullTextFilter function", func() {
+ var filter filterFunc
+ var tableName string
+ var mbidFields []string
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Search.Backend = "legacy"
+ tableName = "test_table"
+ mbidFields = []string{"mbid", "artist_mbid"}
+ filter = fullTextFilter(tableName, mbidFields...)
+ })
+
+ Context("when value is a valid UUID", func() {
+ It("returns only the mbid filter (precedence over full text)", func() {
+ uuid := "550e8400-e29b-41d4-a716-446655440000"
+ result := filter("search", uuid)
+
+ expected := squirrel.Or{
+ squirrel.Eq{"test_table.mbid": uuid},
+ squirrel.Eq{"test_table.artist_mbid": uuid},
+ }
+ Expect(result).To(Equal(expected))
+ })
+
+ It("falls back to full text when no mbid fields are provided", func() {
+ noMbidFilter := fullTextFilter(tableName)
+ uuid := "550e8400-e29b-41d4-a716-446655440000"
+ result := noMbidFilter("search", uuid)
+
+ // mbidExpr with no fields returns nil, so cmp.Or falls back to search strategy
+ sql, args, err := result.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
+ Expect(args).To(ContainElement("% 550e8400-e29b-41d4-a716-446655440000%"))
+ })
+ })
+
+ Context("when value is not a valid UUID", func() {
+ It("returns full text search condition only", func() {
+ result := filter("search", "beatles")
+
+ // mbidExpr returns nil for non-UUIDs, so search strategy result is returned directly
+ sql, args, err := result.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
+ Expect(args).To(ContainElement("% beatles%"))
+ })
+
+ It("handles multi-word search terms", func() {
+ result := filter("search", "the beatles abbey road")
+
+ sql, args, err := result.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ // All words should be present as LIKE conditions
+ Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
+ Expect(args).To(HaveLen(4))
+ Expect(args).To(ContainElement("% the%"))
+ Expect(args).To(ContainElement("% beatles%"))
+ Expect(args).To(ContainElement("% abbey%"))
+ Expect(args).To(ContainElement("% road%"))
+ })
+ })
+
+ Context("when SearchFullString config changes behavior", func() {
+ It("uses different separator with SearchFullString=false", func() {
+ conf.Server.Search.FullString = false
+ result := filter("search", "test query")
+
+ sql, args, err := result.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
+ Expect(args).To(HaveLen(2))
+ Expect(args).To(ContainElement("% test%"))
+ Expect(args).To(ContainElement("% query%"))
+ })
+
+ It("uses no separator with SearchFullString=true", func() {
+ conf.Server.Search.FullString = true
+ result := filter("search", "test query")
+
+ sql, args, err := result.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
+ Expect(args).To(HaveLen(2))
+ Expect(args).To(ContainElement("%test%"))
+ Expect(args).To(ContainElement("%query%"))
+ })
+ })
+
+ Context("single-character queries (regression: must not be rejected)", func() {
+ It("returns valid filter for single-char query with legacy backend", func() {
+ conf.Server.Search.Backend = "legacy"
+ result := filter("search", "a")
+ Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
+ sql, args, err := result.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("LIKE"))
+ Expect(args).ToNot(BeEmpty())
+ })
+
+ It("returns valid filter for single-char query with FTS backend", func() {
+ conf.Server.Search.Backend = "fts"
+ conf.Server.Search.FullString = false
+ ftsFilter := fullTextFilter(tableName, mbidFields...)
+ result := ftsFilter("search", "a")
+ Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
+ sql, args, err := result.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("MATCH"))
+ Expect(args).ToNot(BeEmpty())
+ })
+ })
+
+ Context("edge cases", func() {
+ It("returns nil for empty string", func() {
+ result := filter("search", "")
+ Expect(result).To(BeNil())
+ })
+
+ It("returns nil for string with only whitespace", func() {
+ result := filter("search", " ")
+ Expect(result).To(BeNil())
+ })
+
+ It("handles special characters that are sanitized", func() {
+ result := filter("search", "don't")
+
+ sql, args, err := result.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
+ Expect(args).To(ContainElement("% dont%"))
+ })
+
+ It("returns nil for single quote (SQL injection protection)", func() {
+ result := filter("search", "'")
+ Expect(result).To(BeNil())
+ })
+
+ It("handles mixed case UUIDs", func() {
+ uuid := "550E8400-E29B-41D4-A716-446655440000"
+ result := filter("search", uuid)
+
+ // Should return only mbid filter (uppercase UUID should work)
+ expected := squirrel.Or{
+ squirrel.Eq{"test_table.mbid": strings.ToLower(uuid)},
+ squirrel.Eq{"test_table.artist_mbid": strings.ToLower(uuid)},
+ }
+ Expect(result).To(Equal(expected))
+ })
+
+ It("handles invalid UUID format gracefully", func() {
+ result := filter("search", "550e8400-invalid-uuid")
+
+ // Should return full text filter since UUID is invalid
+ sql, args, err := result.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
+ Expect(args).To(ContainElement("% 550e8400-invalid-uuid%"))
+ })
+
+ It("handles empty mbid fields array", func() {
+ emptyMbidFilter := fullTextFilter(tableName, []string{}...)
+ result := emptyMbidFilter("search", "test")
+
+ // mbidExpr with empty fields returns nil, so search strategy result is returned directly
+ sql, args, err := result.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
+ Expect(args).To(ContainElement("% test%"))
+ })
+
+ It("converts value to lowercase before processing", func() {
+ result := filter("search", "TEST")
+
+ sql, args, err := result.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
+ Expect(args).To(ContainElement("% test%"))
+ })
+ })
+ })
+
})
diff --git a/persistence/sql_search.go b/persistence/sql_search.go
index 9ac171263..43965ebb7 100644
--- a/persistence/sql_search.go
+++ b/persistence/sql_search.go
@@ -4,6 +4,7 @@ import (
"strings"
. "github.com/Masterminds/squirrel"
+ "github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str"
@@ -14,45 +15,81 @@ func formatFullText(text ...string) string {
return " " + fullText
}
-func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, includeMissing bool, results any, orderBys ...string) error {
+// searchConfig holds per-repository constants for doSearch.
+type searchConfig struct {
+ NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid")
+ OrderBy []string // ORDER BY for text search results (e.g. ["name"])
+ MBIDFields []string // columns to match when query is a UUID
+ // LibraryFilter overrides the default applyLibraryFilter for FTS Phase 1.
+ // Needed when library access requires a junction table (e.g. artist → library_artist).
+ LibraryFilter func(sq SelectBuilder) SelectBuilder
+}
+
+// searchStrategy defines how to execute a text search against a repository table.
+// options carries filters and pagination that must reach all query phases,
+// including FTS Phase 1 which builds its own query outside sq.
+type searchStrategy interface {
+ Sqlizer
+ execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error
+}
+
+// getSearchStrategy returns the appropriate search strategy based on config and query content.
+// Returns nil when the query produces no searchable tokens.
+func getSearchStrategy(tableName, query string) searchStrategy {
+ if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString {
+ return newLegacySearch(tableName, query)
+ }
+ if containsCJK(query) {
+ return newLikeSearch(tableName, query)
+ }
+ return newFTSSearch(tableName, query)
+}
+
+// doSearch dispatches a search query: empty → natural order, UUID → MBID match,
+// otherwise delegates to getSearchStrategy. sq must already have LIMIT/OFFSET set
+// via newSelect(options...). options is forwarded so FTS Phase 1 can apply the same
+// filters and pagination independently.
+func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg searchConfig, options model.QueryOptions) error {
q = strings.TrimSpace(q)
q = strings.TrimSuffix(q, "*")
+
+ sq = sq.Where(Eq{r.tableName + ".missing": false})
+
+ // Empty query (OpenSubsonic `search3?query=""`) — return all in natural order.
+ if q == "" || q == `""` {
+ sq = sq.OrderBy(cfg.NaturalOrder)
+ return r.queryAll(sq, results, options)
+ }
+
+ // MBID search: if query is a valid UUID, search by MBID fields instead
+ if uuid.Validate(q) == nil && len(cfg.MBIDFields) > 0 {
+ sq = sq.Where(mbidExpr(r.tableName, q, cfg.MBIDFields...))
+ return r.queryAll(sq, results)
+ }
+
+ // Min-length guard: single-character queries are too broad for search3.
+ // This check lives here (not in the strategies) so that fullTextFilter
+ // (REST filter path) can still use single-character queries.
if len(q) < 2 {
return nil
}
- //sq := r.newSelect().Columns(r.tableName + ".*")
- //sq = r.withAnnotation(sq, r.tableName+".id")
- //sq = r.withBookmark(sq, r.tableName+".id")
- filter := fullTextExpr(r.tableName, q)
- if filter != nil {
- sq = sq.Where(filter)
- sq = sq.OrderBy(orderBys...)
- } else {
- // If the filter is empty, we sort by rowid.
- // This is to speed up the results of `search3?query=""`, for OpenSubsonic
- sq = sq.OrderBy(r.tableName + ".rowid")
- }
- if !includeMissing {
- sq = sq.Where(Eq{r.tableName + ".missing": false})
- }
- sq = sq.Limit(uint64(size)).Offset(uint64(offset))
- return r.queryAll(sq, results, model.QueryOptions{Offset: offset})
-}
-
-func fullTextExpr(tableName string, s string) Sqlizer {
- q := str.SanitizeStrings(s)
- if q == "" {
+ strategy := getSearchStrategy(r.tableName, q)
+ if strategy == nil {
return nil
}
- var sep string
- if !conf.Server.SearchFullString {
- sep = " "
- }
- parts := strings.Split(q, " ")
- filters := And{}
- for _, part := range parts {
- filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"})
- }
- return filters
+
+ return strategy.execute(r, sq, results, cfg, options)
+}
+
+func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
+ if uuid.Validate(mbid) != nil || len(mbidFields) == 0 {
+ return nil
+ }
+ mbid = strings.ToLower(mbid)
+ var cond []Sqlizer
+ for _, mbidField := range mbidFields {
+ cond = append(cond, Eq{tableName + "." + mbidField: mbid})
+ }
+ return Or(cond)
}
diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go
new file mode 100644
index 000000000..b90dc937b
--- /dev/null
+++ b/persistence/sql_search_fts.go
@@ -0,0 +1,441 @@
+package persistence
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ . "github.com/Masterminds/squirrel"
+ "github.com/deluan/sanitize"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+)
+
+// containsCJK returns true if the string contains any CJK (Chinese/Japanese/Korean) characters.
+// CJK text doesn't use spaces between words, so FTS5's unicode61 tokenizer treats entire
+// CJK phrases as single tokens, making token-based search ineffective for CJK content.
+func containsCJK(s string) bool {
+ for _, r := range s {
+ if unicode.Is(unicode.Han, r) ||
+ unicode.Is(unicode.Hiragana, r) ||
+ unicode.Is(unicode.Katakana, r) ||
+ unicode.Is(unicode.Hangul, r) {
+ return true
+ }
+ }
+ return false
+}
+
+// fts5SpecialChars matches characters that should be stripped from user input.
+// We keep only Unicode letters, numbers, whitespace, * (prefix wildcard), " (phrase quotes),
+// and \x00 (internal placeholder marker). All punctuation is removed because the unicode61
+// tokenizer treats it as token separators, and characters like ' can cause FTS5 parse errors
+// as unbalanced string delimiters.
+var fts5SpecialChars = regexp.MustCompile(`[^\p{L}\p{N}\s*"\x00]`)
+
+// fts5PunctStrip strips everything except letters and numbers (no whitespace, wildcards, or quotes).
+// Used for normalizing words at index time to create concatenated forms (e.g., "R.E.M." → "REM").
+var fts5PunctStrip = regexp.MustCompile(`[^\p{L}\p{N}]`)
+
+// fts5Operators matches FTS5 boolean operators as whole words (case-insensitive).
+var fts5Operators = regexp.MustCompile(`(?i)\b(AND|OR|NOT|NEAR)\b`)
+
+// fts5LeadingStar matches a * at the start of a token. FTS5 only supports * at the end (prefix queries).
+var fts5LeadingStar = regexp.MustCompile(`(^|[\s])\*+`)
+
+// normalizeForFTS takes multiple strings and returns a space-separated, deduplicated list of
+// alternative searchable forms for each word: punctuation-stripped (R.E.M. → REM, AC/DC → ACDC)
+// and ASCII-transliterated (Bjørk → Bjork, œuvre → oeuvre). The transliterated form is needed
+// because FTS5's `unicode61 remove_diacritics 2` only handles NFKD-decomposable diacritics —
+// atomic letters like ø/æ/œ/ß survive tokenization, so the query side and index side disagree
+// without an explicit transliterated entry here.
+func normalizeForFTS(values ...string) string {
+ seen := make(map[string]struct{})
+ var result []string
+ add := func(orig, variant string) {
+ if variant == "" || variant == orig {
+ return
+ }
+ lower := strings.ToLower(variant)
+ if _, ok := seen[lower]; ok {
+ return
+ }
+ seen[lower] = struct{}{}
+ result = append(result, variant)
+ }
+ for _, v := range values {
+ for word := range strings.FieldsSeq(v) {
+ transliterated := sanitize.Accents(word)
+ // Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne.
+ add(word, fts5PunctStrip.ReplaceAllString(transliterated, ""))
+ // Accent-only transliteration for words without name-punctuation (Bjørk → Bjork).
+ add(word, transliterated)
+ }
+ }
+ return strings.Join(result, " ")
+}
+
+// isSingleUnicodeLetter returns true if token is exactly one Unicode letter.
+func isSingleUnicodeLetter(token string) bool {
+ r, size := utf8.DecodeRuneInString(token)
+ return size == len(token) && size > 0 && unicode.IsLetter(r)
+}
+
+// namePunctuation is the set of characters commonly used as separators in artist/album
+// names (hyphens, slashes, dots, apostrophes). Only words containing these are candidates
+// for punctuated-word processing; other special characters (^, :, &) are just stripped.
+const namePunctuation = `-/.''`
+
+// processPunctuatedWords handles words with embedded name punctuation before the general
+// special-character stripping. For each punctuated word it produces either:
+// - A quoted phrase for dotted abbreviations: R.E.M. → "R E M"
+// - A phrase+concat OR for other patterns: a-ha → ("a ha" OR aha*)
+func processPunctuatedWords(input string, phrases []string) (string, []string) {
+ words := strings.Fields(input)
+ var result []string
+ for _, w := range words {
+ if strings.HasPrefix(w, "\x00") || strings.ContainsAny(w, `*"`) || !strings.ContainsAny(w, namePunctuation) {
+ result = append(result, w)
+ continue
+ }
+ concat := fts5PunctStrip.ReplaceAllString(w, "")
+ if concat == "" || concat == w {
+ result = append(result, w)
+ continue
+ }
+ subTokens := strings.Fields(fts5SpecialChars.ReplaceAllString(w, " "))
+ if len(subTokens) < 2 {
+ // Single sub-token after splitting (e.g., N' → N): just use the stripped form
+ result = append(result, concat)
+ continue
+ }
+ // Dotted abbreviations (R.E.M., U.K.) — all single letters separated by dots only
+ if isDottedAbbreviation(w, subTokens) {
+ phrases = append(phrases, fmt.Sprintf(`"%s"`, strings.Join(subTokens, " ")))
+ } else {
+ // Punctuated names (a-ha, AC/DC, Jay-Z) — phrase for adjacency + concat for search_normalized
+ phrases = append(phrases, fmt.Sprintf(`("%s" OR %s*)`, strings.Join(subTokens, " "), concat))
+ }
+ result = append(result, fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1))
+ }
+ return strings.Join(result, " "), phrases
+}
+
+// isDottedAbbreviation returns true if w uses only dots as punctuation and all sub-tokens
+// are single letters (e.g., "R.E.M.", "U.K." but not "a-ha" or "AC/DC").
+func isDottedAbbreviation(w string, subTokens []string) bool {
+ for _, r := range w {
+ if !unicode.IsLetter(r) && !unicode.IsNumber(r) && r != '.' {
+ return false
+ }
+ }
+ for _, st := range subTokens {
+ if !isSingleUnicodeLetter(st) {
+ return false
+ }
+ }
+ return true
+}
+
+// buildFTS5Query preprocesses user input into a safe FTS5 MATCH expression.
+// It preserves quoted phrases and * prefix wildcards, neutralizes FTS5 operators
+// (by lowercasing them, since FTS5 operators are case-sensitive) and strips
+// special characters to prevent query injection.
+func buildFTS5Query(userInput string) string {
+ q := strings.TrimSpace(userInput)
+ if q == "" || q == `""` {
+ return ""
+ }
+
+ var phrases []string
+ result := q
+ for {
+ start := strings.Index(result, `"`)
+ if start == -1 {
+ break
+ }
+ end := strings.Index(result[start+1:], `"`)
+ if end == -1 {
+ // Unmatched quote — remove it
+ result = result[:start] + result[start+1:]
+ break
+ }
+ end += start + 1
+ phrase := result[start : end+1] // includes quotes
+ phrases = append(phrases, phrase)
+ result = result[:start] + fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1) + result[end+1:]
+ }
+
+ // Transliterate non-ASCII letters in the unquoted portion (ø→o, æ→ae, œ→oe, ß→ss, …)
+ // so the query matches the ASCII variants emitted by normalizeForFTS at index time.
+ // FTS5's own `remove_diacritics 2` only strips NFKD-decomposable marks, so without
+ // this step queries for words containing these letters can miss. Quoted phrases are
+ // left untouched so they continue to match the original text in title/artist columns.
+ result = sanitize.Accents(result)
+
+ // Neutralize FTS5 operators by lowercasing them (FTS5 operators are case-sensitive:
+ // AND, OR, NOT, NEAR are operators, but and, or, not, near are plain tokens)
+ result = fts5Operators.ReplaceAllStringFunc(result, strings.ToLower)
+
+ // Handle words with embedded punctuation (a-ha, AC/DC, R.E.M.) before stripping
+ result, phrases = processPunctuatedWords(result, phrases)
+
+ result = fts5SpecialChars.ReplaceAllString(result, " ")
+ result = fts5LeadingStar.ReplaceAllString(result, "$1")
+ tokens := strings.Fields(result)
+
+ // Append * to plain tokens for prefix matching (e.g., "love" → "love*").
+ // Skip tokens that are already wildcarded or are quoted phrase placeholders.
+ for i, t := range tokens {
+ if strings.HasPrefix(t, "\x00") || strings.HasSuffix(t, "*") {
+ continue
+ }
+ tokens[i] = t + "*"
+ }
+
+ // Use explicit AND between tokens — FTS5's implicit AND (space-separated)
+ // doesn't work correctly with parenthesized OR groups from processPunctuatedWords.
+ result = strings.Join(tokens, " AND ")
+
+ for i, phrase := range phrases {
+ placeholder := fmt.Sprintf("\x00PHRASE%d\x00", i)
+ result = strings.ReplaceAll(result, placeholder, phrase)
+ }
+
+ return result
+}
+
+// ftsColumn pairs an FTS5 column name with its BM25 relevance weight.
+type ftsColumn struct {
+ Name string
+ Weight float64
+}
+
+// ftsColumnDefs defines FTS5 columns and their BM25 relevance weights.
+// The order MUST match the column order in the FTS5 table definition (see migrations).
+// All columns are both searched and ranked. When adding indexed-but-not-searched
+// columns in the future, use Weight: 0 to exclude from the search column filter.
+var ftsColumnDefs = map[string][]ftsColumn{
+ "media_file": {
+ {"title", 10.0},
+ {"album", 5.0},
+ {"artist", 3.0},
+ {"album_artist", 3.0},
+ {"sort_title", 1.0},
+ {"sort_album_name", 1.0},
+ {"sort_artist_name", 1.0},
+ {"sort_album_artist_name", 1.0},
+ {"disc_subtitle", 1.0},
+ {"search_participants", 2.0},
+ {"search_normalized", 1.0},
+ },
+ "album": {
+ {"name", 10.0},
+ {"sort_album_name", 1.0},
+ {"album_artist", 3.0},
+ {"search_participants", 2.0},
+ {"discs", 1.0},
+ {"catalog_num", 1.0},
+ {"album_version", 1.0},
+ {"search_normalized", 1.0},
+ },
+ "artist": {
+ {"name", 10.0},
+ {"sort_artist_name", 1.0},
+ {"search_normalized", 1.0},
+ },
+}
+
+// ftsColumnFilters and ftsBM25Weights are precomputed from ftsColumnDefs at init time
+// to avoid per-query allocations.
+var (
+ ftsColumnFilters = map[string]string{}
+ ftsBM25Weights = map[string]string{}
+)
+
+func init() {
+ for table, cols := range ftsColumnDefs {
+ var names []string
+ weights := make([]string, len(cols))
+ for i, c := range cols {
+ if c.Weight > 0 {
+ names = append(names, c.Name)
+ }
+ weights[i] = fmt.Sprintf("%.1f", c.Weight)
+ }
+ ftsColumnFilters[table] = "{" + strings.Join(names, " ") + "}"
+ ftsBM25Weights[table] = strings.Join(weights, ", ")
+ }
+}
+
+// ftsSearch implements searchStrategy using FTS5 full-text search with BM25 ranking.
+type ftsSearch struct {
+ tableName string
+ ftsTable string
+ matchExpr string
+ rankExpr string
+}
+
+// ToSql returns a single-query fallback for the REST filter path (no two-phase split).
+func (s *ftsSearch) ToSql() (string, []any, error) {
+ sql := s.tableName + ".rowid IN (SELECT rowid FROM " + s.ftsTable + " WHERE " + s.ftsTable + " MATCH ?)"
+ return sql, []any{s.matchExpr}, nil
+}
+
+// execute runs a two-phase FTS5 search:
+// - Phase 1: lightweight rowid query (main table + FTS + library filter) for ranking and pagination.
+// - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid set.
+//
+// Complex ORDER BY (function calls, aggregations) are dropped from Phase 1.
+func (s *ftsSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error {
+ qualifiedOrderBys := []string{s.rankExpr}
+ for _, ob := range cfg.OrderBy {
+ if qualified := qualifyOrderBy(s.tableName, ob); qualified != "" {
+ qualifiedOrderBys = append(qualifiedOrderBys, qualified)
+ }
+ }
+
+ // Phase 1: fresh query — must set LIMIT/OFFSET from options explicitly.
+ // Mirror applyOptions behavior: Max=0 means no limit, not LIMIT 0.
+ rowidQuery := Select(s.tableName+".rowid").
+ From(s.tableName).
+ Join(s.ftsTable+" ON "+s.ftsTable+".rowid = "+s.tableName+".rowid AND "+s.ftsTable+" MATCH ?", s.matchExpr).
+ Where(Eq{s.tableName + ".missing": false}).
+ OrderBy(qualifiedOrderBys...)
+ if options.Max > 0 {
+ rowidQuery = rowidQuery.Limit(uint64(options.Max))
+ }
+ if options.Offset > 0 {
+ rowidQuery = rowidQuery.Offset(uint64(options.Offset))
+ }
+
+ // Library filter + musicFolderId must be applied here, before pagination.
+ if cfg.LibraryFilter != nil {
+ rowidQuery = cfg.LibraryFilter(rowidQuery)
+ } else {
+ rowidQuery = r.applyLibraryFilter(rowidQuery)
+ }
+ if options.Filters != nil {
+ rowidQuery = rowidQuery.Where(options.Filters)
+ }
+
+ rowidSQL, rowidArgs, err := rowidQuery.ToSql()
+ if err != nil {
+ return fmt.Errorf("building FTS rowid query: %w", err)
+ }
+
+ // Phase 2: strip LIMIT/OFFSET from sq (Phase 1 handled pagination),
+ // join on the ranked rowid set to hydrate with full columns.
+ sq = sq.RemoveLimit().RemoveOffset()
+ rankedSubquery := fmt.Sprintf(
+ "(SELECT rowid as _rid, row_number() OVER () AS _rn FROM (%s)) AS _ranked",
+ rowidSQL,
+ )
+ sq = sq.Join(rankedSubquery+" ON "+s.tableName+".rowid = _ranked._rid", rowidArgs...)
+ sq = sq.OrderBy("_ranked._rn")
+ return r.queryAll(sq, dest)
+}
+
+// qualifyOrderBy prepends tableName to a simple column name. Returns empty string for
+// complex expressions (function calls, aggregations) that can't be used in Phase 1.
+func qualifyOrderBy(tableName, orderBy string) string {
+ orderBy = strings.TrimSpace(orderBy)
+ if orderBy == "" || strings.ContainsAny(orderBy, "(,") {
+ return ""
+ }
+ parts := strings.Fields(orderBy)
+ if !strings.Contains(parts[0], ".") {
+ parts[0] = tableName + "." + parts[0]
+ }
+ return strings.Join(parts, " ")
+}
+
+// ftsQueryDegraded returns true when the FTS query lost significant discriminating
+// content compared to the original input. This happens when special characters that
+// are part of the entity name (e.g., "1+", "C++", "!!!", "C#") get stripped by FTS
+// tokenization, leaving only very short/broad tokens. Also detects quoted phrases
+// that would be degraded by FTS5's unicode61 tokenizer (e.g., "1+" → token "1").
+func ftsQueryDegraded(original, ftsQuery string) bool {
+ original = strings.TrimSpace(original)
+ if original == "" || ftsQuery == "" {
+ return false
+ }
+ // Strip quotes from original for comparison — we want the raw content
+ stripped := strings.ReplaceAll(original, `"`, "")
+ // Extract the alphanumeric content from the original query
+ alphaNum := fts5PunctStrip.ReplaceAllString(stripped, "")
+ // If the original is entirely alphanumeric, nothing was stripped — not degraded
+ if len(alphaNum) == len(stripped) {
+ return false
+ }
+ // Check if all effective FTS tokens are very short (≤2 chars).
+ // Short tokens with prefix matching are too broad when special chars were stripped.
+ // For quoted phrases, extract the content and check the tokens inside.
+ tokens := strings.FieldsSeq(ftsQuery)
+ for t := range tokens {
+ t = strings.TrimSuffix(t, "*")
+ // Skip internal phrase placeholders
+ if strings.HasPrefix(t, "\x00") {
+ return false
+ }
+ // For OR groups from processPunctuatedWords (e.g., ("a ha" OR aha*)),
+ // the punctuated word was already handled meaningfully — not degraded.
+ if strings.HasPrefix(t, "(") {
+ return false
+ }
+ // For quoted phrases, check the tokens inside as FTS5 will tokenize them
+ if strings.HasPrefix(t, `"`) {
+ // Extract content between quotes
+ inner := strings.Trim(t, `"`)
+ innerAlpha := fts5PunctStrip.ReplaceAllString(inner, " ")
+ for it := range strings.FieldsSeq(innerAlpha) {
+ if len(it) > 2 {
+ return false
+ }
+ }
+ continue
+ }
+ if len(t) > 2 {
+ return false
+ }
+ }
+ return true
+}
+
+// newFTSSearch creates an FTS5 search strategy. Falls back to LIKE search if the
+// query produces no FTS tokens (e.g., punctuation-only like "!!!!!!!") or if FTS
+// tokenization stripped significant content from the query (e.g., "1+" → "1*").
+// Returns nil when the query produces no searchable tokens at all.
+func newFTSSearch(tableName, query string) searchStrategy {
+ q := buildFTS5Query(query)
+ if q == "" || ftsQueryDegraded(query, q) {
+ // Fallback: try LIKE search with the raw query
+ cleaned := strings.TrimSpace(strings.ReplaceAll(query, `"`, ""))
+ if cleaned != "" {
+ log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", cleaned)
+ return newLikeSearch(tableName, cleaned)
+ }
+ return nil
+ }
+ ftsTable := tableName + "_fts"
+ matchExpr := q
+ if cols, ok := ftsColumnFilters[tableName]; ok {
+ matchExpr = cols + " : (" + q + ")"
+ }
+
+ rankExpr := ftsTable + ".rank"
+ if weights, ok := ftsBM25Weights[tableName]; ok {
+ rankExpr = "bm25(" + ftsTable + ", " + weights + ")"
+ }
+
+ s := &ftsSearch{
+ tableName: tableName,
+ ftsTable: ftsTable,
+ matchExpr: matchExpr,
+ rankExpr: rankExpr,
+ }
+ log.Trace("Search using FTS5 backend", "table", tableName, "query", q, "filter", s)
+ return s
+}
diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go
new file mode 100644
index 000000000..b54e5856a
--- /dev/null
+++ b/persistence/sql_search_fts_test.go
@@ -0,0 +1,450 @@
+package persistence
+
+import (
+ "context"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = DescribeTable("buildFTS5Query",
+ func(input, expected string) {
+ Expect(buildFTS5Query(input)).To(Equal(expected))
+ },
+ Entry("returns empty string for empty input", "", ""),
+ Entry("returns empty string for whitespace-only input", " ", ""),
+ Entry("appends * to a single word for prefix matching", "beatles", "beatles*"),
+ Entry("appends * to each word for prefix matching", "abbey road", "abbey* AND road*"),
+ Entry("preserves quoted phrases without appending *", `"the beatles"`, `"the beatles"`),
+ Entry("does not double-append * to existing prefix wildcard", "beat*", "beat*"),
+ Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* AND or* AND not* AND near*"),
+ Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* AND col* AND val*"),
+ Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" AND abbey*`),
+ Entry("handles prefix with multiple words", "beat* abbey", "beat* AND abbey*"),
+ Entry("collapses multiple spaces", "abbey road", "abbey* AND road*"),
+ Entry("strips leading * from tokens and appends trailing *", "*livia", "livia*"),
+ Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* AND oliv*"),
+ Entry("strips standalone *", "*", ""),
+ Entry("strips apostrophe from input", "Guns N' Roses", "Guns* AND N* AND Roses*"),
+ Entry("converts slashed word to phrase+concat OR", "AC/DC", `("AC DC" OR ACDC*)`),
+ Entry("converts hyphenated word to phrase+concat OR", "a-ha", `("a ha" OR aha*)`),
+ Entry("converts partial hyphenated word to phrase+concat OR", "a-h", `("a h" OR ah*)`),
+ Entry("converts hyphenated name to phrase+concat OR", "Jay-Z", `("Jay Z" OR JayZ*)`),
+ Entry("converts contraction to phrase+concat OR", "it's", `("it s" OR its*)`),
+ Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* AND of* AND ("a ha" OR aha*)`),
+ Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND got*`),
+ Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* AND roll* AND vol* AND 2*"),
+ Entry("transliterates NFKD-decomposable diacritics", "Björk début", "Bjork* AND debut*"),
+ Entry("transliterates ø to o", "Øystein", "Oystein*"),
+ Entry("transliterates œ ligature to oe", "œuvre", "oeuvre*"),
+ Entry("transliterates æ ligature to ae", "Brennæ", "Brennae*"),
+ Entry("transliterates mixed unicode words", "Mø Sigur Rós", "Mo* AND Sigur* AND Ros*"),
+ Entry("transliterates ß to ss", "Straße", "Strasse*"),
+ Entry("preserves quoted unicode phrase verbatim", `"Björk"`, `"Björk"`),
+ Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`),
+ Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`),
+ Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* AND of* AND "R E M"`),
+ Entry("collapses two-letter abbreviation", "U.K.", `"U K"`),
+ Entry("does not collapse single letter surrounded by words", "I am fine", "I* AND am* AND fine*"),
+ Entry("does not collapse single standalone letter", "A test", "A* AND test*"),
+ Entry("preserves quoted phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`),
+ Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`),
+ Entry("returns empty string for punctuation-only input", "!!!!!!!", ""),
+ Entry("returns empty string for mixed punctuation", "!@#$%^&", ""),
+ Entry("returns empty string for empty quoted phrase", `""`, ""),
+)
+
+var _ = DescribeTable("ftsQueryDegraded",
+ func(original, ftsQuery string, expected bool) {
+ Expect(ftsQueryDegraded(original, ftsQuery)).To(Equal(expected))
+ },
+ Entry("not degraded for empty original", "", "1*", false),
+ Entry("not degraded for empty ftsQuery", "1+", "", false),
+ Entry("not degraded for purely alphanumeric query", "beatles", "beatles*", false),
+ Entry("not degraded when long tokens remain", "test^val", "test* val*", false),
+ Entry("not degraded for quoted phrase with long tokens", `"the beatles"`, `"the beatles"`, false),
+ Entry("degraded for quoted phrase with only short tokens after tokenizer strips special chars", `"1+"`, `"1+"`, true),
+ Entry("not degraded for quoted phrase with meaningful content", `"C++ programming"`, `"C++ programming"`, false),
+ Entry("degraded when special chars stripped leaving short token", "1+", "1*", true),
+ Entry("degraded when special chars stripped leaving two short tokens", "C# 1", "C* 1*", true),
+ Entry("not degraded when at least one long token remains", "1+ beatles", "1* beatles*", false),
+ Entry("not degraded for OR groups from processPunctuatedWords", "AC/DC", `("AC DC" OR ACDC*)`, false),
+)
+
+var _ = DescribeTable("normalizeForFTS",
+ func(expected string, values ...string) {
+ Expect(normalizeForFTS(values...)).To(Equal(expected))
+ },
+ Entry("strips dots and concatenates", "REM", "R.E.M."),
+ Entry("strips slash", "ACDC", "AC/DC"),
+ Entry("strips hyphen", "Aha", "A-ha"),
+ Entry("skips unchanged ASCII words", "", "The Beatles"),
+ Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"),
+ Entry("deduplicates", "REM", "R.E.M.", "R.E.M."),
+ Entry("strips apostrophe from word", "N", "Guns N' Roses"),
+ Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"),
+ Entry("transliterates ø to o", "Bjork", "Bjørk"),
+ Entry("transliterates Ø to O", "Oystein", "Øystein"),
+ Entry("transliterates œ ligature to oe", "oeuvre", "œuvre"),
+ Entry("transliterates Latin diacritics", "cafe", "café"),
+ Entry("transliterates only the non-ASCII words", "Mo Ros", "Mø Rós"),
+ Entry("combines punctuation strip and transliteration", "StEtienne St-Etienne", "St-Étienne"),
+ Entry("deduplicates against punctuation form", "Cafe", "Café", "Cafe"),
+ Entry("transliterates ß to ss", "Strasse", "Straße"),
+)
+
+var _ = DescribeTable("containsCJK",
+ func(input string, expected bool) {
+ Expect(containsCJK(input)).To(Equal(expected))
+ },
+ Entry("returns false for empty string", "", false),
+ Entry("returns false for ASCII text", "hello world", false),
+ Entry("returns false for Latin with diacritics", "Björk début", false),
+ Entry("detects Chinese characters (Han)", "周杰伦", true),
+ Entry("detects Japanese Hiragana", "こんにちは", true),
+ Entry("detects Japanese Katakana", "カタカナ", true),
+ Entry("detects Korean Hangul", "한국어", true),
+ Entry("detects CJK mixed with Latin", "best of 周杰伦", true),
+ Entry("detects single CJK character", "a曲b", true),
+)
+
+var _ = DescribeTable("qualifyOrderBy",
+ func(tableName, orderBy, expected string) {
+ Expect(qualifyOrderBy(tableName, orderBy)).To(Equal(expected))
+ },
+ Entry("returns empty string for empty input", "artist", "", ""),
+ Entry("qualifies simple column with table name", "artist", "name", "artist.name"),
+ Entry("qualifies column with direction", "artist", "name desc", "artist.name desc"),
+ Entry("preserves already-qualified column", "artist", "artist.name", "artist.name"),
+ Entry("preserves already-qualified column with direction", "artist", "artist.name desc", "artist.name desc"),
+ Entry("returns empty for function call expression", "artist", "sum(json_extract(stats, '$.total.m')) desc", ""),
+ Entry("returns empty for expression with comma", "artist", "a, b", ""),
+ Entry("qualifies media_file column", "media_file", "title", "media_file.title"),
+)
+
+var _ = Describe("ftsColumnDefs helpers", func() {
+ Describe("ftsColumnFilters", func() {
+ It("returns column filter for media_file", func() {
+ Expect(ftsColumnFilters).To(HaveKeyWithValue("media_file",
+ "{title album artist album_artist sort_title sort_album_name sort_artist_name sort_album_artist_name disc_subtitle search_participants search_normalized}",
+ ))
+ })
+
+ It("returns column filter for album", func() {
+ Expect(ftsColumnFilters).To(HaveKeyWithValue("album",
+ "{name sort_album_name album_artist search_participants discs catalog_num album_version search_normalized}",
+ ))
+ })
+
+ It("returns column filter for artist", func() {
+ Expect(ftsColumnFilters).To(HaveKeyWithValue("artist",
+ "{name sort_artist_name search_normalized}",
+ ))
+ })
+
+ It("has no entry for unknown table", func() {
+ Expect(ftsColumnFilters).ToNot(HaveKey("unknown"))
+ })
+ })
+
+ Describe("ftsBM25Weights", func() {
+ It("returns weight CSV for media_file", func() {
+ Expect(ftsBM25Weights).To(HaveKeyWithValue("media_file",
+ "10.0, 5.0, 3.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 1.0",
+ ))
+ })
+
+ It("returns weight CSV for album", func() {
+ Expect(ftsBM25Weights).To(HaveKeyWithValue("album",
+ "10.0, 1.0, 3.0, 2.0, 1.0, 1.0, 1.0, 1.0",
+ ))
+ })
+
+ It("returns weight CSV for artist", func() {
+ Expect(ftsBM25Weights).To(HaveKeyWithValue("artist",
+ "10.0, 1.0, 1.0",
+ ))
+ })
+
+ It("has no entry for unknown table", func() {
+ Expect(ftsBM25Weights).ToNot(HaveKey("unknown"))
+ })
+ })
+
+ It("has definitions for all known tables", func() {
+ for _, table := range []string{"media_file", "album", "artist"} {
+ Expect(ftsColumnDefs).To(HaveKey(table))
+ Expect(ftsColumnDefs[table]).ToNot(BeEmpty())
+ }
+ })
+
+ It("has matching column count between filter and weights", func() {
+ for table, cols := range ftsColumnDefs {
+ // Column filter only includes Weight > 0 columns
+ filterCount := 0
+ for _, c := range cols {
+ if c.Weight > 0 {
+ filterCount++
+ }
+ }
+ // For now, all columns have Weight > 0, so filter count == total count
+ Expect(filterCount).To(Equal(len(cols)), "table %s: all columns should have positive weights", table)
+ }
+ })
+})
+
+var _ = Describe("newFTSSearch", func() {
+ It("returns nil for empty query", func() {
+ Expect(newFTSSearch("media_file", "")).To(BeNil())
+ })
+
+ It("returns non-nil for single-character query", func() {
+ strategy := newFTSSearch("media_file", "a")
+ Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here")
+ sql, _, err := strategy.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("MATCH"))
+ })
+
+ It("returns ftsSearch with correct table names and MATCH expression", func() {
+ strategy := newFTSSearch("media_file", "beatles")
+ fts, ok := strategy.(*ftsSearch)
+ Expect(ok).To(BeTrue())
+ Expect(fts.tableName).To(Equal("media_file"))
+ Expect(fts.ftsTable).To(Equal("media_file_fts"))
+ Expect(fts.matchExpr).To(HavePrefix("{title album artist album_artist"))
+ Expect(fts.matchExpr).To(ContainSubstring("beatles*"))
+ })
+
+ It("ToSql generates rowid IN subquery with MATCH (fallback path)", func() {
+ strategy := newFTSSearch("media_file", "beatles")
+ sql, args, err := strategy.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("media_file.rowid IN"))
+ Expect(sql).To(ContainSubstring("media_file_fts"))
+ Expect(sql).To(ContainSubstring("MATCH"))
+ Expect(args).To(HaveLen(1))
+ })
+
+ It("generates correct FTS table name per entity", func() {
+ for _, table := range []string{"media_file", "album", "artist"} {
+ strategy := newFTSSearch(table, "test")
+ fts, ok := strategy.(*ftsSearch)
+ Expect(ok).To(BeTrue())
+ Expect(fts.tableName).To(Equal(table))
+ Expect(fts.ftsTable).To(Equal(table + "_fts"))
+ }
+ })
+
+ It("builds bm25() rank expression with column weights", func() {
+ strategy := newFTSSearch("media_file", "beatles")
+ fts, ok := strategy.(*ftsSearch)
+ Expect(ok).To(BeTrue())
+ Expect(fts.rankExpr).To(HavePrefix("bm25(media_file_fts,"))
+ Expect(fts.rankExpr).To(ContainSubstring("10.0"))
+
+ strategy = newFTSSearch("artist", "beatles")
+ fts, ok = strategy.(*ftsSearch)
+ Expect(ok).To(BeTrue())
+ Expect(fts.rankExpr).To(HavePrefix("bm25(artist_fts,"))
+ })
+
+ It("falls back to ftsTable.rank for unknown tables", func() {
+ strategy := newFTSSearch("unknown_table", "test")
+ fts, ok := strategy.(*ftsSearch)
+ Expect(ok).To(BeTrue())
+ Expect(fts.rankExpr).To(Equal("unknown_table_fts.rank"))
+ })
+
+ It("wraps query with column filter for known tables", func() {
+ strategy := newFTSSearch("artist", "Beatles")
+ fts, ok := strategy.(*ftsSearch)
+ Expect(ok).To(BeTrue())
+ Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)"))
+ })
+
+ It("passes query without column filter for unknown tables", func() {
+ strategy := newFTSSearch("unknown_table", "test")
+ fts, ok := strategy.(*ftsSearch)
+ Expect(ok).To(BeTrue())
+ Expect(fts.matchExpr).To(Equal("test*"))
+ })
+
+ It("preserves phrase queries inside column filter", func() {
+ strategy := newFTSSearch("media_file", `"the beatles"`)
+ fts, ok := strategy.(*ftsSearch)
+ Expect(ok).To(BeTrue())
+ Expect(fts.matchExpr).To(ContainSubstring(`"the beatles"`))
+ })
+
+ It("preserves prefix queries inside column filter", func() {
+ strategy := newFTSSearch("media_file", "beat*")
+ fts, ok := strategy.(*ftsSearch)
+ Expect(ok).To(BeTrue())
+ Expect(fts.matchExpr).To(ContainSubstring("beat*"))
+ })
+
+ It("falls back to LIKE search for punctuation-only query", func() {
+ strategy := newFTSSearch("media_file", "!!!!!!!")
+ Expect(strategy).ToNot(BeNil())
+ _, ok := strategy.(*ftsSearch)
+ Expect(ok).To(BeFalse(), "punctuation-only should fall back to LIKE, not FTS")
+ sql, args, err := strategy.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("LIKE"))
+ Expect(args).To(ContainElement("%!!!!!!!%"))
+ })
+
+ It("falls back to LIKE search for degraded query (special chars stripped leaving short tokens)", func() {
+ strategy := newFTSSearch("album", "1+")
+ Expect(strategy).ToNot(BeNil())
+ _, ok := strategy.(*ftsSearch)
+ Expect(ok).To(BeFalse(), "degraded query should fall back to LIKE, not FTS")
+ sql, args, err := strategy.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("LIKE"))
+ Expect(args).To(ContainElement("%1+%"))
+ })
+
+ It("returns nil for empty string even with LIKE fallback", func() {
+ Expect(newFTSSearch("media_file", "")).To(BeNil())
+ Expect(newFTSSearch("media_file", " ")).To(BeNil())
+ })
+
+ It("returns nil for empty quoted phrase", func() {
+ Expect(newFTSSearch("media_file", `""`)).To(BeNil())
+ })
+})
+
+var _ = Describe("FTS5 Integration Search", func() {
+ var (
+ mr model.MediaFileRepository
+ alr model.AlbumRepository
+ arr model.ArtistRepository
+ )
+
+ BeforeEach(func() {
+ ctx := log.NewContext(context.TODO())
+ ctx = request.WithUser(ctx, adminUser)
+ conn := GetDBXBuilder()
+ mr = NewMediaFileRepository(ctx, conn)
+ alr = NewAlbumRepository(ctx, conn)
+ arr = NewArtistRepository(ctx, conn)
+ })
+
+ Describe("MediaFile search", func() {
+ It("finds media files by title", func() {
+ results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].Title).To(Equal("Radioactivity"))
+ Expect(results[0].ID).To(Equal(songRadioactivity.ID))
+ })
+
+ It("finds media files by artist name", func() {
+ results, err := mr.Search("Beatles", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(3))
+ for _, r := range results {
+ Expect(r.Artist).To(Equal("The Beatles"))
+ }
+ })
+ })
+
+ Describe("Album search", func() {
+ It("finds albums by name", func() {
+ results, err := alr.Search("Sgt Peppers", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].Name).To(Equal("Sgt Peppers"))
+ Expect(results[0].ID).To(Equal(albumSgtPeppers.ID))
+ })
+
+ It("finds albums with multi-word search", func() {
+ results, err := alr.Search("Abbey Road", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+ })
+ })
+
+ Describe("Artist search", func() {
+ It("finds artists by name", func() {
+ results, err := arr.Search("Kraftwerk", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].Name).To(Equal("Kraftwerk"))
+ Expect(results[0].ID).To(Equal(artistKraftwerk.ID))
+ })
+ })
+
+ Describe("CJK search", func() {
+ It("finds media files by CJK title", func() {
+ results, err := mr.Search("プラチナ", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].Title).To(Equal("プラチナ・ジェット"))
+ Expect(results[0].ID).To(Equal(songCJK.ID))
+ })
+
+ It("finds media files by CJK artist name", func() {
+ results, err := mr.Search("シートベルツ", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].Artist).To(Equal("シートベルツ"))
+ })
+
+ It("finds albums by CJK artist name", func() {
+ results, err := alr.Search("シートベルツ", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].Name).To(Equal("COWBOY BEBOP"))
+ Expect(results[0].ID).To(Equal(albumCJK.ID))
+ })
+
+ It("finds artists by CJK name", func() {
+ results, err := arr.Search("シートベルツ", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].Name).To(Equal("シートベルツ"))
+ Expect(results[0].ID).To(Equal(artistCJK.ID))
+ })
+ })
+
+ Describe("Album version search", func() {
+ It("finds albums by version tag via FTS", func() {
+ results, err := alr.Search("Deluxe", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].ID).To(Equal(albumWithVersion.ID))
+ })
+ })
+
+ Describe("Punctuation-only search", func() {
+ It("finds media files with punctuation-only title", func() {
+ results, err := mr.Search("!!!!!!!", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].Title).To(Equal("!!!!!!!"))
+ Expect(results[0].ID).To(Equal(songPunctuation.ID))
+ })
+ })
+
+ Describe("Single-character search (doSearch min-length guard)", func() {
+ It("returns empty results for single-char query via Search", func() {
+ results, err := mr.Search("a", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty(), "doSearch should reject single-char queries")
+ })
+ })
+
+ Describe("Max=0 means no limit (regression: must not produce LIMIT 0)", func() {
+ It("returns results with Max=0", func() {
+ results, err := mr.Search("Beatles", model.QueryOptions{Max: 0})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0")
+ })
+ })
+})
diff --git a/persistence/sql_search_like.go b/persistence/sql_search_like.go
new file mode 100644
index 000000000..972545ac5
--- /dev/null
+++ b/persistence/sql_search_like.go
@@ -0,0 +1,106 @@
+package persistence
+
+import (
+ "strings"
+
+ . "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils/str"
+)
+
+// likeSearch implements searchStrategy using LIKE-based SQL filters.
+// Used for legacy full_text searches, CJK fallback, and punctuation-only fallback.
+type likeSearch struct {
+ filter Sqlizer
+}
+
+func (s *likeSearch) ToSql() (string, []any, error) {
+ return s.filter.ToSql()
+}
+
+func (s *likeSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error {
+ sq = sq.Where(s.filter)
+ sq = sq.OrderBy(cfg.OrderBy...)
+ return r.queryAll(sq, dest, options)
+}
+
+// newLegacySearch creates a LIKE search against the full_text column.
+// Returns nil when the query produces no searchable tokens.
+func newLegacySearch(tableName, query string) searchStrategy {
+ filter := legacySearchExpr(tableName, query)
+ if filter == nil {
+ return nil
+ }
+ return &likeSearch{filter: filter}
+}
+
+// newLikeSearch creates a LIKE search against core entity columns (CJK, punctuation fallback).
+// No minimum length is enforced, since single CJK characters are meaningful words.
+// Returns nil when the query produces no searchable tokens.
+func newLikeSearch(tableName, query string) searchStrategy {
+ filter := likeSearchExpr(tableName, query)
+ if filter == nil {
+ return nil
+ }
+ return &likeSearch{filter: filter}
+}
+
+// legacySearchExpr generates LIKE-based search filters against the full_text column.
+// This is the original search implementation, used when Search.Backend="legacy".
+func legacySearchExpr(tableName string, s string) Sqlizer {
+ q := str.SanitizeStrings(s)
+ if q == "" {
+ log.Trace("Search using legacy backend, query is empty", "table", tableName)
+ return nil
+ }
+ var sep string
+ if !conf.Server.Search.FullString {
+ sep = " "
+ }
+ parts := strings.Split(q, " ")
+ filters := And{}
+ for _, part := range parts {
+ filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"})
+ }
+ log.Trace("Search using legacy backend", "query", filters, "table", tableName)
+ return filters
+}
+
+// likeSearchColumns defines the core columns to search with LIKE queries.
+// These are the primary user-visible fields for each entity type.
+// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
+var likeSearchColumns = map[string][]string{
+ "media_file": {"title", "album", "artist", "album_artist"},
+ "album": {"name", "album_artist"},
+ "artist": {"name"},
+}
+
+// likeSearchExpr generates LIKE-based search filters against core columns.
+// Each word in the query must match at least one column (AND between words),
+// and each word can match any column (OR within a word).
+// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
+func likeSearchExpr(tableName string, s string) Sqlizer {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ log.Trace("Search using LIKE backend, query is empty", "table", tableName)
+ return nil
+ }
+ columns, ok := likeSearchColumns[tableName]
+ if !ok {
+ log.Trace("Search using LIKE backend, couldn't find columns for this table", "table", tableName)
+ return nil
+ }
+ words := strings.Fields(s)
+ wordFilters := And{}
+ for _, word := range words {
+ colFilters := Or{}
+ for _, col := range columns {
+ colFilters = append(colFilters, Like{tableName + "." + col: "%" + word + "%"})
+ }
+ wordFilters = append(wordFilters, colFilters)
+ }
+ log.Trace("Search using LIKE backend", "query", wordFilters, "table", tableName)
+ return wordFilters
+}
diff --git a/persistence/sql_search_like_test.go b/persistence/sql_search_like_test.go
new file mode 100644
index 000000000..8ee4ef93c
--- /dev/null
+++ b/persistence/sql_search_like_test.go
@@ -0,0 +1,134 @@
+package persistence
+
+import (
+ "context"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("newLegacySearch", func() {
+ It("returns non-nil for single-character query", func() {
+ strategy := newLegacySearch("media_file", "a")
+ Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here")
+ sql, _, err := strategy.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("LIKE"))
+ })
+})
+
+var _ = Describe("legacySearchExpr", func() {
+ It("returns nil for empty query", func() {
+ Expect(legacySearchExpr("media_file", "")).To(BeNil())
+ })
+
+ It("generates LIKE filter for single word", func() {
+ expr := legacySearchExpr("media_file", "beatles")
+ sql, args, err := expr.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("media_file.full_text LIKE"))
+ Expect(args).To(ContainElement("% beatles%"))
+ })
+
+ It("generates AND of LIKE filters for multiple words", func() {
+ expr := legacySearchExpr("media_file", "abbey road")
+ sql, args, err := expr.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("AND"))
+ Expect(args).To(HaveLen(2))
+ })
+})
+
+var _ = Describe("likeSearchExpr", func() {
+ It("returns nil for empty query", func() {
+ Expect(likeSearchExpr("media_file", "")).To(BeNil())
+ })
+
+ It("returns nil for whitespace-only query", func() {
+ Expect(likeSearchExpr("media_file", " ")).To(BeNil())
+ })
+
+ It("generates LIKE filters against core columns for single CJK word", func() {
+ expr := likeSearchExpr("media_file", "周杰伦")
+ sql, args, err := expr.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ // Should have OR between columns for the single word
+ Expect(sql).To(ContainSubstring("OR"))
+ Expect(sql).To(ContainSubstring("media_file.title LIKE"))
+ Expect(sql).To(ContainSubstring("media_file.album LIKE"))
+ Expect(sql).To(ContainSubstring("media_file.artist LIKE"))
+ Expect(sql).To(ContainSubstring("media_file.album_artist LIKE"))
+ Expect(args).To(HaveLen(4))
+ for _, arg := range args {
+ Expect(arg).To(Equal("%周杰伦%"))
+ }
+ })
+
+ It("generates AND of OR groups for multi-word query", func() {
+ expr := likeSearchExpr("media_file", "周杰伦 greatest")
+ sql, args, err := expr.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ // Two groups AND'd together, each with 4 columns OR'd
+ Expect(sql).To(ContainSubstring("AND"))
+ Expect(args).To(HaveLen(8))
+ })
+
+ It("uses correct columns for album table", func() {
+ expr := likeSearchExpr("album", "周杰伦")
+ sql, args, err := expr.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("album.name LIKE"))
+ Expect(sql).To(ContainSubstring("album.album_artist LIKE"))
+ Expect(args).To(HaveLen(2))
+ })
+
+ It("uses correct columns for artist table", func() {
+ expr := likeSearchExpr("artist", "周杰伦")
+ sql, args, err := expr.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("artist.name LIKE"))
+ Expect(args).To(HaveLen(1))
+ })
+
+ It("returns nil for unknown table", func() {
+ Expect(likeSearchExpr("unknown_table", "周杰伦")).To(BeNil())
+ })
+})
+
+var _ = Describe("Legacy Integration Search", func() {
+ var mr model.MediaFileRepository
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Search.Backend = "legacy"
+
+ ctx := log.NewContext(context.TODO())
+ ctx = request.WithUser(ctx, adminUser)
+ conn := GetDBXBuilder()
+ mr = NewMediaFileRepository(ctx, conn)
+ })
+
+ It("returns results using legacy LIKE-based search", func() {
+ results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(1))
+ Expect(results[0].Title).To(Equal("Radioactivity"))
+ })
+
+ It("returns empty results for single-char query (doSearch min-length guard)", func() {
+ results, err := mr.Search("a", model.QueryOptions{Max: 10})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty(), "doSearch should reject single-char queries")
+ })
+
+ It("returns results with Max=0 (regression: must not produce LIMIT 0)", func() {
+ results, err := mr.Search("Beatles", model.QueryOptions{Max: 0})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0")
+ })
+})
diff --git a/persistence/sql_search_test.go b/persistence/sql_search_test.go
index 6bfd88d9f..68ee205b4 100644
--- a/persistence/sql_search_test.go
+++ b/persistence/sql_search_test.go
@@ -1,6 +1,8 @@
package persistence
import (
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -11,4 +13,100 @@ var _ = Describe("sqlRepository", func() {
Expect(formatFullText("legiao urbana")).To(Equal(" legiao urbana"))
})
})
+
+ Describe("getSearchStrategy", func() {
+ It("returns FTS strategy by default", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Search.Backend = "fts"
+ conf.Server.Search.FullString = false
+
+ strategy := getSearchStrategy("media_file", "test")
+ Expect(strategy).ToNot(BeNil())
+ sql, _, err := strategy.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("MATCH"))
+ })
+
+ It("returns legacy LIKE strategy when SearchBackend is legacy", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Search.Backend = "legacy"
+ conf.Server.Search.FullString = false
+
+ strategy := getSearchStrategy("media_file", "test")
+ Expect(strategy).ToNot(BeNil())
+ sql, _, err := strategy.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("LIKE"))
+ })
+
+ It("falls back to legacy LIKE strategy when SearchFullString is enabled", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Search.Backend = "fts"
+ conf.Server.Search.FullString = true
+
+ strategy := getSearchStrategy("media_file", "test")
+ Expect(strategy).ToNot(BeNil())
+ sql, _, err := strategy.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("LIKE"))
+ })
+
+ It("routes CJK queries to LIKE strategy instead of FTS", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Search.Backend = "fts"
+ conf.Server.Search.FullString = false
+
+ strategy := getSearchStrategy("media_file", "周杰伦")
+ Expect(strategy).ToNot(BeNil())
+ sql, _, err := strategy.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ // CJK should use LIKE, not MATCH
+ Expect(sql).To(ContainSubstring("LIKE"))
+ Expect(sql).NotTo(ContainSubstring("MATCH"))
+ })
+
+ It("routes non-CJK queries to FTS strategy", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Search.Backend = "fts"
+ conf.Server.Search.FullString = false
+
+ strategy := getSearchStrategy("media_file", "beatles")
+ Expect(strategy).ToNot(BeNil())
+ sql, _, err := strategy.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(ContainSubstring("MATCH"))
+ })
+
+ It("returns non-nil for single-character query (no min-length in strategy)", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Search.Backend = "fts"
+ conf.Server.Search.FullString = false
+
+ strategy := getSearchStrategy("media_file", "a")
+ Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by strategies (min-length is enforced in doSearch)")
+ })
+
+ It("returns non-nil for single-character query with legacy backend", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Search.Backend = "legacy"
+ conf.Server.Search.FullString = false
+
+ strategy := getSearchStrategy("media_file", "a")
+ Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by legacy strategy (min-length is enforced in doSearch)")
+ })
+
+ It("uses legacy for CJK when SearchBackend is legacy", func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Search.Backend = "legacy"
+ conf.Server.Search.FullString = false
+
+ strategy := getSearchStrategy("media_file", "周杰伦")
+ Expect(strategy).ToNot(BeNil())
+ sql, _, err := strategy.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ // Legacy should still use full_text column LIKE
+ Expect(sql).To(ContainSubstring("LIKE"))
+ Expect(sql).To(ContainSubstring("full_text"))
+ })
+ })
})
diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go
index d7b48f23e..88acebb7f 100644
--- a/persistence/sql_tags.go
+++ b/persistence/sql_tags.go
@@ -1,12 +1,15 @@
package persistence
import (
+ "context"
"encoding/json"
"fmt"
"strings"
. "github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
+ "github.com/pocketbase/dbx"
)
// Format of a tag in the DB
@@ -55,3 +58,111 @@ func tagIDFilter(name string, idValue any) Sqlizer {
},
)
}
+
+// tagLibraryIdFilter filters tags based on library access through the library_tag table
+func tagLibraryIdFilter(_ string, value any) Sqlizer {
+ return Eq{"library_tag.library_id": value}
+}
+
+// baseTagRepository provides common functionality for all tag-based repositories.
+// It handles CRUD operations with optional filtering by tag name.
+type baseTagRepository struct {
+ sqlRepository
+ tagFilter *model.TagName // nil = no filter (all tags), non-nil = filter by specific tag name
+}
+
+// newBaseTagRepository creates a new base tag repository with optional tag filtering.
+// If tagFilter is nil, the repository will work with all tags.
+// If tagFilter is provided, the repository will only work with tags of that specific name.
+func newBaseTagRepository(ctx context.Context, db dbx.Builder, tagFilter *model.TagName) *baseTagRepository {
+ r := &baseTagRepository{
+ tagFilter: tagFilter,
+ }
+ r.ctx = ctx
+ r.db = db
+ r.tableName = "tag"
+ r.registerModel(&model.Tag{}, map[string]filterFunc{
+ "name": containsFilter("tag_value"),
+ "library_id": tagLibraryIdFilter,
+ })
+ r.setSortMappings(map[string]string{
+ "name": "tag_value",
+ })
+ return r
+}
+
+// applyLibraryFiltering adds the appropriate library joins based on user context
+func (r *baseTagRepository) applyLibraryFiltering(sq SelectBuilder) SelectBuilder {
+ // Add library_tag join
+ sq = sq.LeftJoin("library_tag on library_tag.tag_id = tag.id")
+
+ // For authenticated users, also join with user_library to filter by accessible libraries
+ user := loggedUser(r.ctx)
+ if user.ID != invalidUserId {
+ sq = sq.Join("user_library on user_library.library_id = library_tag.library_id AND user_library.user_id = ?", user.ID)
+ }
+
+ return sq
+}
+
+// newSelect overrides the base implementation to apply tag name filtering and library filtering.
+func (r *baseTagRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
+ sq := r.sqlRepository.newSelect(options...)
+
+ // Apply tag name filtering if specified
+ if r.tagFilter != nil {
+ sq = sq.Where(Eq{"tag.tag_name": *r.tagFilter})
+ }
+
+ // Apply library filtering and set up aggregation columns
+ sq = r.applyLibraryFiltering(sq).Columns(
+ "tag.id",
+ "tag.tag_name",
+ "tag.tag_value",
+ "COALESCE(SUM(library_tag.album_count), 0) as album_count",
+ "COALESCE(SUM(library_tag.media_file_count), 0) as song_count",
+ ).GroupBy("tag.id", "tag.tag_name", "tag.tag_value")
+
+ return sq
+}
+
+// ResourceRepository interface implementation
+
+func (r *baseTagRepository) Count(options ...rest.QueryOptions) (int64, error) {
+ sq := Select("COUNT(DISTINCT tag.id)").From("tag")
+
+ // Apply tag name filtering if specified
+ if r.tagFilter != nil {
+ sq = sq.Where(Eq{"tag.tag_name": *r.tagFilter})
+ }
+
+ // Apply library filtering
+ sq = r.applyLibraryFiltering(sq)
+
+ return r.count(sq, r.parseRestOptions(r.ctx, options...))
+}
+
+func (r *baseTagRepository) Read(id string) (any, error) {
+ query := r.newSelect().Where(Eq{"id": id})
+ var res model.Tag
+ err := r.queryOne(query, &res)
+ return &res, err
+}
+
+func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
+ query := r.newSelect(r.parseRestOptions(r.ctx, options...))
+ var res model.TagList
+ err := r.queryAll(query, &res)
+ return res, err
+}
+
+func (r *baseTagRepository) EntityName() string {
+ return "tag"
+}
+
+func (r *baseTagRepository) NewInstance() any {
+ return model.Tag{}
+}
+
+// Interface compliance check
+var _ model.ResourceRepository = (*baseTagRepository)(nil)
diff --git a/persistence/tag_library_filtering_test.go b/persistence/tag_library_filtering_test.go
new file mode 100644
index 000000000..ddd897165
--- /dev/null
+++ b/persistence/tag_library_filtering_test.go
@@ -0,0 +1,263 @@
+package persistence
+
+import (
+ "context"
+ "time"
+
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/id"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
+)
+
+const (
+ adminUserID = "userid"
+ regularUserID = "2222"
+ libraryID1 = 1
+ libraryID2 = 2
+ libraryID3 = 3
+
+ tagNameGenre = "genre"
+ tagValueRock = "rock"
+ tagValuePop = "pop"
+ tagValueJazz = "jazz"
+)
+
+var _ = Describe("Tag Library Filtering", func() {
+ var (
+ tagRockID = id.NewTagID(tagNameGenre, tagValueRock)
+ tagPopID = id.NewTagID(tagNameGenre, tagValuePop)
+ tagJazzID = id.NewTagID(tagNameGenre, tagValueJazz)
+ )
+
+ expectTagValues := func(tagList model.TagList, expected []string) {
+ tagValues := make([]string, len(tagList))
+ for i, tag := range tagList {
+ tagValues[i] = tag.TagValue
+ }
+ Expect(tagValues).To(ContainElements(expected))
+ }
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+
+ // Generate unique path suffix to avoid conflicts with other tests
+ uniqueSuffix := time.Now().Format("20060102150405.000")
+
+ // Clean up database
+ db := GetDBXBuilder()
+ _, err := db.NewQuery("DELETE FROM library_tag").Execute()
+ Expect(err).ToNot(HaveOccurred())
+ _, err = db.NewQuery("DELETE FROM tag").Execute()
+ Expect(err).ToNot(HaveOccurred())
+ _, err = db.NewQuery("DELETE FROM user_library WHERE user_id != {:admin} AND user_id != {:regular}").
+ Bind(dbx.Params{"admin": adminUserID, "regular": regularUserID}).Execute()
+ Expect(err).ToNot(HaveOccurred())
+ _, err = db.NewQuery("DELETE FROM library WHERE id > 1").Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Create test libraries with unique names and paths to avoid conflicts with other tests
+ _, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
+ Bind(dbx.Params{"id": libraryID2, "name": "Library 2-" + uniqueSuffix, "path": "/music/lib2-" + uniqueSuffix}).Execute()
+ Expect(err).ToNot(HaveOccurred())
+ _, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
+ Bind(dbx.Params{"id": libraryID3, "name": "Library 3-" + uniqueSuffix, "path": "/music/lib3-" + uniqueSuffix}).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Give admin access to all libraries
+ for _, libID := range []int{libraryID1, libraryID2, libraryID3} {
+ _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ({:user}, {:lib})").
+ Bind(dbx.Params{"user": adminUserID, "lib": libID}).Execute()
+ Expect(err).ToNot(HaveOccurred())
+ }
+
+ // Create test tags
+ adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser)
+ tagRepo := NewTagRepository(adminCtx, GetDBXBuilder())
+
+ createTag := func(libraryID int, name, value string) {
+ tag := model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value}
+ err := tagRepo.Add(libraryID, tag)
+ Expect(err).ToNot(HaveOccurred())
+ }
+
+ createTag(libraryID1, tagNameGenre, tagValueRock)
+ createTag(libraryID2, tagNameGenre, tagValuePop)
+ createTag(libraryID3, tagNameGenre, tagValueJazz)
+ createTag(libraryID2, tagNameGenre, tagValueRock) // Rock appears in both lib1 and lib2
+
+ // Set tag counts (manually for testing)
+ setCounts := func(tagID string, libID, albums, songs int) {
+ _, err := db.NewQuery("UPDATE library_tag SET album_count = {:albums}, media_file_count = {:songs} WHERE tag_id = {:tag} AND library_id = {:lib}").
+ Bind(dbx.Params{"albums": albums, "songs": songs, "tag": tagID, "lib": libID}).Execute()
+ Expect(err).ToNot(HaveOccurred())
+ }
+
+ setCounts(tagRockID, libraryID1, 5, 20)
+ setCounts(tagPopID, libraryID2, 3, 10)
+ setCounts(tagJazzID, libraryID3, 2, 8)
+ setCounts(tagRockID, libraryID2, 1, 4)
+
+ // Give regular user access to library 2 only
+ _, err = db.NewQuery("INSERT INTO user_library (user_id, library_id) VALUES ({:user}, {:lib})").
+ Bind(dbx.Params{"user": regularUserID, "lib": libraryID2}).Execute()
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ Describe("TagRepository Library Filtering", func() {
+ // Helper to create repository and read all tags
+ readAllTags := func(user *model.User, filters ...rest.QueryOptions) model.TagList {
+ var ctx context.Context
+ if user != nil {
+ ctx = request.WithUser(log.NewContext(context.TODO()), *user)
+ } else {
+ ctx = context.Background() // Headless context
+ }
+
+ tagRepo := NewTagRepository(ctx, GetDBXBuilder())
+ repo := tagRepo.(model.ResourceRepository)
+
+ var opts rest.QueryOptions
+ if len(filters) > 0 {
+ opts = filters[0]
+ }
+
+ tags, err := repo.ReadAll(opts)
+ Expect(err).ToNot(HaveOccurred())
+ return tags.(model.TagList)
+ }
+
+ // Helper to count tags
+ countTags := func(user *model.User) int64 {
+ var ctx context.Context
+ if user != nil {
+ ctx = request.WithUser(log.NewContext(context.TODO()), *user)
+ } else {
+ ctx = context.Background()
+ }
+
+ tagRepo := NewTagRepository(ctx, GetDBXBuilder())
+ repo := tagRepo.(model.ResourceRepository)
+
+ count, err := repo.Count()
+ Expect(err).ToNot(HaveOccurred())
+ return count
+ }
+
+ Context("Admin User", func() {
+ It("should see all tags regardless of library", func() {
+ tags := readAllTags(&adminUser)
+ Expect(tags).To(HaveLen(3))
+ })
+ })
+
+ Context("Regular User with Limited Library Access", func() {
+ It("should only see tags from accessible libraries", func() {
+ tags := readAllTags(®ularUser)
+ // Should see rock (libraries 1,2) and pop (library 2), but not jazz (library 3)
+ Expect(tags).To(HaveLen(2))
+ })
+
+ It("should respect explicit library_id filters within accessible libraries", func() {
+ tags := readAllTags(®ularUser, rest.QueryOptions{
+ Filters: map[string]any{"library_id": libraryID2},
+ })
+ // Should see only tags from library 2: pop and rock(lib2)
+ Expect(tags).To(HaveLen(2))
+ expectTagValues(tags, []string{tagValuePop, tagValueRock})
+ })
+
+ It("should not return tags when filtering by inaccessible library", func() {
+ tags := readAllTags(®ularUser, rest.QueryOptions{
+ Filters: map[string]any{"library_id": libraryID3},
+ })
+ // Should return no tags since user can't access library 3
+ Expect(tags).To(HaveLen(0))
+ })
+
+ It("should filter by library 1 correctly", func() {
+ tags := readAllTags(®ularUser, rest.QueryOptions{
+ Filters: map[string]any{"library_id": libraryID1},
+ })
+ // Should see only rock from library 1
+ Expect(tags).To(HaveLen(1))
+ Expect(tags[0].TagValue).To(Equal(tagValueRock))
+ })
+ })
+
+ Context("Headless Processes (No User Context)", func() {
+ It("should see all tags from all libraries when no user is in context", func() {
+ tags := readAllTags(nil) // nil = headless context
+ // Should see all tags from all libraries (no filtering applied)
+ Expect(tags).To(HaveLen(3))
+ expectTagValues(tags, []string{tagValueRock, tagValuePop, tagValueJazz})
+ })
+
+ It("should count all tags from all libraries when no user is in context", func() {
+ count := countTags(nil)
+ // Should count all tags from all libraries
+ Expect(count).To(Equal(int64(3)))
+ })
+
+ It("should calculate proper statistics from all libraries for headless processes", func() {
+ tags := readAllTags(nil)
+
+ // Find the rock tag (appears in libraries 1 and 2)
+ var rockTag *model.Tag
+ for _, tag := range tags {
+ if tag.TagValue == tagValueRock {
+ rockTag = &tag
+ break
+ }
+ }
+ Expect(rockTag).ToNot(BeNil())
+
+ // Should have stats from all libraries where rock appears
+ // Library 1: 5 albums, 20 songs
+ // Library 2: 1 album, 4 songs
+ // Total: 6 albums, 24 songs
+ Expect(rockTag.AlbumCount).To(Equal(6))
+ Expect(rockTag.SongCount).To(Equal(24))
+ })
+
+ It("should allow headless processes to apply explicit library_id filters", func() {
+ tags := readAllTags(nil, rest.QueryOptions{
+ Filters: map[string]any{"library_id": libraryID3},
+ })
+ // Should see only jazz from library 3
+ Expect(tags).To(HaveLen(1))
+ Expect(tags[0].TagValue).To(Equal(tagValueJazz))
+ })
+ })
+
+ Context("Admin User with Explicit Library Filtering", func() {
+ It("should see all tags when no filter is applied", func() {
+ tags := readAllTags(&adminUser)
+ Expect(tags).To(HaveLen(3))
+ })
+
+ It("should respect explicit library_id filters", func() {
+ tags := readAllTags(&adminUser, rest.QueryOptions{
+ Filters: map[string]any{"library_id": libraryID3},
+ })
+ // Should see only jazz from library 3
+ Expect(tags).To(HaveLen(1))
+ Expect(tags[0].TagValue).To(Equal(tagValueJazz))
+ })
+
+ It("should filter by library 2 correctly", func() {
+ tags := readAllTags(&adminUser, rest.QueryOptions{
+ Filters: map[string]any{"library_id": libraryID2},
+ })
+ // Should see pop and rock from library 2
+ Expect(tags).To(HaveLen(2))
+ expectTagValues(tags, []string{tagValuePop, tagValueRock})
+ })
+ })
+ })
+})
diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go
index d63584af0..5bb8b3832 100644
--- a/persistence/tag_repository.go
+++ b/persistence/tag_repository.go
@@ -7,26 +7,22 @@ import (
"time"
. "github.com/Masterminds/squirrel"
- "github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/pocketbase/dbx"
)
type tagRepository struct {
- sqlRepository
+ *baseTagRepository
}
func NewTagRepository(ctx context.Context, db dbx.Builder) model.TagRepository {
- r := &tagRepository{}
- r.ctx = ctx
- r.db = db
- r.tableName = "tag"
- r.registerModel(&model.Tag{}, nil)
- return r
+ return &tagRepository{
+ baseTagRepository: newBaseTagRepository(ctx, db, nil), // nil = no filter, works with all tags
+ }
}
-func (r *tagRepository) Add(tags ...model.Tag) error {
+func (r *tagRepository) Add(libraryID int, tags ...model.Tag) error {
for chunk := range slices.Chunk(tags, 200) {
sq := Insert(r.tableName).Columns("id", "tag_name", "tag_value").
Suffix("on conflict (id) do nothing")
@@ -37,34 +33,42 @@ func (r *tagRepository) Add(tags ...model.Tag) error {
if err != nil {
return err
}
+
+ // Create library_tag entries for library filtering
+ libSq := Insert("library_tag").Columns("tag_id", "library_id", "album_count", "media_file_count").
+ Suffix("on conflict (tag_id, library_id) do nothing")
+ for _, t := range chunk {
+ libSq = libSq.Values(t.ID, libraryID, 0, 0)
+ }
+ _, err = r.executeSQL(libSq)
+ if err != nil {
+ return fmt.Errorf("adding library_tag entries: %w", err)
+ }
}
return nil
}
-// UpdateCounts updates the album_count and media_file_count columns in the tag_counts table.
+// UpdateCounts updates the library_tag table with per-library statistics.
// Only genres are being updated for now.
func (r *tagRepository) UpdateCounts() error {
template := `
-with updated_values as (
- select jt.value as id, count(distinct %[1]s.id) as %[1]s_count
- from %[1]s
- join json_tree(tags, '$.genre') as jt
- where atom is not null
- and key = 'id'
- group by jt.value
-)
-update tag
-set %[1]s_count = updated_values.%[1]s_count
-from updated_values
-where tag.id = updated_values.id;
+INSERT INTO library_tag (tag_id, library_id, %[1]s_count)
+SELECT jt.value as tag_id, %[1]s.library_id, count(distinct %[1]s.id) as %[1]s_count
+FROM %[1]s
+JOIN json_tree(%[1]s.tags, '$.genre') as jt ON jt.atom IS NOT NULL AND jt.key = 'id'
+JOIN tag ON tag.id = jt.value
+GROUP BY jt.value, %[1]s.library_id
+ON CONFLICT (tag_id, library_id)
+DO UPDATE SET %[1]s_count = excluded.%[1]s_count;
`
+
for _, table := range []string{"album", "media_file"} {
start := time.Now()
query := Expr(fmt.Sprintf(template, table))
c, err := r.executeSQL(query)
- log.Debug(r.ctx, "Updated tag counts", "table", table, "elapsed", time.Since(start), "updated", c)
+ log.Debug(r.ctx, "Updated library tag counts", "table", table, "elapsed", time.Since(start), "updated", c)
if err != nil {
- return fmt.Errorf("updating %s tag counts: %w", table, err)
+ return fmt.Errorf("updating %s library tag counts: %w", table, err)
}
}
return nil
@@ -74,43 +78,22 @@ func (r *tagRepository) purgeUnused() error {
del := Delete(r.tableName).Where(`
id not in (select jt.value
from album left join json_tree(album.tags, '$') as jt
+ where atom is not null
+ and key = 'id'
+ UNION
+ select jt.value
+ from media_file left join json_tree(media_file.tags, '$') as jt
where atom is not null
and key = 'id')
`)
c, err := r.executeSQL(del)
if err != nil {
- return fmt.Errorf("error purging unused tags: %w", err)
+ return fmt.Errorf("error purging %s unused tags: %w", r.tableName, err)
}
if c > 0 {
- log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c)
+ log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c, "table", r.tableName)
}
return err
}
-func (r *tagRepository) Count(options ...rest.QueryOptions) (int64, error) {
- return r.count(r.newSelect(), r.parseRestOptions(r.ctx, options...))
-}
-
-func (r *tagRepository) Read(id string) (interface{}, error) {
- query := r.newSelect().Columns("*").Where(Eq{"id": id})
- var res model.Tag
- err := r.queryOne(query, &res)
- return &res, err
-}
-
-func (r *tagRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
- query := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
- var res model.TagList
- err := r.queryAll(query, &res)
- return res, err
-}
-
-func (r *tagRepository) EntityName() string {
- return "tag"
-}
-
-func (r *tagRepository) NewInstance() interface{} {
- return model.Tag{}
-}
-
var _ model.ResourceRepository = &tagRepository{}
diff --git a/persistence/tag_repository_test.go b/persistence/tag_repository_test.go
new file mode 100644
index 000000000..9a019c30e
--- /dev/null
+++ b/persistence/tag_repository_test.go
@@ -0,0 +1,311 @@
+package persistence
+
+import (
+ "context"
+ "slices"
+ "strings"
+
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/id"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
+)
+
+var _ = Describe("TagRepository", func() {
+ var repo model.TagRepository
+ var restRepo model.ResourceRepository
+ var ctx context.Context
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true})
+ tagRepo := NewTagRepository(ctx, GetDBXBuilder())
+ repo = tagRepo
+ restRepo = tagRepo.(model.ResourceRepository)
+
+ // Clean the database before each test to ensure isolation
+ db := GetDBXBuilder()
+ _, err := db.NewQuery("DELETE FROM tag").Execute()
+ Expect(err).ToNot(HaveOccurred())
+ _, err = db.NewQuery("DELETE FROM library_tag").Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Ensure library 1 exists (if it doesn't already)
+ _, err = db.NewQuery("INSERT OR IGNORE INTO library (id, name, path, default_new_users) VALUES (1, 'Test Library', '/test', true)").Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Ensure the admin user has access to library 1
+ _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 1)").Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Add comprehensive test data that covers all test scenarios
+ newTag := func(name, value string) model.Tag {
+ return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value}
+ }
+
+ err = repo.Add(1,
+ // Genre tags
+ newTag("genre", "rock"),
+ newTag("genre", "pop"),
+ newTag("genre", "jazz"),
+ newTag("genre", "electronic"),
+ newTag("genre", "classical"),
+ newTag("genre", "ambient"),
+ newTag("genre", "techno"),
+ newTag("genre", "house"),
+ newTag("genre", "trance"),
+ newTag("genre", "Alternative Rock"),
+ newTag("genre", "Blues"),
+ newTag("genre", "Country"),
+ // Mood tags
+ newTag("mood", "happy"),
+ newTag("mood", "sad"),
+ newTag("mood", "energetic"),
+ newTag("mood", "calm"),
+ // Other tag types
+ newTag("instrument", "guitar"),
+ newTag("instrument", "piano"),
+ newTag("decade", "1980s"),
+ newTag("decade", "1990s"),
+ )
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ Describe("Add", func() {
+ It("should handle adding new tags", func() {
+ newTag := model.Tag{
+ ID: id.NewTagID("genre", "experimental"),
+ TagName: "genre",
+ TagValue: "experimental",
+ }
+
+ err := repo.Add(1, newTag)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify tag was added
+ result, err := restRepo.Read(newTag.ID)
+ Expect(err).ToNot(HaveOccurred())
+ resultTag := result.(*model.Tag)
+ Expect(resultTag.TagValue).To(Equal("experimental"))
+
+ // Check count increased
+ count, err := restRepo.Count()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(Equal(int64(21))) // 20 from dataset + 1 new
+ })
+
+ It("should handle duplicate tags gracefully", func() {
+ // Try to add a duplicate tag
+ duplicateTag := model.Tag{
+ ID: id.NewTagID("genre", "rock"), // This already exists
+ TagName: "genre",
+ TagValue: "rock",
+ }
+
+ count, err := restRepo.Count()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(Equal(int64(20))) // Still 20 tags
+
+ err = repo.Add(1, duplicateTag)
+ Expect(err).ToNot(HaveOccurred()) // Should not error
+
+ // Count should remain the same
+ count, err = restRepo.Count()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(Equal(int64(20))) // Still 20 tags
+ })
+ })
+
+ Describe("UpdateCounts", func() {
+ It("should update tag counts successfully", func() {
+ err := repo.UpdateCounts()
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("should handle empty database gracefully", func() {
+ // Clear the database first
+ db := GetDBXBuilder()
+ _, err := db.NewQuery("DELETE FROM tag").Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ err = repo.UpdateCounts()
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("should handle albums with non-existent tag IDs in JSON gracefully", func() {
+ // Regression test for foreign key constraint error
+ // Create an album with tag IDs in JSON that don't exist in tag table
+ db := GetDBXBuilder()
+
+ // First, create a non-existent tag ID (this simulates tags in JSON that aren't in tag table)
+ nonExistentTagID := id.NewTagID("genre", "nonexistent-genre")
+
+ // Create album with JSON containing the non-existent tag ID
+ albumWithBadTags := `{"genre":[{"id":"` + nonExistentTagID + `","value":"nonexistent-genre"}]}`
+
+ // Insert album directly into database with the problematic JSON
+ _, err := db.NewQuery("INSERT INTO album (id, name, library_id, tags) VALUES ({:id}, {:name}, {:lib}, {:tags})").
+ Bind(dbx.Params{
+ "id": "test-album-bad-tags",
+ "name": "Album With Bad Tags",
+ "lib": 1,
+ "tags": albumWithBadTags,
+ }).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // This should not fail with foreign key constraint error
+ err = repo.UpdateCounts()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Cleanup
+ _, err = db.NewQuery("DELETE FROM album WHERE id = {:id}").
+ Bind(dbx.Params{"id": "test-album-bad-tags"}).Execute()
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("should handle media files with non-existent tag IDs in JSON gracefully", func() {
+ // Regression test for foreign key constraint error with media files
+ db := GetDBXBuilder()
+
+ // Create a non-existent tag ID
+ nonExistentTagID := id.NewTagID("genre", "another-nonexistent-genre")
+
+ // Create media file with JSON containing the non-existent tag ID
+ mediaFileWithBadTags := `{"genre":[{"id":"` + nonExistentTagID + `","value":"another-nonexistent-genre"}]}`
+
+ // Insert media file directly into database with the problematic JSON
+ _, err := db.NewQuery("INSERT INTO media_file (id, title, library_id, tags) VALUES ({:id}, {:title}, {:lib}, {:tags})").
+ Bind(dbx.Params{
+ "id": "test-media-bad-tags",
+ "title": "Media File With Bad Tags",
+ "lib": 1,
+ "tags": mediaFileWithBadTags,
+ }).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // This should not fail with foreign key constraint error
+ err = repo.UpdateCounts()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Cleanup
+ _, err = db.NewQuery("DELETE FROM media_file WHERE id = {:id}").
+ Bind(dbx.Params{"id": "test-media-bad-tags"}).Execute()
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ Describe("Count", func() {
+ It("should return correct count of tags", func() {
+ count, err := restRepo.Count()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(Equal(int64(20))) // From the test dataset
+ })
+ })
+
+ Describe("Read", func() {
+ It("should return existing tag", func() {
+ rockID := id.NewTagID("genre", "rock")
+ result, err := restRepo.Read(rockID)
+ Expect(err).ToNot(HaveOccurred())
+ resultTag := result.(*model.Tag)
+ Expect(resultTag.ID).To(Equal(rockID))
+ Expect(resultTag.TagName).To(Equal(model.TagName("genre")))
+ Expect(resultTag.TagValue).To(Equal("rock"))
+ })
+
+ It("should return error for non-existent tag", func() {
+ _, err := restRepo.Read("non-existent-id")
+ Expect(err).To(HaveOccurred())
+ })
+ })
+
+ Describe("ReadAll", func() {
+ It("should return all tags from dataset", func() {
+ result, err := restRepo.ReadAll()
+ Expect(err).ToNot(HaveOccurred())
+ tags := result.(model.TagList)
+ Expect(tags).To(HaveLen(20))
+ })
+
+ It("should filter tags by partial value correctly", func() {
+ options := rest.QueryOptions{
+ Filters: map[string]any{"name": "%rock%"}, // Tags containing 'rock'
+ }
+ result, err := restRepo.ReadAll(options)
+ Expect(err).ToNot(HaveOccurred())
+ tags := result.(model.TagList)
+ Expect(tags).To(HaveLen(2)) // "rock" and "Alternative Rock"
+
+ // Verify all returned tags contain 'rock' in their value
+ for _, tag := range tags {
+ Expect(strings.ToLower(tag.TagValue)).To(ContainSubstring("rock"))
+ }
+ })
+
+ It("should filter tags by partial value using LIKE", func() {
+ options := rest.QueryOptions{
+ Filters: map[string]any{"name": "%e%"}, // Tags containing 'e'
+ }
+ result, err := restRepo.ReadAll(options)
+ Expect(err).ToNot(HaveOccurred())
+ tags := result.(model.TagList)
+ Expect(tags).To(HaveLen(8)) // electronic, house, trance, energetic, Blues, decade x2, Alternative Rock
+
+ // Verify all returned tags contain 'e' in their value
+ for _, tag := range tags {
+ Expect(strings.ToLower(tag.TagValue)).To(ContainSubstring("e"))
+ }
+ })
+
+ It("should sort tags by value ascending", func() {
+ options := rest.QueryOptions{
+ Filters: map[string]any{"name": "%r%"}, // Tags containing 'r'
+ Sort: "name",
+ Order: "asc",
+ }
+ result, err := restRepo.ReadAll(options)
+ Expect(err).ToNot(HaveOccurred())
+ tags := result.(model.TagList)
+ Expect(tags).To(HaveLen(7))
+
+ Expect(slices.IsSortedFunc(tags, func(a, b model.Tag) int {
+ return strings.Compare(strings.ToLower(a.TagValue), strings.ToLower(b.TagValue))
+ }))
+ })
+
+ It("should sort tags by value descending", func() {
+ options := rest.QueryOptions{
+ Filters: map[string]any{"name": "%r%"}, // Tags containing 'r'
+ Sort: "name",
+ Order: "desc",
+ }
+ result, err := restRepo.ReadAll(options)
+ Expect(err).ToNot(HaveOccurred())
+ tags := result.(model.TagList)
+ Expect(tags).To(HaveLen(7))
+
+ Expect(slices.IsSortedFunc(tags, func(a, b model.Tag) int {
+ return strings.Compare(strings.ToLower(b.TagValue), strings.ToLower(a.TagValue)) // Descending order
+ }))
+ })
+ })
+
+ Describe("EntityName", func() {
+ It("should return correct entity name", func() {
+ name := restRepo.EntityName()
+ Expect(name).To(Equal("tag"))
+ })
+ })
+
+ Describe("NewInstance", func() {
+ It("should return new tag instance", func() {
+ instance := restRepo.NewInstance()
+ Expect(instance).To(BeAssignableToTypeOf(model.Tag{}))
+ })
+ })
+})
diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go
index bdcbe7262..96fd3efdb 100644
--- a/persistence/transcoding_repository.go
+++ b/persistence/transcoding_repository.go
@@ -41,7 +41,7 @@ func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding,
}
func (r *transcodingRepository) Put(t *model.Transcoding) error {
- if !isAdmin(r.ctx) {
+ if !loggedUser(r.ctx).IsAdmin {
return rest.ErrPermissionDenied
}
_, err := r.put(t.ID, t)
@@ -52,27 +52,42 @@ func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, erro
return r.count(Select(), r.parseRestOptions(r.ctx, options...))
}
-func (r *transcodingRepository) Read(id string) (interface{}, error) {
- return r.Get(id)
+func (r *transcodingRepository) Read(id string) (any, error) {
+ res, err := r.Get(id)
+ if err != nil {
+ return nil, err
+ }
+ if !loggedUser(r.ctx).IsAdmin {
+ res.Command = ""
+ }
+ return res, nil
}
-func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
+func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
res := model.Transcodings{}
err := r.queryAll(sel, &res)
- return res, err
+ if err != nil {
+ return nil, err
+ }
+ if !loggedUser(r.ctx).IsAdmin {
+ for i := range res {
+ res[i].Command = ""
+ }
+ }
+ return res, nil
}
func (r *transcodingRepository) EntityName() string {
return "transcoding"
}
-func (r *transcodingRepository) NewInstance() interface{} {
+func (r *transcodingRepository) NewInstance() any {
return &model.Transcoding{}
}
-func (r *transcodingRepository) Save(entity interface{}) (string, error) {
- if !isAdmin(r.ctx) {
+func (r *transcodingRepository) Save(entity any) (string, error) {
+ if !loggedUser(r.ctx).IsAdmin {
return "", rest.ErrPermissionDenied
}
t := entity.(*model.Transcoding)
@@ -83,8 +98,8 @@ func (r *transcodingRepository) Save(entity interface{}) (string, error) {
return id, err
}
-func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error {
- if !isAdmin(r.ctx) {
+func (r *transcodingRepository) Update(id string, entity any, cols ...string) error {
+ if !loggedUser(r.ctx).IsAdmin {
return rest.ErrPermissionDenied
}
t := entity.(*model.Transcoding)
@@ -97,7 +112,7 @@ func (r *transcodingRepository) Update(id string, entity interface{}, cols ...st
}
func (r *transcodingRepository) Delete(id string) error {
- if !isAdmin(r.ctx) {
+ if !loggedUser(r.ctx).IsAdmin {
return rest.ErrPermissionDenied
}
err := r.delete(Eq{"id": id})
diff --git a/persistence/transcoding_repository_test.go b/persistence/transcoding_repository_test.go
index eddc5047a..73250163c 100644
--- a/persistence/transcoding_repository_test.go
+++ b/persistence/transcoding_repository_test.go
@@ -64,9 +64,69 @@ var _ = Describe("TranscodingRepository", func() {
_, err = adminRepo.Get("to-delete")
Expect(err).To(MatchError(model.ErrNotFound))
})
+
+ It("reads the Command field via the REST Read method", func() {
+ tr := &model.Transcoding{ID: "adminread", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
+ Expect(adminRepo.Put(tr)).To(Succeed())
+
+ res, err := adminRepo.(*transcodingRepository).Read("adminread")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(res.(*model.Transcoding).Command).To(Equal("ffmpeg -secret"))
+ })
})
Describe("Regular User", func() {
+ It("reads a transcoding but with the Command field redacted", func() {
+ tr := &model.Transcoding{ID: "readreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
+ Expect(adminRepo.Put(tr)).To(Succeed())
+
+ res, err := repo.(*transcodingRepository).Read("readreg")
+ Expect(err).ToNot(HaveOccurred())
+ t := res.(*model.Transcoding)
+ Expect(t.Name).To(Equal("temp"))
+ Expect(t.TargetFormat).To(Equal("test_format"))
+ Expect(t.Command).To(BeEmpty())
+ })
+
+ It("lists transcodings but with the Command field redacted", func() {
+ tr := &model.Transcoding{ID: "listreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
+ Expect(adminRepo.Put(tr)).To(Succeed())
+
+ res, err := repo.(*transcodingRepository).ReadAll()
+ Expect(err).ToNot(HaveOccurred())
+ list := res.(model.Transcodings)
+ Expect(list).ToNot(BeEmpty())
+ for _, t := range list {
+ Expect(t.Command).To(BeEmpty())
+ }
+ })
+
+ It("counts transcodings", func() {
+ count, err := repo.(*transcodingRepository).Count()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(count).To(BeNumerically(">=", 0))
+ })
+
+ It("can still resolve a transcoding for streaming via Get (Command not redacted)", func() {
+ tr := &model.Transcoding{ID: "streamreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
+ Expect(adminRepo.Put(tr)).To(Succeed())
+
+ res, err := repo.Get("streamreg")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(res.ID).To(Equal("streamreg"))
+ Expect(res.Command).To(Equal("ffmpeg -secret"))
+ })
+
+ It("can still resolve a transcoding for streaming via FindByFormat (Command not redacted)", func() {
+ tr := &model.Transcoding{ID: "fmtreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
+ Expect(adminRepo.Put(tr)).To(Succeed())
+
+ res, err := repo.FindByFormat("test_format")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(res.ID).To(Equal("fmtreg"))
+ Expect(res.Command).To(Equal("ffmpeg -secret"))
+ })
+
It("fails to create", func() {
err := repo.Put(&model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"})
Expect(err).To(Equal(rest.ErrPermissionDenied))
diff --git a/persistence/user_repository.go b/persistence/user_repository.go
index 073e32963..9decff4e5 100644
--- a/persistence/user_repository.go
+++ b/persistence/user_repository.go
@@ -3,6 +3,7 @@ package persistence
import (
"context"
"crypto/sha256"
+ "encoding/json"
"errors"
"fmt"
"strings"
@@ -17,6 +18,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils"
+ "github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
)
@@ -24,6 +26,26 @@ type userRepository struct {
sqlRepository
}
+type dbUser struct {
+ *model.User `structs:",flatten"`
+ LibrariesJSON string `structs:"-" json:"-"`
+}
+
+func (u *dbUser) PostScan() error {
+ if u.LibrariesJSON != "" {
+ if err := json.Unmarshal([]byte(u.LibrariesJSON), &u.User.Libraries); err != nil {
+ return fmt.Errorf("parsing user libraries from db: %w", err)
+ }
+ }
+ return nil
+}
+
+type dbUsers []dbUser
+
+func (us dbUsers) toModels() model.Users {
+ return slice.Map(us, func(u dbUser) model.User { return *u.User })
+}
+
var (
once sync.Once
encKey []byte
@@ -33,8 +55,11 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
r := &userRepository{}
r.ctx = ctx
r.db = db
+ r.tableName = "user"
r.registerModel(&model.User{}, map[string]filterFunc{
+ "id": idFilter(r.tableName),
"password": invalidFilter(ctx),
+ "name": startsWithFilter(r.tableName + ".name"),
})
once.Do(func() {
_ = r.initPasswordEncryptionKey()
@@ -42,28 +67,48 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
return r
}
+// selectUserWithLibraries returns a SelectBuilder that includes library information
+func (r *userRepository) selectUserWithLibraries(options ...model.QueryOptions) SelectBuilder {
+ return r.newSelect(options...).
+ Columns(`user.*`,
+ `COALESCE(json_group_array(json_object(
+ 'id', library.id,
+ 'name', library.name,
+ 'path', library.path,
+ 'remote_path', library.remote_path,
+ 'last_scan_at', library.last_scan_at,
+ 'last_scan_started_at', library.last_scan_started_at,
+ 'full_scan_in_progress', library.full_scan_in_progress,
+ 'updated_at', library.updated_at,
+ 'created_at', library.created_at
+ )) FILTER (WHERE library.id IS NOT NULL), '[]') AS libraries_json`).
+ LeftJoin("user_library ul ON user.id = ul.user_id").
+ LeftJoin("library ON ul.library_id = library.id").
+ GroupBy("user.id")
+}
+
func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) {
return r.count(Select(), qo...)
}
func (r *userRepository) Get(id string) (*model.User, error) {
- sel := r.newSelect().Columns("*").Where(Eq{"id": id})
- var res model.User
+ sel := r.selectUserWithLibraries().Where(Eq{"user.id": id})
+ var res dbUser
err := r.queryOne(sel, &res)
if err != nil {
return nil, err
}
- return &res, nil
+ return res.User, nil
}
func (r *userRepository) GetAll(options ...model.QueryOptions) (model.Users, error) {
- sel := r.newSelect(options...).Columns("*")
- res := model.Users{}
+ sel := r.selectUserWithLibraries(options...)
+ var res dbUsers
err := r.queryAll(sel, &res)
if err != nil {
return nil, err
}
- return res, nil
+ return res.toModels(), nil
}
func (r *userRepository) Put(u *model.User) error {
@@ -79,38 +124,65 @@ func (r *userRepository) Put(u *model.User) error {
return fmt.Errorf("error converting user to SQL args: %w", err)
}
delete(values, "current_password")
+
+ // Save/update the user
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
count, err := r.executeSQL(update)
if err != nil {
return err
}
- if count > 0 {
- return nil
+
+ isNewUser := count == 0
+ if isNewUser {
+ values["created_at"] = time.Now()
+ insert := Insert(r.tableName).SetMap(values)
+ _, err = r.executeSQL(insert)
+ if err != nil {
+ return err
+ }
}
- values["created_at"] = time.Now()
- insert := Insert(r.tableName).SetMap(values)
- _, err = r.executeSQL(insert)
- return err
+
+ // Auto-assign all libraries to admin users in a single SQL operation
+ if u.IsAdmin {
+ sql := Expr(
+ "INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library",
+ u.ID,
+ )
+ if _, err := r.executeSQL(sql); err != nil {
+ return fmt.Errorf("failed to assign all libraries to admin user: %w", err)
+ }
+ } else if isNewUser { // Only for new regular users
+ // Auto-assign default libraries to new regular users
+ sql := Expr(
+ "INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library WHERE default_new_users = true",
+ u.ID,
+ )
+ if _, err := r.executeSQL(sql); err != nil {
+ return fmt.Errorf("failed to assign default libraries to new user: %w", err)
+ }
+ }
+
+ return nil
}
func (r *userRepository) FindFirstAdmin() (*model.User, error) {
- sel := r.newSelect(model.QueryOptions{Sort: "updated_at", Max: 1}).Columns("*").Where(Eq{"is_admin": true})
- var usr model.User
+ sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true})
+ var usr dbUser
err := r.queryOne(sel, &usr)
if err != nil {
return nil, err
}
- return &usr, nil
+ return usr.User, nil
}
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
- sel := r.newSelect().Columns("*").Where(Expr("user_name = ? COLLATE NOCASE", username))
- var usr model.User
+ sel := r.selectUserWithLibraries().Where(Expr("user.user_name = ? COLLATE NOCASE", username))
+ var usr dbUser
err := r.queryOne(sel, &usr)
if err != nil {
return nil, err
}
- return &usr, nil
+ return usr.User, nil
}
func (r *userRepository) FindByUsernameWithPassword(username string) (*model.User, error) {
@@ -268,7 +340,15 @@ func (r *userRepository) Delete(id string) error {
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
- return err
+ if err != nil {
+ return err
+ }
+
+ // Clean up orphaned plugin references for the deleted user
+ if err := cleanupPluginUserReferences(r.db, id); err != nil {
+ log.Error(r.ctx, "Failed to cleanup plugin user references", "userID", id, err)
+ }
+ return nil
}
func keyTo32Bytes(input string) []byte {
@@ -365,6 +445,39 @@ func (r *userRepository) decryptAllPasswords(users model.Users) error {
return nil
}
+// Library association methods
+
+func (r *userRepository) GetUserLibraries(userID string) (model.Libraries, error) {
+ sel := Select("l.*").
+ From("library l").
+ Join("user_library ul ON l.id = ul.library_id").
+ Where(Eq{"ul.user_id": userID}).
+ OrderBy("l.name")
+
+ var res model.Libraries
+ err := r.queryAll(sel, &res)
+ return res, err
+}
+
+func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error {
+ // Remove existing associations
+ delSql := Delete("user_library").Where(Eq{"user_id": userID})
+ if _, err := r.executeSQL(delSql); err != nil {
+ return err
+ }
+
+ // Add new associations
+ if len(libraryIDs) > 0 {
+ insert := Insert("user_library").Columns("user_id", "library_id")
+ for _, libID := range libraryIDs {
+ insert = insert.Values(userID, libID)
+ }
+ _, err := r.executeSQL(insert)
+ return err
+ }
+ return nil
+}
+
var _ model.UserRepository = (*userRepository)(nil)
var _ rest.Repository = (*userRepository)(nil)
var _ rest.Persistable = (*userRepository)(nil)
diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go
index 7b1ad79d7..6f8ab9161 100644
--- a/persistence/user_repository_test.go
+++ b/persistence/user_repository_test.go
@@ -3,12 +3,15 @@ package persistence
import (
"context"
"errors"
+ "slices"
+ "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
+ "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -18,7 +21,7 @@ var _ = Describe("UserRepository", func() {
var repo model.UserRepository
BeforeEach(func() {
- repo = NewUserRepository(log.NewContext(context.TODO()), GetDBXBuilder())
+ repo = NewUserRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder())
})
Describe("Put/Get/FindByUsername", func() {
@@ -79,7 +82,7 @@ var _ = Describe("UserRepository", func() {
It("does nothing if passwords are not specified", func() {
user := &model.User{ID: "2", UserName: "johndoe"}
err := validatePasswordChange(user, loggedUser)
- Expect(err).To(BeNil())
+ Expect(err).ToNot(HaveOccurred())
})
Context("Autogenerated password (used with Reverse Proxy Authentication)", func() {
@@ -91,7 +94,7 @@ var _ = Describe("UserRepository", func() {
It("does nothing if passwords are not specified", func() {
user = *loggedUser
err := validatePasswordChange(&user, loggedUser)
- Expect(err).To(BeNil())
+ Expect(err).ToNot(HaveOccurred())
})
It("does not requires currentPassword for regular user", func() {
user = *loggedUser
@@ -118,7 +121,7 @@ var _ = Describe("UserRepository", func() {
user := &model.User{ID: "2", UserName: "johndoe"}
user.NewPassword = "new"
err := validatePasswordChange(user, loggedUser)
- Expect(err).To(BeNil())
+ Expect(err).ToNot(HaveOccurred())
})
It("requires currentPassword to change its own", func() {
user := *loggedUser
@@ -156,7 +159,7 @@ var _ = Describe("UserRepository", func() {
user.CurrentPassword = "abc123"
user.NewPassword = "new"
err := validatePasswordChange(&user, loggedUser)
- Expect(err).To(BeNil())
+ Expect(err).ToNot(HaveOccurred())
})
})
@@ -200,10 +203,57 @@ var _ = Describe("UserRepository", func() {
user.CurrentPassword = "abc123"
user.NewPassword = "new"
err := validatePasswordChange(&user, loggedUser)
- Expect(err).To(BeNil())
+ Expect(err).ToNot(HaveOccurred())
})
})
})
+
+ Describe("ReadAll name filter", func() {
+ var adminRepo model.ResourceRepository
+
+ BeforeEach(func() {
+ adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin-id", UserName: "admin", IsAdmin: true})
+ adminRepo = NewUserRepository(adminCtx, GetDBXBuilder()).(model.ResourceRepository)
+
+ for _, u := range []model.User{
+ {ID: "filter-alice", UserName: "alice_filter", Name: "Alice Filter", NewPassword: "x"},
+ {ID: "filter-bob", UserName: "bob_filter", Name: "Bob Filter", NewPassword: "x"},
+ } {
+ Expect(adminRepo.(model.UserRepository).Put(&u)).To(Succeed())
+ }
+ })
+
+ AfterEach(func() {
+ ur := adminRepo.(model.UserRepository)
+ _ = ur.Delete("filter-alice")
+ _ = ur.Delete("filter-bob")
+ })
+
+ It("matches users whose name starts with the given prefix", func() {
+ res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Alice"}})
+ Expect(err).ToNot(HaveOccurred())
+ users := res.(model.Users)
+
+ var names []string
+ for _, u := range users {
+ names = append(names, u.Name)
+ }
+ Expect(names).To(ContainElement("Alice Filter"))
+ Expect(names).ToNot(ContainElement("Bob Filter"))
+ })
+
+ It("does not match names by mid-string substring (startsWith, not contains)", func() {
+ res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Filter"}})
+ Expect(err).ToNot(HaveOccurred())
+ users := res.(model.Users)
+
+ for _, u := range users {
+ Expect(u.ID).ToNot(Or(Equal("filter-alice"), Equal("filter-bob")),
+ "a mid-string substring should not match a startsWith filter")
+ }
+ })
+ })
+
Describe("validateUsernameUnique", func() {
var repo *tests.MockedUserRepo
var existingUser *model.User
@@ -235,4 +285,336 @@ var _ = Describe("UserRepository", func() {
Expect(err).To(MatchError("fake error"))
})
})
+
+ Describe("Library Association Methods", func() {
+ var userID string
+ var library1, library2 model.Library
+
+ BeforeEach(func() {
+ // Create a test user first to satisfy foreign key constraints
+ testUser := model.User{
+ ID: "test-user-id",
+ UserName: "testuser",
+ Name: "Test User",
+ Email: "test@example.com",
+ NewPassword: "password",
+ IsAdmin: false,
+ }
+ Expect(repo.Put(&testUser)).To(BeNil())
+ userID = testUser.ID
+
+ library1 = model.Library{ID: 0, Name: "Library 500", Path: "/path/500"}
+ library2 = model.Library{ID: 0, Name: "Library 501", Path: "/path/501"}
+
+ // Create test libraries
+ libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
+ Expect(libRepo.Put(&library1)).To(BeNil())
+ Expect(libRepo.Put(&library2)).To(BeNil())
+ })
+
+ AfterEach(func() {
+ // Clean up user-library associations to ensure test isolation
+ _ = repo.SetUserLibraries(userID, []int{})
+
+ // Clean up test libraries to ensure isolation between test groups
+ libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
+ _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}})
+ })
+
+ Describe("GetUserLibraries", func() {
+ It("returns empty list when user has no library associations", func() {
+ libraries, err := repo.GetUserLibraries("non-existent-user")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libraries).To(HaveLen(0))
+ })
+
+ It("returns user's associated libraries", func() {
+ err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
+ Expect(err).ToNot(HaveOccurred())
+
+ libraries, err := repo.GetUserLibraries(userID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libraries).To(HaveLen(2))
+
+ libIDs := []int{libraries[0].ID, libraries[1].ID}
+ Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
+ })
+ })
+
+ Describe("SetUserLibraries", func() {
+ It("sets user's library associations", func() {
+ libraryIDs := []int{library1.ID, library2.ID}
+ err := repo.SetUserLibraries(userID, libraryIDs)
+ Expect(err).ToNot(HaveOccurred())
+
+ libraries, err := repo.GetUserLibraries(userID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libraries).To(HaveLen(2))
+ })
+
+ It("replaces existing associations", func() {
+ // Set initial associations
+ err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
+ Expect(err).ToNot(HaveOccurred())
+
+ // Replace with just one library
+ err = repo.SetUserLibraries(userID, []int{library1.ID})
+ Expect(err).ToNot(HaveOccurred())
+
+ libraries, err := repo.GetUserLibraries(userID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libraries).To(HaveLen(1))
+ Expect(libraries[0].ID).To(Equal(library1.ID))
+ })
+
+ It("removes all associations when passed empty slice", func() {
+ // Set initial associations
+ err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
+ Expect(err).ToNot(HaveOccurred())
+
+ // Remove all
+ err = repo.SetUserLibraries(userID, []int{})
+ Expect(err).ToNot(HaveOccurred())
+
+ libraries, err := repo.GetUserLibraries(userID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libraries).To(HaveLen(0))
+ })
+ })
+ })
+
+ Describe("Admin User Auto-Assignment", func() {
+ var (
+ libRepo model.LibraryRepository
+ library1 model.Library
+ library2 model.Library
+ initialLibCount int
+ )
+
+ BeforeEach(func() {
+ libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
+
+ // Count initial libraries
+ existingLibs, err := libRepo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ initialLibCount = len(existingLibs)
+
+ library1 = model.Library{ID: 0, Name: "Admin Test Library 1", Path: "/admin/test/path1"}
+ library2 = model.Library{ID: 0, Name: "Admin Test Library 2", Path: "/admin/test/path2"}
+
+ // Create test libraries
+ Expect(libRepo.Put(&library1)).To(BeNil())
+ Expect(libRepo.Put(&library2)).To(BeNil())
+ })
+
+ AfterEach(func() {
+ // Clean up test libraries and their associations
+ _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}})
+
+ // Clean up user-library associations for these test libraries
+ _, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}}))
+ })
+
+ It("automatically assigns all libraries to admin users when created", func() {
+ adminUser := model.User{
+ ID: "admin-user-id-1",
+ UserName: "adminuser1",
+ Name: "Admin User",
+ Email: "admin1@example.com",
+ NewPassword: "password",
+ IsAdmin: true,
+ }
+
+ err := repo.Put(&adminUser)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Admin should automatically have access to all libraries (including existing ones)
+ libraries, err := repo.GetUserLibraries(adminUser.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries
+
+ libIDs := make([]int, len(libraries))
+ for i, lib := range libraries {
+ libIDs[i] = lib.ID
+ }
+ Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
+ })
+
+ It("automatically assigns all libraries to admin users when updated", func() {
+ // Create regular user first
+ regularUser := model.User{
+ ID: "regular-user-id-1",
+ UserName: "regularuser1",
+ Name: "Regular User",
+ Email: "regular1@example.com",
+ NewPassword: "password",
+ IsAdmin: false,
+ }
+
+ err := repo.Put(®ularUser)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Give them access to just one library
+ err = repo.SetUserLibraries(regularUser.ID, []int{library1.ID})
+ Expect(err).ToNot(HaveOccurred())
+
+ // Promote to admin
+ regularUser.IsAdmin = true
+ err = repo.Put(®ularUser)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should now have access to all libraries (including existing ones)
+ libraries, err := repo.GetUserLibraries(regularUser.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries
+
+ libIDs := make([]int, len(libraries))
+ for i, lib := range libraries {
+ libIDs[i] = lib.ID
+ }
+ // Should include our test libraries plus all existing ones
+ Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
+ })
+
+ It("assigns default libraries to regular users", func() {
+ regularUser := model.User{
+ ID: "regular-user-id-2",
+ UserName: "regularuser2",
+ Name: "Regular User",
+ Email: "regular2@example.com",
+ NewPassword: "password",
+ IsAdmin: false,
+ }
+
+ err := repo.Put(®ularUser)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Regular user should be assigned to default libraries (library ID 1 from migration)
+ libraries, err := repo.GetUserLibraries(regularUser.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libraries).To(HaveLen(1))
+ Expect(libraries[0].ID).To(Equal(1))
+ Expect(libraries[0].DefaultNewUsers).To(BeTrue())
+ })
+ })
+
+ Describe("Libraries Field Population", func() {
+ var (
+ libRepo model.LibraryRepository
+ library1 model.Library
+ library2 model.Library
+ testUser model.User
+ )
+
+ BeforeEach(func() {
+ libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
+ library1 = model.Library{ID: 0, Name: "Field Test Library 1", Path: "/field/test/path1"}
+ library2 = model.Library{ID: 0, Name: "Field Test Library 2", Path: "/field/test/path2"}
+
+ // Create test libraries
+ Expect(libRepo.Put(&library1)).To(BeNil())
+ Expect(libRepo.Put(&library2)).To(BeNil())
+
+ // Create test user
+ testUser = model.User{
+ ID: "field-test-user",
+ UserName: "fieldtestuser",
+ Name: "Field Test User",
+ Email: "fieldtest@example.com",
+ NewPassword: "password",
+ IsAdmin: false,
+ }
+ Expect(repo.Put(&testUser)).To(BeNil())
+
+ // Assign libraries to user
+ Expect(repo.SetUserLibraries(testUser.ID, []int{library1.ID, library2.ID})).To(BeNil())
+ })
+
+ AfterEach(func() {
+ // Clean up test libraries and their associations
+ _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}})
+ _ = repo.(*userRepository).delete(squirrel.Eq{"id": testUser.ID})
+
+ // Clean up user-library associations for these test libraries
+ _, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}}))
+ })
+
+ It("populates Libraries field when getting a single user", func() {
+ user, err := repo.Get(testUser.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(user.Libraries).To(HaveLen(2))
+
+ libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID}
+ Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
+
+ // Check that library details are properly populated
+ for _, lib := range user.Libraries {
+ switch lib.ID {
+ case library1.ID:
+ Expect(lib.Name).To(Equal("Field Test Library 1"))
+ Expect(lib.Path).To(Equal("/field/test/path1"))
+ case library2.ID:
+ Expect(lib.Name).To(Equal("Field Test Library 2"))
+ Expect(lib.Path).To(Equal("/field/test/path2"))
+ }
+ }
+ })
+
+ It("populates Libraries field when getting all users", func() {
+ users, err := repo.(*userRepository).GetAll()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Find our test user in the results
+ found := slices.IndexFunc(users, func(u model.User) bool { return u.ID == testUser.ID })
+ Expect(found).ToNot(Equal(-1))
+
+ foundUser := users[found]
+ Expect(foundUser).ToNot(BeNil())
+ Expect(foundUser.Libraries).To(HaveLen(2))
+
+ libIDs := []int{foundUser.Libraries[0].ID, foundUser.Libraries[1].ID}
+ Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
+ })
+
+ It("populates Libraries field when finding user by username", func() {
+ user, err := repo.FindByUsername(testUser.UserName)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(user.Libraries).To(HaveLen(2))
+
+ libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID}
+ Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
+ })
+
+ It("returns default Libraries array for new regular users", func() {
+ // Create a user with no explicit library associations - should get default libraries
+ userWithoutLibs := model.User{
+ ID: "no-libs-user",
+ UserName: "nolibsuser",
+ Name: "No Libs User",
+ Email: "nolibs@example.com",
+ NewPassword: "password",
+ IsAdmin: false,
+ }
+ Expect(repo.Put(&userWithoutLibs)).To(BeNil())
+ defer func() { _ = repo.(*userRepository).delete(squirrel.Eq{"id": userWithoutLibs.ID}) }()
+
+ user, err := repo.Get(userWithoutLibs.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(user.Libraries).ToNot(BeNil())
+ // Regular users should be assigned to default libraries (library ID 1 from migration)
+ Expect(user.Libraries).To(HaveLen(1))
+ Expect(user.Libraries[0].ID).To(Equal(1))
+ })
+ })
+
+ Describe("filters", func() {
+ It("qualifies id filter with table name", func() {
+ r := repo.(*userRepository)
+ qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}})
+ sel := r.selectUserWithLibraries(qo)
+ query, _, err := r.toSQL(sel)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(query).To(ContainSubstring("user.id = {:p0}"))
+ })
+ })
})
diff --git a/plugins/.gitignore b/plugins/.gitignore
new file mode 100644
index 000000000..5026985e6
--- /dev/null
+++ b/plugins/.gitignore
@@ -0,0 +1,4 @@
+# Rust build artifacts
+# Cargo.lock is not needed for library crates (this is a cdylib)
+Cargo.lock
+target
\ No newline at end of file
diff --git a/plugins/README.md b/plugins/README.md
new file mode 100644
index 000000000..048cf549d
--- /dev/null
+++ b/plugins/README.md
@@ -0,0 +1,1106 @@
+# Navidrome Plugin System
+
+Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, lyrics providers, audio similarity, and other integrations through host services like scheduling, caching, task queues, WebSockets, and Subsonic API access.
+
+The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. You can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs).
+
+**Essential Extism Resources:**
+- [Extism Documentation](https://extism.org/docs/overview) – Core concepts and architecture
+- [Plugin Development Kits (PDKs)](https://extism.org/docs/concepts/pdk) – Language-specific libraries for writing plugins
+- [Go PDK](https://github.com/extism/go-pdk) – Recommended for Go plugins with TinyGo
+- [Rust PDK](https://github.com/extism/rust-pdk) – For Rust plugins
+- [Python PDK](https://github.com/extism/python-pdk) – Experimental Python support
+- [JavaScript PDK](https://github.com/extism/js-pdk) – For TypeScript/JavaScript plugins
+
+## Table of Contents
+
+- [Quick Start](#quick-start)
+- [Plugin Basics](#plugin-basics)
+- [Capabilities](#capabilities)
+ - [MetadataAgent](#metadataagent)
+ - [Scrobbler](#scrobbler)
+ - [Lyrics](#lyrics)
+ - [SonicSimilarity](#sonicsimilarity)
+ - [TaskWorker](#taskworker)
+ - [Lifecycle](#lifecycle)
+ - [SchedulerCallback](#schedulercallback)
+ - [WebSocketCallback](#websocketcallback)
+- [Host Services](#host-services)
+ - [HTTP](#http)
+ - [Scheduler](#scheduler)
+ - [Cache](#cache)
+ - [KVStore](#kvstore)
+ - [Task](#task)
+ - [WebSocket](#websocket)
+ - [Library](#library)
+ - [Artwork](#artwork)
+ - [SubsonicAPI](#subsonicapi)
+ - [Config](#config)
+ - [Users](#users)
+- [Configuration](#configuration)
+- [Building Plugins](#building-plugins)
+- [Examples](#examples)
+- [Security](#security)
+
+---
+
+## Quick Start
+
+### 1. Create a minimal plugin
+
+Create `main.go`:
+
+```go
+package main
+
+import "github.com/extism/go-pdk"
+
+func main() {}
+
+// Implement your capability functions here
+```
+
+Create `manifest.json`:
+
+```json
+{
+ "name": "My Plugin",
+ "author": "Your Name",
+ "version": "1.0.0"
+}
+```
+
+### 2. Build with TinyGo and package as .ndp
+
+```bash
+# Compile to WebAssembly
+tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared .
+
+# Package as .ndp (zip archive)
+zip -j my-plugin.ndp manifest.json plugin.wasm
+```
+
+### 3. Install
+
+Copy `my-plugin.ndp` to your Navidrome plugins folder and enable plugins in your config:
+
+```toml
+[Plugins]
+Enabled = true
+Folder = "/path/to/plugins"
+```
+
+---
+
+## Plugin Basics
+
+### What is a Plugin?
+
+A Navidrome plugin is an `.ndp` package file (zip archive) containing:
+
+1. **`manifest.json`** – Plugin metadata (name, author, version, permissions)
+2. **`plugin.wasm`** – Compiled WebAssembly module with capability functions
+
+### Plugin Naming
+
+Plugins are identified by their **filename** (without `.ndp` extension), not the manifest `name` field:
+
+- `my-plugin.ndp` → plugin ID is `my-plugin`
+- The manifest `name` is the display name shown in the UI
+
+This allows users to have multiple instances of the same plugin with different configs by renaming the files.
+
+### The Manifest
+
+Every plugin must include a `manifest.json` file. Example:
+
+```json
+{
+ "name": "My Plugin",
+ "author": "Author Name",
+ "version": "1.0.0",
+ "description": "What this plugin does",
+ "website": "https://example.com",
+ "config": {
+ "schema": { ... },
+ "uiSchema": { ... }
+ },
+ "permissions": {
+ "http": {
+ "reason": "Fetch metadata from external API",
+ "requiredHosts": ["api.example.com", "*.musicbrainz.org"]
+ }
+ }
+}
+```
+
+**Required fields:** `name`, `author`, `version`
+
+**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental`
+
+#### Config Definition
+
+The `config` field defines the plugin's configuration schema using [JSON Schema (draft-07)](https://json-schema.org/) and an optional [JSONForms](https://jsonforms.io/) UI schema for rendering in the Navidrome web UI:
+
+```json
+{
+ "config": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "api_key": { "type": "string", "title": "API Key" },
+ "max_retries": { "type": "integer", "default": 3 }
+ },
+ "required": ["api_key"]
+ },
+ "uiSchema": {
+ "api_key": { "ui:widget": "password" }
+ }
+ }
+}
+```
+
+#### Experimental Features
+
+Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported:
+
+- **`threads`** – Enables WebAssembly threads support (for plugins compiled with multi-threading)
+
+```json
+{
+ "experimental": {
+ "threads": {
+ "reason": "Required for concurrent audio processing"
+ }
+ }
+}
+```
+
+> **Note:** Experimental features may have compatibility or performance implications. Use only when necessary.
+
+---
+
+## Capabilities
+
+Capabilities define what your plugin can do. They're automatically detected based on which functions you export. A plugin can implement multiple capabilities.
+
+### MetadataAgent
+
+Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports.
+
+| Function | Input | Output | Description |
+|-----------------------------------|----------------------------|----------------------------------|--------------------------|
+| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |
+| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL |
+| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography |
+| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists |
+| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images |
+| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs |
+| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
+| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
+| `nd_get_similar_songs_by_track` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by track |
+| `nd_get_similar_songs_by_album` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by album |
+| `nd_get_similar_songs_by_artist` | `{id, name, mbid?, count}` | `{songs: [{name, artist}]}` | Similar songs by artist |
+
+To use the plugin as a metadata agent, add it to your config:
+
+```toml
+Agents = "lastfm,spotify,my-plugin"
+```
+
+**Example (using Go PDK package):**
+
+```go
+package main
+
+import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
+
+type myPlugin struct{}
+
+func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
+ return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil
+}
+
+func init() { metadata.Register(&myPlugin{}) }
+func main() {}
+```
+
+**Example (raw wasmexport):**
+
+```go
+//go:wasmexport nd_get_artist_biography
+func ndGetArtistBiography() int32 {
+ var input ArtistInput
+ if err := pdk.InputJSON(&input); err != nil {
+ pdk.SetError(err)
+ return 1
+ }
+ pdk.OutputJSON(BiographyOutput{Biography: "Artist biography..."})
+ return 0
+}
+```
+
+### Scrobbler
+
+Integrates with external scrobbling services. All three methods are **required**.
+
+| Function | Input | Output | Description |
+|------------------------------|-----------------------|--------|-----------------------------|
+| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized |
+| `nd_scrobbler_now_playing` | See below | (none) | Send now playing |
+| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble |
+
+> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration.
+
+**Manifest permission:**
+
+```json
+{
+ "permissions": {
+ "users": {
+ "reason": "Receive scrobble events for users assigned to this plugin"
+ }
+ }
+}
+```
+
+**NowPlaying/Scrobble Input:**
+
+```json
+{
+ "username": "john",
+ "track": {
+ "id": "track-id",
+ "title": "Song Title",
+ "album": "Album Name",
+ "artist": "Artist Name",
+ "albumArtist": "Album Artist",
+ "duration": 180.5,
+ "trackNumber": 1,
+ "discNumber": 1,
+ "mbzRecordingId": "...",
+ "mbzAlbumId": "...",
+ "mbzArtistId": "..."
+ },
+ "timestamp": 1703270400
+}
+```
+
+**Error Handling:**
+
+On success, return `0`. On failure, use `pdk.SetError()` with one of these error types:
+
+- `scrobbler(not_authorized)` – User needs to re-authorize
+- `scrobbler(retry_later)` – Temporary failure, Navidrome will retry
+- `scrobbler(unrecoverable)` – Permanent failure, scrobble discarded
+
+```go
+import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
+
+return scrobbler.ScrobblerErrorNotAuthorized
+return scrobbler.ScrobblerErrorRetryLater
+return scrobbler.ScrobblerErrorUnrecoverable
+```
+
+### Lyrics
+
+Provides lyrics for tracks. The single method is **required**.
+
+| Function | Input | Output | Description |
+|-------------------------|-------------------------------|------------------------------------|-----------------|
+| `nd_lyrics_get_lyrics` | `{artistName, title, ...}` | `{lyrics: [{lang, text}]}` | Get lyrics |
+
+Each returned lyric entry has a `lang` (language code) and `text` field. Multiple entries can be returned for different languages.
+
+### SonicSimilarity
+
+Audio-similarity discovery based on acoustic features (e.g., embeddings). Both methods are **required**.
+
+| Function | Input | Output | Description |
+|---------------------------------|----------------------------------|--------------------------------------------|---------------------------------------|
+| `nd_get_sonic_similar_tracks` | `{song, count}` | `{matches: [{song, similarity}]}` | Find acoustically similar tracks |
+| `nd_find_sonic_path` | `{startSong, endSong, count}` | `{matches: [{song, similarity}]}` | Find a path between two songs |
+
+Each match contains a `song` reference and a `similarity` score (float64, 0.0–1.0).
+
+### TaskWorker
+
+Processes tasks from a queue. The method is **optional** — export it if your plugin uses the [Task](#task) host service for background work.
+
+| Function | Input | Output | Description |
+|---------------------|---------------------------------------------|---------|----------------------|
+| `nd_task_execute` | `{queueName, taskID, payload, attempt}` | `string`| Execute a queued task|
+
+The `payload` is raw bytes (the same bytes passed to `TaskEnqueue`). The `attempt` counter starts at 1 and increments on retries. Return a string result on success.
+
+### Lifecycle
+
+Optional initialization callback. Called once after the plugin fully loads.
+
+| Function | Input | Output | Description |
+|--------------|-------|------------|--------------------------------|
+| `nd_on_init` | `{}` | `{error?}` | Called once after plugin loads |
+
+Useful for initializing connections, scheduling recurring tasks, etc. Errors are logged but don't prevent the plugin from loading.
+
+### SchedulerCallback
+
+Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service.
+
+| Function | Input | Output | Description |
+|---------------------------|----------------------------------------------|--------|-----------------------------|
+| `nd_scheduler_callback` | `{scheduleId, payload, isRecurring}` | (none) | Handle scheduled task event |
+
+### WebSocketCallback
+
+Receives WebSocket events. Export any subset of these to handle events from the [WebSocket](#websocket) host service.
+
+| Function | Input | Description |
+|----------------------------------|---------------------------------|----------------------------------|
+| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received |
+| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) |
+| `nd_websocket_on_error` | `{connectionId, error}` | Connection error |
+| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed |
+
+---
+
+## Host Services
+
+Host services let your plugin call back into Navidrome for advanced functionality. Each service (except [Config](#config)) requires declaring the corresponding permission in your manifest.
+
+### Go PDK Setup
+
+All host service examples below use the generated Go SDK. Add this to your `go.mod`:
+
+```
+require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
+replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
+```
+
+Then import:
+
+```go
+import "github.com/navidrome/navidrome/plugins/pdk/go/host"
+```
+
+### HTTP
+
+Make HTTP requests to external services. This is a dedicated host service (separate from Extism's built-in HTTP support) with additional features like timeouts and redirect control.
+
+**Manifest permission:**
+
+```json
+{
+ "permissions": {
+ "http": {
+ "reason": "Fetch metadata from external API",
+ "requiredHosts": ["api.example.com", "*.musicbrainz.org"]
+ }
+ }
+}
+```
+
+**Host functions:**
+
+| Function | Parameters | Returns |
+|-------------|----------------------------------------------------------|----------------------------------|
+| `http_send` | `method, url, headers, body, timeoutMs, noFollowRedirects` | `statusCode, headers, body` |
+
+**Usage:**
+
+```go
+resp, err := host.HTTPSend(host.HTTPRequest{
+ Method: "GET",
+ URL: "https://api.example.com/data",
+ Headers: map[string]string{"Authorization": "Bearer " + apiKey},
+})
+if resp.StatusCode == 200 {
+ // Process resp.Body
+}
+```
+
+### Scheduler
+
+Schedule one-time or recurring tasks. Your plugin must export the [`nd_scheduler_callback`](#schedulercallback) function to receive events.
+
+**Manifest permission:**
+
+```json
+{
+ "permissions": {
+ "scheduler": {
+ "reason": "Schedule periodic metadata refresh"
+ }
+ }
+}
+```
+
+**Host functions:**
+
+| Function | Parameters | Description |
+|-------------------------------|------------------------------------------|-----------------------------|
+| `scheduler_scheduleonetime` | `delaySeconds, payload, scheduleId?` | Schedule one-time callback |
+| `scheduler_schedulerecurring` | `cronExpression, payload, scheduleId?` | Schedule recurring callback |
+| `scheduler_cancelschedule` | `scheduleId` | Cancel a scheduled task |
+
+**Usage:**
+
+```go
+// Schedule one-time task in 60 seconds
+scheduleID, err := host.SchedulerScheduleOneTime(60, "my-payload", "")
+
+// Schedule recurring task with cron expression (every hour)
+scheduleID, err := host.SchedulerScheduleRecurring("0 * * * *", "hourly-task", "")
+
+// Cancel a task
+err := host.SchedulerCancelSchedule(scheduleID)
+```
+
+### Cache
+
+In-memory TTL-based cache. Each plugin has its own isolated namespace. Cleared on server restart.
+
+**Manifest permission:**
+
+```json
+{
+ "permissions": {
+ "cache": {
+ "reason": "Cache API responses to reduce external requests"
+ }
+ }
+}
+```
+
+**Host functions:**
+
+| Function | Parameters | Description |
+|-------------------|---------------------------|-----------------------|
+| `cache_setstring` | `key, value, ttl_seconds` | Store a string |
+| `cache_getstring` | `key` | Get a string |
+| `cache_setint` | `key, value, ttl_seconds` | Store an integer |
+| `cache_getint` | `key` | Get an integer |
+| `cache_setfloat` | `key, value, ttl_seconds` | Store a float |
+| `cache_getfloat` | `key` | Get a float |
+| `cache_setbytes` | `key, value, ttl_seconds` | Store bytes |
+| `cache_getbytes` | `key` | Get bytes |
+| `cache_has` | `key` | Check if key exists |
+| `cache_remove` | `key` | Delete a cached value |
+
+**TTL:** Pass `0` for the default (24 hours), or specify seconds.
+
+**Usage:**
+
+```go
+// Cache a value for 1 hour
+host.CacheSetString("api-response", responseData, 3600)
+
+// Retrieve (returns value, exists, error)
+value, exists, err := host.CacheGetString("api-response")
+if exists {
+ // Use value
+}
+```
+
+### KVStore
+
+Persistent key-value storage backed by SQLite. Survives server restarts. Each plugin has its own isolated database at `${DataFolder}/plugins/${pluginID}/kvstore.db`.
+
+**Manifest permission:**
+
+```json
+{
+ "permissions": {
+ "kvstore": {
+ "reason": "Store OAuth tokens and plugin state",
+ "maxSize": "1MB"
+ }
+ }
+}
+```
+
+- `maxSize`: Maximum storage size (e.g., `"1MB"`, `"500KB"`). Default: 1MB
+
+**Key constraints:** Maximum 256 bytes, must be valid UTF-8.
+
+**Host functions:**
+
+| Function | Parameters | Description |
+|-----------------------------|--------------------------|-----------------------------------|
+| `kvstore_set` | `key, value` | Store a byte value |
+| `kvstore_setwithttl` | `key, value, ttlSeconds` | Store with auto-expiration |
+| `kvstore_get` | `key` | Retrieve a byte value |
+| `kvstore_getmany` | `keys` | Retrieve multiple values at once |
+| `kvstore_has` | `key` | Check if key exists |
+| `kvstore_list` | `prefix` | List keys matching prefix |
+| `kvstore_delete` | `key` | Delete a value |
+| `kvstore_deletebyprefix` | `prefix` | Delete all keys matching prefix |
+| `kvstore_getstorageused` | – | Get current storage usage (bytes) |
+
+**Usage:**
+
+```go
+// Store a value (as raw bytes)
+token := []byte(`{"access_token": "xyz", "refresh_token": "abc"}`)
+host.KVStoreSet("oauth:spotify", token)
+
+// Store with TTL (auto-expires after 1 hour)
+host.KVStoreSetWithTTL("session:abc", sessionData, 3600)
+
+// Retrieve a value
+value, exists, err := host.KVStoreGet("oauth:spotify")
+if exists {
+ var tokenData map[string]string
+ json.Unmarshal(value, &tokenData)
+}
+
+// Batch retrieve
+results, err := host.KVStoreGetMany([]string{"key1", "key2", "key3"})
+
+// List and delete by prefix
+keys, err := host.KVStoreList("user:")
+host.KVStoreDeleteByPrefix("user:")
+
+// Check storage usage
+usage, err := host.KVStoreGetStorageUsed()
+fmt.Printf("Using %d bytes\n", usage)
+```
+
+### Task
+
+Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function.
+
+**Manifest permission:**
+
+```json
+{
+ "permissions": {
+ "taskqueue": {
+ "reason": "Process audio analysis in the background",
+ "maxConcurrency": 2
+ }
+ }
+}
+```
+
+**Host functions:**
+
+| Function | Parameters | Description |
+|---------------------|---------------------------------------------------|----------------------------|
+| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, ...` | Create a named task queue |
+| `task_enqueue` | `queueName, payload` | Add a task to the queue |
+| `task_get` | `taskID` | Get task status and result |
+| `task_cancel` | `taskID` | Cancel a pending task |
+| `task_clearqueue` | `queueName` | Remove all tasks from queue|
+
+**Usage:**
+
+```go
+// Create a queue with retry configuration
+host.TaskCreateQueue("analysis", host.QueueConfig{
+ Concurrency: 2,
+ MaxRetries: 3,
+ BackoffMs: 1000,
+})
+
+// Enqueue a task
+taskID, err := host.TaskEnqueue("analysis", []byte(`{"trackId": "abc"}`))
+
+// Check task status
+info, err := host.TaskGet(taskID)
+fmt.Printf("Status: %s, Attempt: %d\n", info.Status, info.Attempt)
+```
+
+### WebSocket
+
+Establish persistent WebSocket connections to external services. Your plugin must export [WebSocketCallback](#websocketcallback) functions to receive events.
+
+**Manifest permission:**
+
+```json
+{
+ "permissions": {
+ "websocket": {
+ "reason": "Real-time connection to service",
+ "requiredHosts": ["gateway.example.com", "*.discord.gg"]
+ }
+ }
+}
+```
+
+**Host functions:**
+
+| Function | Parameters | Description |
+|----------------------------|---------------------------------|-------------------|
+| `websocket_connect` | `url, headers?, connectionId?` | Open a connection |
+| `websocket_sendtext` | `connectionId, message` | Send text message |
+| `websocket_sendbinary` | `connectionId, data` | Send binary data |
+| `websocket_closeconnection`| `connectionId, code?, reason?` | Close connection |
+
+**Usage:**
+
+```go
+connID, err := host.WebSocketConnect("wss://gateway.example.com", nil, "")
+host.WebSocketSendText(connID, `{"op": 1, "d": null}`)
+host.WebSocketCloseConnection(connID, 1000, "done")
+```
+
+### Library
+
+Access music library metadata and optionally read files from library directories.
+
+**Manifest permission:**
+
+```json
+{
+ "permissions": {
+ "library": {
+ "reason": "Access library metadata for analysis",
+ "filesystem": false
+ }
+ }
+}
+```
+
+- `filesystem` – Set to `true` to enable read-only access to library directories (default: `false`)
+
+**Host functions:**
+
+| Function | Parameters | Returns |
+|----------------------------|------------|---------------------------|
+| `library_getlibrary` | `id` | Library metadata |
+| `library_getalllibraries` | (none) | Array of library metadata |
+
+**Library metadata:**
+
+```json
+{
+ "id": 1,
+ "name": "My Music",
+ "path": "/music/collection",
+ "mountPoint": "/libraries/1",
+ "lastScanAt": 1703270400,
+ "totalSongs": 5000,
+ "totalAlbums": 500,
+ "totalArtists": 200,
+ "totalSize": 50000000000,
+ "totalDuration": 1500000.5
+}
+```
+
+> **Note:** The `path` and `mountPoint` fields are only included when `filesystem: true` is set in the permission.
+
+**Filesystem access:**
+
+When `filesystem: true`, your plugin can read files from library directories via WASI filesystem APIs. Each library is mounted at `/libraries/`:
+
+```go
+import "os"
+
+content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3")
+entries, err := os.ReadDir("/libraries/1/Artist")
+```
+
+> **Security:** Filesystem access is read-only and restricted to configured library paths only.
+
+**Usage:**
+
+```go
+// Get a specific library
+library, err := host.LibraryGetLibrary(1)
+fmt.Printf("Library: %s (%d songs)\n", library.Name, library.TotalSongs)
+
+// Get all libraries
+libraries, err := host.LibraryGetAllLibraries()
+for _, lib := range libraries {
+ fmt.Printf("Library: %s (%d songs)\n", lib.Name, lib.TotalSongs)
+}
+```
+
+### Artwork
+
+Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists).
+
+**Manifest permission:**
+
+```json
+{
+ "permissions": {
+ "artwork": {
+ "reason": "Get artwork URLs for display"
+ }
+ }
+}
+```
+
+**Host functions:**
+
+| Function | Parameters | Returns |
+|--------------------------|------------|-------------|
+| `artwork_getartisturl` | `id, size` | Artwork URL |
+| `artwork_getalbumurl` | `id, size` | Artwork URL |
+| `artwork_gettrackurl` | `id, size` | Artwork URL |
+| `artwork_getplaylisturl` | `id, size` | Artwork URL |
+
+**Usage:**
+
+```go
+url, err := host.ArtworkGetAlbumUrl("album-id", 300)
+```
+
+### SubsonicAPI
+
+Call Navidrome's Subsonic API internally (no network round-trip).
+
+**Manifest permission:**
+
+```json
+{
+ "permissions": {
+ "subsonicapi": {
+ "reason": "Access library data"
+ },
+ "users": {
+ "reason": "Access user information for SubsonicAPI authorization"
+ }
+ }
+}
+```
+
+> **Important:** The `subsonicapi` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI.
+
+**Host functions:**
+
+| Function | Parameters | Returns |
+|-----------------------|------------|--------------------------------|
+| `subsonicapi_call` | `uri` | JSON response string |
+| `subsonicapi_callraw` | `uri` | Content type + binary response |
+
+**Usage:**
+
+```go
+// JSON response
+response, err := host.SubsonicAPICall("getAlbumList2?type=random&size=10&u=username")
+
+// Binary response (e.g., cover art, streams)
+contentType, data, err := host.SubsonicAPICallRaw("getCoverArt?id=al-123&u=username")
+```
+
+### Config
+
+Access plugin configuration values. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys — useful for discovering dynamic configuration.
+
+> **Note:** This service is always available and does not require a manifest permission.
+
+**Host functions:**
+
+| Function | Parameters | Returns |
+|-----------------|------------|-----------------------------|
+| `config_get` | `key` | `value, exists` |
+| `config_getint` | `key` | `value, exists` |
+| `config_keys` | `prefix` | Array of matching key names |
+
+**Usage:**
+
+```go
+// Get a configuration value
+value, exists := host.ConfigGet("api_key")
+
+// Get an integer configuration value
+count, exists := host.ConfigGetInt("max_retries")
+
+// List all keys with a prefix (useful for user-specific config)
+keys := host.ConfigKeys("user:")
+
+// List all configuration keys
+allKeys := host.ConfigKeys("")
+```
+
+### Users
+
+Access user information for the users that the plugin has been granted access to.
+
+**Manifest permission:**
+
+```json
+{
+ "permissions": {
+ "users": {
+ "reason": "Display user information in status updates"
+ }
+ }
+}
+```
+
+**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access:
+
+1. **Allow all users** – Enable the "Allow all users" toggle in the plugin settings
+2. **Select specific users** – Choose individual users from the user list
+
+If neither option is configured, the plugin cannot be enabled.
+
+**Host functions:**
+
+| Function | Parameters | Returns |
+|------------------|------------|-----------------------|
+| `users_getusers` | – | Array of User objects |
+| `users_getadmins`| – | Array of admin Users |
+
+**User object fields:**
+
+| Field | Type | Description |
+|------------|---------|--------------------------------|
+| `userName` | string | The user's unique username |
+| `name` | string | The user's display name |
+| `isAdmin` | boolean | Whether the user is an admin |
+
+> **Security:** Sensitive fields like passwords, email addresses, and internal IDs are never exposed to plugins.
+
+**Usage:**
+
+```go
+users, err := host.UsersGetUsers()
+for _, user := range users {
+ pdk.Log(pdk.LogInfo, "User: " + user.UserName + " (" + user.Name + ")")
+}
+
+admins, err := host.UsersGetAdmins()
+```
+
+---
+
+## Configuration
+
+### Server Configuration
+
+Enable plugins in `navidrome.toml`:
+
+```toml
+[Plugins]
+Enabled = true
+Folder = "/path/to/plugins" # Default: DataFolder/plugins
+AutoReload = true # Auto-reload on file changes (dev mode)
+LogLevel = "debug" # Plugin-specific log level
+CacheSize = "200MB" # Compilation cache size limit
+```
+
+### Plugin Configuration
+
+Plugin configuration is managed through the Navidrome web UI. Navigate to the Plugins page, select a plugin, and edit its configuration as key-value pairs.
+
+Access configuration values in your plugin:
+
+```go
+apiKey, ok := pdk.GetConfig("api_key")
+if !ok {
+ pdk.SetErrorString("api_key configuration is required")
+ return 1
+}
+```
+
+For more advanced access (listing keys, integer values), use the [Config](#config) host service.
+
+---
+
+## Building Plugins
+
+### Supported Languages
+
+Plugins can be written in any language that Extism supports. We recommend:
+
+- **Go** – Best overall experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk). Familiar syntax, excellent stdlib support.
+- **Rust** – Best for performance-critical plugins. Smallest binaries, excellent type safety. Uses the [Rust PDK](https://github.com/extism/rust-pdk).
+- **Python** – Best for rapid prototyping. Experimental support via [extism-py](https://github.com/extism/python-pdk). Note some limitations compared to compiled languages.
+- **TypeScript** – Experimental support via [extism-js](https://github.com/extism/js-pdk).
+
+### Go with TinyGo (Recommended)
+
+```bash
+# Install TinyGo: https://tinygo.org/getting-started/install/
+
+# Build WebAssembly module
+tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared .
+
+# Package as .ndp
+zip -j my-plugin.ndp manifest.json plugin.wasm
+```
+
+#### Using Go PDK Packages
+
+Navidrome provides type-safe Go packages for each capability and host service in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern:
+
+```go
+package main
+
+import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
+
+type myPlugin struct{}
+
+func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
+ return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil
+}
+
+func init() { metadata.Register(&myPlugin{}) }
+func main() {}
+```
+
+Add to your `go.mod`:
+
+```
+require github.com/navidrome/navidrome v0.0.0
+replace github.com/navidrome/navidrome => ../../..
+```
+
+**Available capability packages:**
+
+| Package | Import Path | Description |
+|-------------------|--------------------------------------|--------------------------------------|
+| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers |
+| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services |
+| `lyrics` | `plugins/pdk/go/lyrics` | Lyrics providers |
+| `sonicsimilarity` | `plugins/pdk/go/sonicsimilarity` | Audio similarity discovery |
+| `taskworker` | `plugins/pdk/go/taskworker` | Background task processing |
+| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization |
+| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks |
+| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers |
+| `host` | `plugins/pdk/go/host` | Host service SDK (all services) |
+
+See the example plugins in [examples/](examples/) for complete usage patterns.
+
+### Rust
+
+```bash
+# Build WebAssembly module
+cargo build --release --target wasm32-wasip1
+
+# Package as .ndp
+zip -j my-plugin.ndp manifest.json target/wasm32-wasip1/release/plugin.wasm
+```
+
+#### Using Rust PDK
+
+```toml
+# Cargo.toml
+[dependencies]
+nd-pdk = { path = "../../pdk/rust/nd-pdk" }
+extism-pdk = "1.2"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+```
+
+**Implementing capabilities with traits and macros:**
+
+```rust
+use nd_pdk::scrobbler::{Scrobbler, IsAuthorizedRequest, Error};
+use nd_pdk::register_scrobbler;
+
+#[derive(Default)]
+struct MyPlugin;
+
+impl Scrobbler for MyPlugin {
+ fn is_authorized(&self, req: IsAuthorizedRequest) -> Result {
+ Ok(true)
+ }
+ fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error> { Ok(()) }
+ fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error> { Ok(()) }
+}
+
+register_scrobbler!(MyPlugin); // Generates all WASM exports
+```
+
+**Using host services:**
+
+```rust
+use nd_pdk::host::{cache, scheduler, library};
+
+cache::set_string("my_key", "my_value", 3600)?;
+scheduler::schedule_recurring("@every 5m", "payload", "task_id")?;
+let libs = library::get_all_libraries()?;
+```
+
+See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation.
+
+### Python (with extism-py)
+
+```bash
+# Build WebAssembly module (requires extism-py installed)
+extism-py plugin.wasm -o plugin.wasm *.py
+
+# Package as .ndp
+zip -j my-plugin.ndp manifest.json plugin.wasm
+```
+
+**For Python host services:** Copy functions from the `nd_host_*.py` files in `plugins/pdk/python/host/` into your `__init__.py` (see comments in those files for extism-py limitations).
+
+### Using XTP CLI (Scaffolding)
+
+Bootstrap a new plugin from a schema:
+
+```bash
+# Install XTP CLI: https://docs.xtp.dylibso.com/docs/cli
+
+# Create a metadata agent plugin
+xtp plugin init \
+ --schema-file plugins/capabilities/metadata_agent.yaml \
+ --template go \
+ --path ./my-agent \
+ --name my-agent
+
+# Build and package
+cd my-agent && xtp plugin build
+zip -j my-agent.ndp manifest.json dist/plugin.wasm
+```
+
+See [capabilities/README.md](capabilities/README.md) for available schemas and scaffolding examples.
+
+---
+
+## Examples
+
+See [examples/](examples/) for complete working plugins:
+
+| Plugin | Language | Capabilities | Host Services | Description |
+|----------------------------------------------------------------|----------------|---------------|--------------------------------------------|--------------------------------|
+| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example |
+| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration |
+| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
+| [coverartarchive-as](examples/coverartarchive-as/) | AssemblyScript | MetadataAgent | HTTP | Cover Art Archive |
+| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
+| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger |
+| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
+| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo |
+| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration |
+
+---
+
+## Security
+
+Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime:
+
+1. **Host Allowlisting** – Only explicitly allowed hosts are accessible via HTTP/WebSocket
+2. **Limited File System** – Read-only access to library directories, only when explicitly granted the `library.filesystem` permission
+3. **No Network Listeners** – Plugins cannot bind ports
+4. **Config Isolation** – Plugins only receive their own config section
+5. **Memory Limits** – Controlled by the WebAssembly runtime
+6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration
+7. **Users Permission** – Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed
+
+---
+
+## Runtime Management
+
+### Auto-Reload
+
+With `AutoReload = true`, Navidrome watches the plugins folder and automatically detects when `.ndp` files are added, modified, or removed. When a plugin file changes, the plugin is disabled and its metadata is re-read from the archive.
+
+If `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin changes.
+
+### Enabling/Disabling Plugins
+
+Plugins can be enabled/disabled via the Navidrome UI. The plugin state is persisted in the database.
+
+### Important Notes
+
+- **In-flight requests** – When reloading, existing requests complete before the new version takes over
+- **Config changes** – Changes to the plugin configuration in the UI are applied immediately
+- **Cache persistence** – The in-memory cache is cleared when a plugin is unloaded
\ No newline at end of file
diff --git a/plugins/capabilities.go b/plugins/capabilities.go
new file mode 100644
index 000000000..81e683b6b
--- /dev/null
+++ b/plugins/capabilities.go
@@ -0,0 +1,41 @@
+package plugins
+
+import "slices"
+
+// Capability represents a plugin capability type.
+// Capabilities are detected by checking which functions a plugin exports.
+type Capability string
+
+// capabilityFunctions maps each capability to its required/optional functions.
+// A plugin has a capability if it exports at least one of these functions.
+var capabilityFunctions = map[Capability][]string{}
+
+// registerCapability registers a capability with its associated functions.
+func registerCapability(cap Capability, functions ...string) {
+ capabilityFunctions[cap] = functions
+}
+
+// functionExistsChecker is an interface for checking if a function exists in a plugin.
+// This allows for testing without a real plugin instance.
+type functionExistsChecker interface {
+ FunctionExists(name string) bool
+}
+
+// detectCapabilities detects which capabilities a plugin has by checking
+// which functions it exports.
+func detectCapabilities(plugin functionExistsChecker) []Capability {
+ var capabilities []Capability
+
+ for cap, functions := range capabilityFunctions {
+ if slices.ContainsFunc(functions, plugin.FunctionExists) {
+ capabilities = append(capabilities, cap) // Found at least one function, plugin has this capability
+ }
+ }
+
+ return capabilities
+}
+
+// hasCapability checks if the given capabilities slice contains a specific capability.
+func hasCapability(capabilities []Capability, cap Capability) bool {
+ return slices.Contains(capabilities, cap)
+}
diff --git a/plugins/capabilities/README.md b/plugins/capabilities/README.md
new file mode 100644
index 000000000..fca3cbd31
--- /dev/null
+++ b/plugins/capabilities/README.md
@@ -0,0 +1,87 @@
+# Navidrome Plugin Capabilities
+
+This directory contains the Go interface definitions for Navidrome plugin capabilities. These interfaces are the **source of truth** for plugin development and are used to generate:
+
+1. **Go PDK packages** (`pdk/go/*/`) - Type-safe wrappers for Go plugin developers
+2. **Rust PDK crates** (`pdk/rust/*/`) - Type-safe wrappers for Rust plugin developers
+3. **XTP YAML schemas** (`*.yaml`) - Schema files for other [Extism plugin languages](https://extism.org/docs/concepts/pdk/) (TypeScript, Python, C#, Zig, C++, ...)
+
+## For Go Plugin Developers
+
+Go developers should use the generated PDK packages in `plugins/pdk/go/`. See the example Go plugins in `plugins/examples/` for usage patterns.
+
+## For Rust Plugin Developers
+
+Rust developers should use the generated PDK crate in `plugins/pdk/rust/nd-pdk`. See the example Rust plugins in `plugins/examples` for usage patterns.
+
+## For Non-Go Plugin Developers
+
+If you're developing plugins in other languages (TypeScript, Rust, Python, C#, Zig, C++), you can use the XTP CLI to generate type-safe bindings from the YAML schema files in this directory.
+
+### Prerequisites
+
+Install the XTP CLI:
+
+```bash
+# macOS
+brew install dylibso/tap/xtp
+
+# Other platforms - see https://docs.xtp.dylibso.com/docs/cli
+curl https://static.dylibso.com/cli/install.sh | bash
+```
+
+### Generating Plugin Scaffolding
+
+Use the XTP CLI to generate plugin boilerplate from any capability schema:
+
+```bash
+# TypeScript
+xtp plugin init --schema-file plugins/capabilities/metadata_agent.yaml \
+ --template typescript --path my-plugin
+
+# Rust
+xtp plugin init --schema-file plugins/capabilities/scrobbler.yaml \
+ --template rust --path my-plugin
+
+# Python
+xtp plugin init --schema-file plugins/capabilities/lifecycle.yaml \
+ --template python --path my-plugin
+
+# C#
+xtp plugin init --schema-file plugins/capabilities/scheduler_callback.yaml \
+ --template csharp --path my-plugin
+
+# Go (alternative to using the PDK packages)
+xtp plugin init --schema-file plugins/capabilities/websocket_callback.yaml \
+ --template go --path my-plugin
+```
+
+### Available Capabilities
+
+| Capability | Schema File | Description |
+|--------------------|---------------------------|-------------------------------------------------------------|
+| Metadata Agent | `metadata_agent.yaml` | Fetch artist biographies, album images, and similar artists |
+| Scrobbler | `scrobbler.yaml` | Report listening activity to external services |
+| Lifecycle | `lifecycle.yaml` | Plugin initialization callbacks |
+| Scheduler Callback | `scheduler_callback.yaml` | Scheduled task execution |
+| WebSocket Callback | `websocket_callback.yaml` | Real-time WebSocket message handling |
+
+### Building Your Plugin
+
+After generating the scaffolding, implement the required functions and build your plugin as a WebAssembly module. The exact build process depends on your chosen language - see the [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for language-specific guides.
+
+## XTP Schema Generation
+
+The YAML schemas in this package are automatically generated from the capability Go interfaces using `ndpgen`.
+To regenerate the schemas after modifying the interfaces, run:
+
+```bash
+cd plugins/cmd/ndpgen && go run . -schemas -input=./plugins/capabilities
+```
+
+## Resources
+
+- [XTP Documentation](https://docs.xtp.dylibso.com/)
+- [XTP Bindgen Repository](https://github.com/dylibso/xtp-bindgen)
+- [Extism Plugin Development Kit](https://extism.org/docs/concepts/pdk)
+- [XTP Schema Definition](https://raw.githubusercontent.com/dylibso/xtp-bindgen/5090518dd86ba5e734dc225a33066ecc0ed2e12d/plugin/schema.json)
diff --git a/plugins/capabilities/doc.go b/plugins/capabilities/doc.go
new file mode 100644
index 000000000..fa9b7eb5d
--- /dev/null
+++ b/plugins/capabilities/doc.go
@@ -0,0 +1,56 @@
+// Package capabilities defines Go interfaces for Navidrome plugin capabilities.
+//
+// These interfaces serve as the source of truth for capability definitions.
+// The ndpgen tool generates:
+// - Go export wrappers in plugins/pdk/go// for Go plugins
+// - XTP YAML schemas for non-Go plugins (Rust, TypeScript, etc.)
+//
+// Each capability is defined as an annotated interface:
+//
+// //nd:capability name=metadata
+// type MetadataAgent interface {
+// //nd:export name=nd_get_artist_biography
+// GetArtistBiography(ArtistRequest) (*ArtistBiographyResponse, error)
+// }
+//
+// Annotation Reference:
+//
+// //nd:capability name= [required=true]
+// - Marks an interface as a capability
+// - name: Generated package name (e.g., name=metadata → pdk/go/metadata/)
+// - required: If true, all methods must be implemented (default: false)
+//
+// //nd:export name=
+// - Marks a method as an exported WASM function
+// - name: The export name (e.g., nd_get_artist_biography)
+//
+// Generated Code Structure:
+//
+// For a capability like MetadataAgent with required=false:
+//
+// package metadata
+//
+// // Agent is the marker interface
+// type Agent interface{}
+//
+// // Optional provider interfaces
+// type ArtistBiographyProvider interface {
+// GetArtistBiography(ArtistRequest) (*ArtistBiographyResponse, error)
+// }
+//
+// // Registration function
+// func Register(impl Agent) { ... }
+//
+// For a capability with required=true:
+//
+// package scrobbler
+//
+// // Scrobbler requires all methods
+// type Scrobbler interface {
+// IsAuthorized(IsAuthorizedRequest) (bool, error)
+// NowPlaying(NowPlayingRequest) error
+// Scrobble(ScrobbleRequest) error
+// }
+//
+// func Register(impl Scrobbler) { ... }
+package capabilities
diff --git a/plugins/capabilities/lifecycle.go b/plugins/capabilities/lifecycle.go
new file mode 100644
index 000000000..b5f19ec5b
--- /dev/null
+++ b/plugins/capabilities/lifecycle.go
@@ -0,0 +1,19 @@
+package capabilities
+
+// Lifecycle provides plugin lifecycle hooks.
+// This capability allows plugins to perform initialization when loaded,
+// such as establishing connections, starting background processes, or
+// validating configuration.
+//
+// The OnInit function is called once when the plugin is loaded, and is NOT
+// called when the plugin is hot-reloaded. Plugins should not assume this
+// function will be called on every startup.
+//
+//nd:capability name=lifecycle
+type Lifecycle interface {
+ // OnInit is called after a plugin is fully loaded with all services registered.
+ // Plugins can use this function to perform one-time initialization tasks.
+ // Errors are logged but will not prevent the plugin from being loaded.
+ //nd:export name=nd_on_init
+ OnInit() error
+}
diff --git a/plugins/capabilities/lifecycle.yaml b/plugins/capabilities/lifecycle.yaml
new file mode 100644
index 000000000..7c6af62b8
--- /dev/null
+++ b/plugins/capabilities/lifecycle.yaml
@@ -0,0 +1,7 @@
+version: v1-draft
+exports:
+ nd_on_init:
+ description: |-
+ OnInit is called after a plugin is fully loaded with all services registered.
+ Plugins can use this function to perform one-time initialization tasks.
+ Errors are logged but will not prevent the plugin from being loaded.
diff --git a/plugins/capabilities/lyrics.go b/plugins/capabilities/lyrics.go
new file mode 100644
index 000000000..6f6d19177
--- /dev/null
+++ b/plugins/capabilities/lyrics.go
@@ -0,0 +1,26 @@
+package capabilities
+
+// Lyrics provides lyrics for a given track from external sources.
+//
+//nd:capability name=lyrics required=true
+type Lyrics interface {
+ //nd:export name=nd_lyrics_get_lyrics
+ GetLyrics(GetLyricsRequest) (GetLyricsResponse, error)
+}
+
+// GetLyricsRequest contains the track information for lyrics lookup.
+type GetLyricsRequest struct {
+ Track TrackInfo `json:"track"`
+}
+
+// GetLyricsResponse contains the lyrics returned by the plugin.
+type GetLyricsResponse struct {
+ Lyrics []LyricsText `json:"lyrics"`
+}
+
+// LyricsText represents a single set of lyrics in raw text format.
+// Text can be plain text or LRC format — Navidrome will parse it.
+type LyricsText struct {
+ Lang string `json:"lang,omitempty"`
+ Text string `json:"text"`
+}
diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml
new file mode 100644
index 000000000..04dd283dd
--- /dev/null
+++ b/plugins/capabilities/lyrics.yaml
@@ -0,0 +1,126 @@
+version: v1-draft
+exports:
+ nd_lyrics_get_lyrics:
+ input:
+ $ref: '#/components/schemas/GetLyricsRequest'
+ contentType: application/json
+ output:
+ $ref: '#/components/schemas/GetLyricsResponse'
+ contentType: application/json
+components:
+ schemas:
+ ArtistRef:
+ description: ArtistRef is a reference to an artist with name and optional MBID.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome artist ID (if known).
+ name:
+ type: string
+ description: Name is the artist name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the artist.
+ required:
+ - name
+ GetLyricsRequest:
+ description: GetLyricsRequest contains the track information for lyrics lookup.
+ properties:
+ track:
+ $ref: '#/components/schemas/TrackInfo'
+ required:
+ - track
+ GetLyricsResponse:
+ description: GetLyricsResponse contains the lyrics returned by the plugin.
+ properties:
+ lyrics:
+ type: array
+ items:
+ $ref: '#/components/schemas/LyricsText'
+ required:
+ - lyrics
+ LyricsText:
+ description: |-
+ LyricsText represents a single set of lyrics in raw text format.
+ Text can be plain text or LRC format — Navidrome will parse it.
+ properties:
+ lang:
+ type: string
+ text:
+ type: string
+ required:
+ - text
+ TrackInfo:
+ description: TrackInfo contains track metadata.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome track ID.
+ title:
+ type: string
+ description: Title is the track title.
+ album:
+ type: string
+ description: Album is the album name.
+ artist:
+ type: string
+ description: Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
+ albumArtist:
+ type: string
+ description: AlbumArtist is the formatted album artist name for display.
+ artists:
+ type: array
+ description: Artists is the list of track artists.
+ items:
+ $ref: '#/components/schemas/ArtistRef'
+ albumArtists:
+ type: array
+ description: AlbumArtists is the list of album artists.
+ items:
+ $ref: '#/components/schemas/ArtistRef'
+ duration:
+ type: number
+ format: float
+ description: Duration is the track duration in seconds.
+ trackNumber:
+ type: integer
+ format: int32
+ description: TrackNumber is the track number on the album.
+ discNumber:
+ type: integer
+ format: int32
+ description: DiscNumber is the disc number.
+ mbzRecordingId:
+ type: string
+ description: MBZRecordingID is the MusicBrainz recording ID.
+ mbzAlbumId:
+ type: string
+ description: MBZAlbumID is the MusicBrainz album/release ID.
+ mbzReleaseGroupId:
+ type: string
+ description: MBZReleaseGroupID is the MusicBrainz release group ID.
+ mbzReleaseTrackId:
+ type: string
+ description: MBZReleaseTrackID is the MusicBrainz release track ID.
+ libraryId:
+ type: integer
+ format: int32
+ description: |-
+ LibraryID is the ID of the library the track belongs to.
+ Only included if the plugin has library permission with filesystem access for the track's library.
+ path:
+ type: string
+ description: |-
+ Path is the full path to the track file, relative to the library root.
+ Only included if the plugin has library permission with filesystem access for the track's library.
+ required:
+ - id
+ - title
+ - album
+ - artist
+ - albumArtist
+ - artists
+ - albumArtists
+ - duration
+ - trackNumber
+ - discNumber
diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go
new file mode 100644
index 000000000..407f21ec5
--- /dev/null
+++ b/plugins/capabilities/metadata_agent.go
@@ -0,0 +1,237 @@
+package capabilities
+
+// MetadataAgent provides artist and album metadata retrieval.
+// This capability allows plugins to provide external metadata for artists and albums,
+// such as biographies, images, similar artists, and top songs.
+//
+// Plugins implementing this capability can choose which methods to implement.
+// Each method is optional - plugins only need to provide the functionality they support.
+//
+//nd:capability name=metadata
+type MetadataAgent interface {
+ // GetArtistMBID retrieves the MusicBrainz ID for an artist.
+ //nd:export name=nd_get_artist_mbid
+ GetArtistMBID(ArtistMBIDRequest) (*ArtistMBIDResponse, error)
+
+ // GetArtistURL retrieves the external URL for an artist.
+ //nd:export name=nd_get_artist_url
+ GetArtistURL(ArtistRequest) (*ArtistURLResponse, error)
+
+ // GetArtistBiography retrieves the biography for an artist.
+ //nd:export name=nd_get_artist_biography
+ GetArtistBiography(ArtistRequest) (*ArtistBiographyResponse, error)
+
+ // GetSimilarArtists retrieves similar artists for a given artist.
+ //nd:export name=nd_get_similar_artists
+ GetSimilarArtists(SimilarArtistsRequest) (*SimilarArtistsResponse, error)
+
+ // GetArtistImages retrieves images for an artist.
+ //nd:export name=nd_get_artist_images
+ GetArtistImages(ArtistRequest) (*ArtistImagesResponse, error)
+
+ // GetArtistTopSongs retrieves top songs for an artist.
+ //nd:export name=nd_get_artist_top_songs
+ GetArtistTopSongs(TopSongsRequest) (*TopSongsResponse, error)
+
+ // GetAlbumInfo retrieves album information.
+ //nd:export name=nd_get_album_info
+ GetAlbumInfo(AlbumRequest) (*AlbumInfoResponse, error)
+
+ // GetAlbumImages retrieves images for an album.
+ //nd:export name=nd_get_album_images
+ GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error)
+
+ // GetSimilarSongsByTrack retrieves songs similar to a specific track.
+ //nd:export name=nd_get_similar_songs_by_track
+ GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error)
+
+ // GetSimilarSongsByAlbum retrieves songs similar to tracks on an album.
+ //nd:export name=nd_get_similar_songs_by_album
+ GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error)
+
+ // GetSimilarSongsByArtist retrieves songs similar to an artist's catalog.
+ //nd:export name=nd_get_similar_songs_by_artist
+ GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error)
+}
+
+// ArtistMBIDRequest is the request for GetArtistMBID.
+type ArtistMBIDRequest struct {
+ // ID is the internal Navidrome artist ID.
+ ID string `json:"id"`
+ // Name is the artist name.
+ Name string `json:"name"`
+}
+
+// ArtistMBIDResponse is the response for GetArtistMBID.
+type ArtistMBIDResponse struct {
+ // MBID is the MusicBrainz ID for the artist.
+ MBID string `json:"mbid"`
+}
+
+// ArtistRequest is the common request for artist-related functions.
+type ArtistRequest struct {
+ // ID is the internal Navidrome artist ID.
+ ID string `json:"id"`
+ // Name is the artist name.
+ Name string `json:"name"`
+ // MBID is the MusicBrainz ID for the artist (if known).
+ MBID string `json:"mbid,omitempty"`
+}
+
+// ArtistURLResponse is the response for GetArtistURL.
+type ArtistURLResponse struct {
+ // URL is the external URL for the artist.
+ URL string `json:"url"`
+}
+
+// ArtistBiographyResponse is the response for GetArtistBiography.
+type ArtistBiographyResponse struct {
+ // Biography is the artist biography text.
+ Biography string `json:"biography"`
+}
+
+// SimilarArtistsRequest is the request for GetSimilarArtists.
+type SimilarArtistsRequest struct {
+ // ID is the internal Navidrome artist ID.
+ ID string `json:"id"`
+ // Name is the artist name.
+ Name string `json:"name"`
+ // MBID is the MusicBrainz ID for the artist (if known).
+ MBID string `json:"mbid,omitempty"`
+ // Limit is the maximum number of similar artists to return.
+ Limit int32 `json:"limit"`
+}
+
+// SimilarArtistsResponse is the response for GetSimilarArtists.
+type SimilarArtistsResponse struct {
+ // Artists is the list of similar artists.
+ Artists []ArtistRef `json:"artists"`
+}
+
+// ImageInfo represents an image with URL and size.
+type ImageInfo struct {
+ // URL is the URL of the image.
+ URL string `json:"url"`
+ // Size is the size of the image in pixels (width or height).
+ Size int32 `json:"size"`
+}
+
+// ArtistImagesResponse is the response for GetArtistImages.
+type ArtistImagesResponse struct {
+ // Images is the list of artist images.
+ Images []ImageInfo `json:"images"`
+}
+
+// TopSongsRequest is the request for GetArtistTopSongs.
+type TopSongsRequest struct {
+ // ID is the internal Navidrome artist ID.
+ ID string `json:"id"`
+ // Name is the artist name.
+ Name string `json:"name"`
+ // MBID is the MusicBrainz ID for the artist (if known).
+ MBID string `json:"mbid,omitempty"`
+ // Count is the maximum number of top songs to return.
+ Count int32 `json:"count"`
+}
+
+// SongRef is a reference to a song with metadata for matching.
+type SongRef struct {
+ // ID is the internal Navidrome mediafile ID (if known).
+ ID string `json:"id,omitempty"`
+ // Name is the song name.
+ Name string `json:"name"`
+ // MBID is the MusicBrainz ID for the song.
+ MBID string `json:"mbid,omitempty"`
+ // ISRC is the International Standard Recording Code for the song.
+ ISRC string `json:"isrc,omitempty"`
+ // Artist is the artist name.
+ Artist string `json:"artist,omitempty"`
+ // ArtistMBID is the MusicBrainz artist ID.
+ ArtistMBID string `json:"artistMbid,omitempty"`
+ // Album is the album name.
+ Album string `json:"album,omitempty"`
+ // AlbumMBID is the MusicBrainz release ID.
+ AlbumMBID string `json:"albumMbid,omitempty"`
+ // Duration is the song duration in seconds.
+ Duration float32 `json:"duration,omitempty"`
+}
+
+// TopSongsResponse is the response for GetArtistTopSongs.
+type TopSongsResponse struct {
+ // Songs is the list of top songs.
+ Songs []SongRef `json:"songs"`
+}
+
+// AlbumRequest is the common request for album-related functions.
+type AlbumRequest struct {
+ // Name is the album name.
+ Name string `json:"name"`
+ // Artist is the album artist name.
+ Artist string `json:"artist"`
+ // MBID is the MusicBrainz ID for the album (if known).
+ MBID string `json:"mbid,omitempty"`
+}
+
+// AlbumInfoResponse is the response for GetAlbumInfo.
+type AlbumInfoResponse struct {
+ // Name is the album name.
+ Name string `json:"name"`
+ // MBID is the MusicBrainz ID for the album.
+ MBID string `json:"mbid"`
+ // Description is the album description/notes.
+ Description string `json:"description"`
+ // URL is the external URL for the album.
+ URL string `json:"url"`
+}
+
+// AlbumImagesResponse is the response for GetAlbumImages.
+type AlbumImagesResponse struct {
+ // Images is the list of album images.
+ Images []ImageInfo `json:"images"`
+}
+
+// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
+type SimilarSongsByTrackRequest struct {
+ // ID is the internal Navidrome mediafile ID.
+ ID string `json:"id"`
+ // Name is the track title.
+ Name string `json:"name"`
+ // Artist is the artist name.
+ Artist string `json:"artist"`
+ // MBID is the MusicBrainz recording ID (if known).
+ MBID string `json:"mbid,omitempty"`
+ // Count is the maximum number of similar songs to return.
+ Count int32 `json:"count"`
+}
+
+// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
+type SimilarSongsByAlbumRequest struct {
+ // ID is the internal Navidrome album ID.
+ ID string `json:"id"`
+ // Name is the album name.
+ Name string `json:"name"`
+ // Artist is the album artist name.
+ Artist string `json:"artist"`
+ // MBID is the MusicBrainz release ID (if known).
+ MBID string `json:"mbid,omitempty"`
+ // Count is the maximum number of similar songs to return.
+ Count int32 `json:"count"`
+}
+
+// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
+type SimilarSongsByArtistRequest struct {
+ // ID is the internal Navidrome artist ID.
+ ID string `json:"id"`
+ // Name is the artist name.
+ Name string `json:"name"`
+ // MBID is the MusicBrainz artist ID (if known).
+ MBID string `json:"mbid,omitempty"`
+ // Count is the maximum number of similar songs to return.
+ Count int32 `json:"count"`
+}
+
+// SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
+type SimilarSongsResponse struct {
+ // Songs is the list of similar songs.
+ Songs []SongRef `json:"songs"`
+}
diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml
new file mode 100644
index 000000000..4940a5056
--- /dev/null
+++ b/plugins/capabilities/metadata_agent.yaml
@@ -0,0 +1,396 @@
+version: v1-draft
+exports:
+ nd_get_artist_mbid:
+ description: GetArtistMBID retrieves the MusicBrainz ID for an artist.
+ input:
+ $ref: '#/components/schemas/ArtistMBIDRequest'
+ contentType: application/json
+ output:
+ $ref: '#/components/schemas/ArtistMBIDResponse'
+ contentType: application/json
+ nd_get_artist_url:
+ description: GetArtistURL retrieves the external URL for an artist.
+ input:
+ $ref: '#/components/schemas/ArtistRequest'
+ contentType: application/json
+ output:
+ $ref: '#/components/schemas/ArtistURLResponse'
+ contentType: application/json
+ nd_get_artist_biography:
+ description: GetArtistBiography retrieves the biography for an artist.
+ input:
+ $ref: '#/components/schemas/ArtistRequest'
+ contentType: application/json
+ output:
+ $ref: '#/components/schemas/ArtistBiographyResponse'
+ contentType: application/json
+ nd_get_similar_artists:
+ description: GetSimilarArtists retrieves similar artists for a given artist.
+ input:
+ $ref: '#/components/schemas/SimilarArtistsRequest'
+ contentType: application/json
+ output:
+ $ref: '#/components/schemas/SimilarArtistsResponse'
+ contentType: application/json
+ nd_get_artist_images:
+ description: GetArtistImages retrieves images for an artist.
+ input:
+ $ref: '#/components/schemas/ArtistRequest'
+ contentType: application/json
+ output:
+ $ref: '#/components/schemas/ArtistImagesResponse'
+ contentType: application/json
+ nd_get_artist_top_songs:
+ description: GetArtistTopSongs retrieves top songs for an artist.
+ input:
+ $ref: '#/components/schemas/TopSongsRequest'
+ contentType: application/json
+ output:
+ $ref: '#/components/schemas/TopSongsResponse'
+ contentType: application/json
+ nd_get_album_info:
+ description: GetAlbumInfo retrieves album information.
+ input:
+ $ref: '#/components/schemas/AlbumRequest'
+ contentType: application/json
+ output:
+ $ref: '#/components/schemas/AlbumInfoResponse'
+ contentType: application/json
+ nd_get_album_images:
+ description: GetAlbumImages retrieves images for an album.
+ input:
+ $ref: '#/components/schemas/AlbumRequest'
+ contentType: application/json
+ output:
+ $ref: '#/components/schemas/AlbumImagesResponse'
+ contentType: application/json
+ nd_get_similar_songs_by_track:
+ description: GetSimilarSongsByTrack retrieves songs similar to a specific track.
+ input:
+ $ref: '#/components/schemas/SimilarSongsByTrackRequest'
+ contentType: application/json
+ output:
+ $ref: '#/components/schemas/SimilarSongsResponse'
+ contentType: application/json
+ nd_get_similar_songs_by_album:
+ description: GetSimilarSongsByAlbum retrieves songs similar to tracks on an album.
+ input:
+ $ref: '#/components/schemas/SimilarSongsByAlbumRequest'
+ contentType: application/json
+ output:
+ $ref: '#/components/schemas/SimilarSongsResponse'
+ contentType: application/json
+ nd_get_similar_songs_by_artist:
+ description: GetSimilarSongsByArtist retrieves songs similar to an artist's catalog.
+ input:
+ $ref: '#/components/schemas/SimilarSongsByArtistRequest'
+ contentType: application/json
+ output:
+ $ref: '#/components/schemas/SimilarSongsResponse'
+ contentType: application/json
+components:
+ schemas:
+ AlbumImagesResponse:
+ description: AlbumImagesResponse is the response for GetAlbumImages.
+ properties:
+ images:
+ type: array
+ description: Images is the list of album images.
+ items:
+ $ref: '#/components/schemas/ImageInfo'
+ required:
+ - images
+ AlbumInfoResponse:
+ description: AlbumInfoResponse is the response for GetAlbumInfo.
+ properties:
+ name:
+ type: string
+ description: Name is the album name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the album.
+ description:
+ type: string
+ description: Description is the album description/notes.
+ url:
+ type: string
+ description: URL is the external URL for the album.
+ required:
+ - name
+ - mbid
+ - description
+ - url
+ AlbumRequest:
+ description: AlbumRequest is the common request for album-related functions.
+ properties:
+ name:
+ type: string
+ description: Name is the album name.
+ artist:
+ type: string
+ description: Artist is the album artist name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the album (if known).
+ required:
+ - name
+ - artist
+ ArtistBiographyResponse:
+ description: ArtistBiographyResponse is the response for GetArtistBiography.
+ properties:
+ biography:
+ type: string
+ description: Biography is the artist biography text.
+ required:
+ - biography
+ ArtistImagesResponse:
+ description: ArtistImagesResponse is the response for GetArtistImages.
+ properties:
+ images:
+ type: array
+ description: Images is the list of artist images.
+ items:
+ $ref: '#/components/schemas/ImageInfo'
+ required:
+ - images
+ ArtistMBIDRequest:
+ description: ArtistMBIDRequest is the request for GetArtistMBID.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome artist ID.
+ name:
+ type: string
+ description: Name is the artist name.
+ required:
+ - id
+ - name
+ ArtistMBIDResponse:
+ description: ArtistMBIDResponse is the response for GetArtistMBID.
+ properties:
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the artist.
+ required:
+ - mbid
+ ArtistRef:
+ description: ArtistRef is a reference to an artist with name and optional MBID.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome artist ID (if known).
+ name:
+ type: string
+ description: Name is the artist name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the artist.
+ required:
+ - name
+ ArtistRequest:
+ description: ArtistRequest is the common request for artist-related functions.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome artist ID.
+ name:
+ type: string
+ description: Name is the artist name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the artist (if known).
+ required:
+ - id
+ - name
+ ArtistURLResponse:
+ description: ArtistURLResponse is the response for GetArtistURL.
+ properties:
+ url:
+ type: string
+ description: URL is the external URL for the artist.
+ required:
+ - url
+ ImageInfo:
+ description: ImageInfo represents an image with URL and size.
+ properties:
+ url:
+ type: string
+ description: URL is the URL of the image.
+ size:
+ type: integer
+ format: int32
+ description: Size is the size of the image in pixels (width or height).
+ required:
+ - url
+ - size
+ SimilarArtistsRequest:
+ description: SimilarArtistsRequest is the request for GetSimilarArtists.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome artist ID.
+ name:
+ type: string
+ description: Name is the artist name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the artist (if known).
+ limit:
+ type: integer
+ format: int32
+ description: Limit is the maximum number of similar artists to return.
+ required:
+ - id
+ - name
+ - limit
+ SimilarArtistsResponse:
+ description: SimilarArtistsResponse is the response for GetSimilarArtists.
+ properties:
+ artists:
+ type: array
+ description: Artists is the list of similar artists.
+ items:
+ $ref: '#/components/schemas/ArtistRef'
+ required:
+ - artists
+ SimilarSongsByAlbumRequest:
+ description: SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome album ID.
+ name:
+ type: string
+ description: Name is the album name.
+ artist:
+ type: string
+ description: Artist is the album artist name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz release ID (if known).
+ count:
+ type: integer
+ format: int32
+ description: Count is the maximum number of similar songs to return.
+ required:
+ - id
+ - name
+ - artist
+ - count
+ SimilarSongsByArtistRequest:
+ description: SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome artist ID.
+ name:
+ type: string
+ description: Name is the artist name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz artist ID (if known).
+ count:
+ type: integer
+ format: int32
+ description: Count is the maximum number of similar songs to return.
+ required:
+ - id
+ - name
+ - count
+ SimilarSongsByTrackRequest:
+ description: SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome mediafile ID.
+ name:
+ type: string
+ description: Name is the track title.
+ artist:
+ type: string
+ description: Artist is the artist name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz recording ID (if known).
+ count:
+ type: integer
+ format: int32
+ description: Count is the maximum number of similar songs to return.
+ required:
+ - id
+ - name
+ - artist
+ - count
+ SimilarSongsResponse:
+ description: SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
+ properties:
+ songs:
+ type: array
+ description: Songs is the list of similar songs.
+ items:
+ $ref: '#/components/schemas/SongRef'
+ required:
+ - songs
+ SongRef:
+ description: SongRef is a reference to a song with metadata for matching.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome mediafile ID (if known).
+ name:
+ type: string
+ description: Name is the song name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the song.
+ isrc:
+ type: string
+ description: ISRC is the International Standard Recording Code for the song.
+ artist:
+ type: string
+ description: Artist is the artist name.
+ artistMbid:
+ type: string
+ description: ArtistMBID is the MusicBrainz artist ID.
+ album:
+ type: string
+ description: Album is the album name.
+ albumMbid:
+ type: string
+ description: AlbumMBID is the MusicBrainz release ID.
+ duration:
+ type: number
+ format: float
+ description: Duration is the song duration in seconds.
+ required:
+ - name
+ TopSongsRequest:
+ description: TopSongsRequest is the request for GetArtistTopSongs.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome artist ID.
+ name:
+ type: string
+ description: Name is the artist name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the artist (if known).
+ count:
+ type: integer
+ format: int32
+ description: Count is the maximum number of top songs to return.
+ required:
+ - id
+ - name
+ - count
+ TopSongsResponse:
+ description: TopSongsResponse is the response for GetArtistTopSongs.
+ properties:
+ songs:
+ type: array
+ description: Songs is the list of top songs.
+ items:
+ $ref: '#/components/schemas/SongRef'
+ required:
+ - songs
diff --git a/plugins/capabilities/scheduler_callback.go b/plugins/capabilities/scheduler_callback.go
new file mode 100644
index 000000000..93f66f10d
--- /dev/null
+++ b/plugins/capabilities/scheduler_callback.go
@@ -0,0 +1,27 @@
+package capabilities
+
+// SchedulerCallback provides scheduled task handling.
+// This capability allows plugins to receive callbacks when their scheduled tasks execute.
+// Plugins that use the scheduler host service must implement this capability
+// to handle task execution.
+//
+//nd:capability name=scheduler
+type SchedulerCallback interface {
+ // OnCallback is called when a scheduled task fires.
+ // Errors are logged but do not affect the scheduling system.
+ //nd:export name=nd_scheduler_callback
+ OnCallback(SchedulerCallbackRequest) error
+}
+
+// SchedulerCallbackRequest is the request provided when a scheduled task fires.
+type SchedulerCallbackRequest struct {
+ // ScheduleID is the unique identifier for this scheduled task.
+ // This is either the ID provided when scheduling, or an auto-generated UUID if none was specified.
+ ScheduleID string `json:"scheduleId"`
+ // Payload is the payload data that was provided when the task was scheduled.
+ // Can be used to pass context or parameters to the callback handler.
+ Payload string `json:"payload"`
+ // IsRecurring is true if this is a recurring schedule (created via ScheduleRecurring),
+ // false if it's a one-time schedule (created via ScheduleOneTime).
+ IsRecurring bool `json:"isRecurring"`
+}
diff --git a/plugins/capabilities/scheduler_callback.yaml b/plugins/capabilities/scheduler_callback.yaml
new file mode 100644
index 000000000..9a081cd08
--- /dev/null
+++ b/plugins/capabilities/scheduler_callback.yaml
@@ -0,0 +1,33 @@
+version: v1-draft
+exports:
+ nd_scheduler_callback:
+ description: |-
+ OnCallback is called when a scheduled task fires.
+ Errors are logged but do not affect the scheduling system.
+ input:
+ $ref: '#/components/schemas/SchedulerCallbackRequest'
+ contentType: application/json
+components:
+ schemas:
+ SchedulerCallbackRequest:
+ description: SchedulerCallbackRequest is the request provided when a scheduled task fires.
+ properties:
+ scheduleId:
+ type: string
+ description: |-
+ ScheduleID is the unique identifier for this scheduled task.
+ This is either the ID provided when scheduling, or an auto-generated UUID if none was specified.
+ payload:
+ type: string
+ description: |-
+ Payload is the payload data that was provided when the task was scheduled.
+ Can be used to pass context or parameters to the callback handler.
+ isRecurring:
+ type: boolean
+ description: |-
+ IsRecurring is true if this is a recurring schedule (created via ScheduleRecurring),
+ false if it's a one-time schedule (created via ScheduleOneTime).
+ required:
+ - scheduleId
+ - payload
+ - isRecurring
diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go
new file mode 100644
index 000000000..4918d5e8f
--- /dev/null
+++ b/plugins/capabilities/scrobbler.go
@@ -0,0 +1,136 @@
+package capabilities
+
+// Scrobbler provides scrobbling functionality to external services.
+// This capability allows plugins to submit listening history to services like Last.fm,
+// ListenBrainz, or custom scrobbling backends.
+//
+// All methods are required - plugins implementing this capability must provide
+// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport.
+//
+//nd:capability name=scrobbler required=true
+type Scrobbler interface {
+ // IsAuthorized checks if a user is authorized to scrobble to this service.
+ //nd:export name=nd_scrobbler_is_authorized
+ IsAuthorized(IsAuthorizedRequest) (bool, error)
+
+ // NowPlaying sends a now playing notification to the scrobbling service.
+ //nd:export name=nd_scrobbler_now_playing
+ NowPlaying(NowPlayingRequest) error
+
+ // Scrobble submits a completed scrobble to the scrobbling service.
+ //nd:export name=nd_scrobbler_scrobble
+ Scrobble(ScrobbleRequest) error
+
+ // PlaybackReport sends a playback state report to the scrobbling service.
+ //nd:export name=nd_scrobbler_playback_report
+ PlaybackReport(PlaybackReportRequest) error
+}
+
+// IsAuthorizedRequest is the request for authorization check.
+type IsAuthorizedRequest struct {
+ // Username is the username of the user.
+ Username string `json:"username"`
+}
+
+// ArtistRef is a reference to an artist with name and optional MBID.
+type ArtistRef struct {
+ // ID is the internal Navidrome artist ID (if known).
+ ID string `json:"id,omitempty"`
+ // Name is the artist name.
+ Name string `json:"name"`
+ // MBID is the MusicBrainz ID for the artist.
+ MBID string `json:"mbid,omitempty"`
+}
+
+// TrackInfo contains track metadata.
+type TrackInfo struct {
+ // ID is the internal Navidrome track ID.
+ ID string `json:"id"`
+ // Title is the track title.
+ Title string `json:"title"`
+ // Album is the album name.
+ Album string `json:"album"`
+ // Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
+ Artist string `json:"artist"`
+ // AlbumArtist is the formatted album artist name for display.
+ AlbumArtist string `json:"albumArtist"`
+ // Artists is the list of track artists.
+ Artists []ArtistRef `json:"artists"`
+ // AlbumArtists is the list of album artists.
+ AlbumArtists []ArtistRef `json:"albumArtists"`
+ // Duration is the track duration in seconds.
+ Duration float32 `json:"duration"`
+ // TrackNumber is the track number on the album.
+ TrackNumber int32 `json:"trackNumber"`
+ // DiscNumber is the disc number.
+ DiscNumber int32 `json:"discNumber"`
+ // MBZRecordingID is the MusicBrainz recording ID.
+ MBZRecordingID string `json:"mbzRecordingId,omitempty"`
+ // MBZAlbumID is the MusicBrainz album/release ID.
+ MBZAlbumID string `json:"mbzAlbumId,omitempty"`
+ // MBZReleaseGroupID is the MusicBrainz release group ID.
+ MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
+ // MBZReleaseTrackID is the MusicBrainz release track ID.
+ MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
+ // LibraryID is the ID of the library the track belongs to.
+ // Only included if the plugin has library permission with filesystem access for the track's library.
+ LibraryID int32 `json:"libraryId,omitempty"`
+ // Path is the full path to the track file, relative to the library root.
+ // Only included if the plugin has library permission with filesystem access for the track's library.
+ Path string `json:"path,omitempty"`
+}
+
+// NowPlayingRequest is the request for now playing notification.
+type NowPlayingRequest struct {
+ // Username is the username of the user.
+ Username string `json:"username"`
+ // Track is the track currently playing.
+ Track TrackInfo `json:"track"`
+ // Position is the current playback position in seconds.
+ Position int32 `json:"position"`
+}
+
+// ScrobbleRequest is the request for submitting a scrobble.
+type ScrobbleRequest struct {
+ // Username is the username of the user.
+ Username string `json:"username"`
+ // Track is the track that was played.
+ Track TrackInfo `json:"track"`
+ // Timestamp is the Unix timestamp when the track started playing.
+ Timestamp int64 `json:"timestamp"`
+}
+
+// PlaybackReportRequest is the request for playback report notifications.
+type PlaybackReportRequest struct {
+ // Username is the username of the user.
+ Username string `json:"username"`
+ // Track is the track being played.
+ Track TrackInfo `json:"track"`
+ // State is the current playback state (starting/playing/paused/stopped/expired).
+ State string `json:"state"`
+ // PositionMs is the current playback position in milliseconds.
+ PositionMs int64 `json:"positionMs"`
+ // PlaybackRate is the playback speed (1.0 = normal).
+ PlaybackRate float64 `json:"playbackRate"`
+ // PlayerId is the unique client identifier.
+ PlayerId string `json:"playerId"`
+ // PlayerName is the human-readable player name.
+ PlayerName string `json:"playerName"`
+ // Timestamp is the Unix timestamp when this report was generated.
+ Timestamp int64 `json:"timestamp"`
+}
+
+// ScrobblerError represents an error type for scrobbling operations.
+type ScrobblerError string
+
+const (
+ // ScrobblerErrorNotAuthorized indicates the user is not authorized.
+ ScrobblerErrorNotAuthorized ScrobblerError = "scrobbler(not_authorized)"
+ // ScrobblerErrorRetryLater indicates the operation should be retried later.
+ ScrobblerErrorRetryLater ScrobblerError = "scrobbler(retry_later)"
+ // ScrobblerErrorUnrecoverable indicates an unrecoverable error.
+ ScrobblerErrorUnrecoverable ScrobblerError = "scrobbler(unrecoverable)"
+)
+
+// Error implements the error interface for ScrobblerError.
+func (e ScrobblerError) Error() string { return string(e) }
diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml
new file mode 100644
index 000000000..9d5cfed30
--- /dev/null
+++ b/plugins/capabilities/scrobbler.yaml
@@ -0,0 +1,196 @@
+version: v1-draft
+exports:
+ nd_scrobbler_is_authorized:
+ description: IsAuthorized checks if a user is authorized to scrobble to this service.
+ input:
+ $ref: '#/components/schemas/IsAuthorizedRequest'
+ contentType: application/json
+ output:
+ type: boolean
+ contentType: application/json
+ nd_scrobbler_now_playing:
+ description: NowPlaying sends a now playing notification to the scrobbling service.
+ input:
+ $ref: '#/components/schemas/NowPlayingRequest'
+ contentType: application/json
+ nd_scrobbler_scrobble:
+ description: Scrobble submits a completed scrobble to the scrobbling service.
+ input:
+ $ref: '#/components/schemas/ScrobbleRequest'
+ contentType: application/json
+ nd_scrobbler_playback_report:
+ description: PlaybackReport sends a playback state report to the scrobbling service.
+ input:
+ $ref: '#/components/schemas/PlaybackReportRequest'
+ contentType: application/json
+components:
+ schemas:
+ ArtistRef:
+ description: ArtistRef is a reference to an artist with name and optional MBID.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome artist ID (if known).
+ name:
+ type: string
+ description: Name is the artist name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the artist.
+ required:
+ - name
+ IsAuthorizedRequest:
+ description: IsAuthorizedRequest is the request for authorization check.
+ properties:
+ username:
+ type: string
+ description: Username is the username of the user.
+ required:
+ - username
+ NowPlayingRequest:
+ description: NowPlayingRequest is the request for now playing notification.
+ properties:
+ username:
+ type: string
+ description: Username is the username of the user.
+ track:
+ $ref: '#/components/schemas/TrackInfo'
+ description: Track is the track currently playing.
+ position:
+ type: integer
+ format: int32
+ description: Position is the current playback position in seconds.
+ required:
+ - username
+ - track
+ - position
+ PlaybackReportRequest:
+ description: PlaybackReportRequest is the request for playback report notifications.
+ properties:
+ username:
+ type: string
+ description: Username is the username of the user.
+ track:
+ $ref: '#/components/schemas/TrackInfo'
+ description: Track is the track being played.
+ state:
+ type: string
+ description: State is the current playback state (starting/playing/paused/stopped/expired).
+ positionMs:
+ type: integer
+ format: int64
+ description: PositionMs is the current playback position in milliseconds.
+ playbackRate:
+ type: number
+ format: float
+ description: PlaybackRate is the playback speed (1.0 = normal).
+ playerId:
+ type: string
+ description: PlayerId is the unique client identifier.
+ playerName:
+ type: string
+ description: PlayerName is the human-readable player name.
+ timestamp:
+ type: integer
+ format: int64
+ description: Timestamp is the Unix timestamp when this report was generated.
+ required:
+ - username
+ - track
+ - state
+ - positionMs
+ - playbackRate
+ - playerId
+ - playerName
+ - timestamp
+ ScrobbleRequest:
+ description: ScrobbleRequest is the request for submitting a scrobble.
+ properties:
+ username:
+ type: string
+ description: Username is the username of the user.
+ track:
+ $ref: '#/components/schemas/TrackInfo'
+ description: Track is the track that was played.
+ timestamp:
+ type: integer
+ format: int64
+ description: Timestamp is the Unix timestamp when the track started playing.
+ required:
+ - username
+ - track
+ - timestamp
+ TrackInfo:
+ description: TrackInfo contains track metadata.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome track ID.
+ title:
+ type: string
+ description: Title is the track title.
+ album:
+ type: string
+ description: Album is the album name.
+ artist:
+ type: string
+ description: Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
+ albumArtist:
+ type: string
+ description: AlbumArtist is the formatted album artist name for display.
+ artists:
+ type: array
+ description: Artists is the list of track artists.
+ items:
+ $ref: '#/components/schemas/ArtistRef'
+ albumArtists:
+ type: array
+ description: AlbumArtists is the list of album artists.
+ items:
+ $ref: '#/components/schemas/ArtistRef'
+ duration:
+ type: number
+ format: float
+ description: Duration is the track duration in seconds.
+ trackNumber:
+ type: integer
+ format: int32
+ description: TrackNumber is the track number on the album.
+ discNumber:
+ type: integer
+ format: int32
+ description: DiscNumber is the disc number.
+ mbzRecordingId:
+ type: string
+ description: MBZRecordingID is the MusicBrainz recording ID.
+ mbzAlbumId:
+ type: string
+ description: MBZAlbumID is the MusicBrainz album/release ID.
+ mbzReleaseGroupId:
+ type: string
+ description: MBZReleaseGroupID is the MusicBrainz release group ID.
+ mbzReleaseTrackId:
+ type: string
+ description: MBZReleaseTrackID is the MusicBrainz release track ID.
+ libraryId:
+ type: integer
+ format: int32
+ description: |-
+ LibraryID is the ID of the library the track belongs to.
+ Only included if the plugin has library permission with filesystem access for the track's library.
+ path:
+ type: string
+ description: |-
+ Path is the full path to the track file, relative to the library root.
+ Only included if the plugin has library permission with filesystem access for the track's library.
+ required:
+ - id
+ - title
+ - album
+ - artist
+ - albumArtist
+ - artists
+ - albumArtists
+ - duration
+ - trackNumber
+ - discNumber
diff --git a/plugins/capabilities/sonic_similarity.go b/plugins/capabilities/sonic_similarity.go
new file mode 100644
index 000000000..aadb9396e
--- /dev/null
+++ b/plugins/capabilities/sonic_similarity.go
@@ -0,0 +1,32 @@
+package capabilities
+
+// SonicSimilarity provides audio-similarity based track discovery.
+//
+//nd:capability name=sonicsimilarity required=true
+type SonicSimilarity interface {
+ //nd:export name=nd_get_sonic_similar_tracks
+ GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error)
+
+ //nd:export name=nd_find_sonic_path
+ FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error)
+}
+
+type GetSonicSimilarTracksRequest struct {
+ Song SongRef `json:"song"`
+ Count int32 `json:"count"`
+}
+
+type FindSonicPathRequest struct {
+ StartSong SongRef `json:"startSong"`
+ EndSong SongRef `json:"endSong"`
+ Count int32 `json:"count"`
+}
+
+type SonicSimilarityResponse struct {
+ Matches []SonicMatch `json:"matches"`
+}
+
+type SonicMatch struct {
+ Song SongRef `json:"song"`
+ Similarity float64 `json:"similarity"`
+}
diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml
new file mode 100644
index 000000000..cba97d9b0
--- /dev/null
+++ b/plugins/capabilities/sonic_similarity.yaml
@@ -0,0 +1,92 @@
+version: v1-draft
+exports:
+ nd_get_sonic_similar_tracks:
+ input:
+ $ref: '#/components/schemas/GetSonicSimilarTracksRequest'
+ contentType: application/json
+ output:
+ $ref: '#/components/schemas/SonicSimilarityResponse'
+ contentType: application/json
+ nd_find_sonic_path:
+ input:
+ $ref: '#/components/schemas/FindSonicPathRequest'
+ contentType: application/json
+ output:
+ $ref: '#/components/schemas/SonicSimilarityResponse'
+ contentType: application/json
+components:
+ schemas:
+ FindSonicPathRequest:
+ properties:
+ startSong:
+ $ref: '#/components/schemas/SongRef'
+ endSong:
+ $ref: '#/components/schemas/SongRef'
+ count:
+ type: integer
+ format: int32
+ required:
+ - startSong
+ - endSong
+ - count
+ GetSonicSimilarTracksRequest:
+ properties:
+ song:
+ $ref: '#/components/schemas/SongRef'
+ count:
+ type: integer
+ format: int32
+ required:
+ - song
+ - count
+ SongRef:
+ description: SongRef is a reference to a song with metadata for matching.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome mediafile ID (if known).
+ name:
+ type: string
+ description: Name is the song name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the song.
+ isrc:
+ type: string
+ description: ISRC is the International Standard Recording Code for the song.
+ artist:
+ type: string
+ description: Artist is the artist name.
+ artistMbid:
+ type: string
+ description: ArtistMBID is the MusicBrainz artist ID.
+ album:
+ type: string
+ description: Album is the album name.
+ albumMbid:
+ type: string
+ description: AlbumMBID is the MusicBrainz release ID.
+ duration:
+ type: number
+ format: float
+ description: Duration is the song duration in seconds.
+ required:
+ - name
+ SonicMatch:
+ properties:
+ song:
+ $ref: '#/components/schemas/SongRef'
+ similarity:
+ type: number
+ format: float
+ required:
+ - song
+ - similarity
+ SonicSimilarityResponse:
+ properties:
+ matches:
+ type: array
+ items:
+ $ref: '#/components/schemas/SonicMatch'
+ required:
+ - matches
diff --git a/plugins/capabilities/taskworker.go b/plugins/capabilities/taskworker.go
new file mode 100644
index 000000000..c53d50174
--- /dev/null
+++ b/plugins/capabilities/taskworker.go
@@ -0,0 +1,27 @@
+package capabilities
+
+// TaskWorker provides task execution handling.
+// This capability allows plugins to receive callbacks when their queued tasks
+// are ready to execute. Plugins that use the taskqueue host service must
+// implement this capability.
+//
+//nd:capability name=taskworker
+type TaskWorker interface {
+ // OnTaskExecute is called when a queued task is ready to run.
+ // The returned string is a status/result message stored in the tasks table.
+ // Return an error to trigger retry (if retries are configured).
+ //nd:export name=nd_task_execute
+ OnTaskExecute(TaskExecuteRequest) (string, error)
+}
+
+// TaskExecuteRequest is the request provided when a task is ready to execute.
+type TaskExecuteRequest struct {
+ // QueueName is the name of the queue this task belongs to.
+ QueueName string `json:"queueName"`
+ // TaskID is the unique identifier for this task.
+ TaskID string `json:"taskId"`
+ // Payload is the opaque data provided when the task was enqueued.
+ Payload []byte `json:"payload"`
+ // Attempt is the current attempt number (1-based: first attempt = 1).
+ Attempt int32 `json:"attempt"`
+}
diff --git a/plugins/capabilities/taskworker.yaml b/plugins/capabilities/taskworker.yaml
new file mode 100644
index 000000000..7aa7126e0
--- /dev/null
+++ b/plugins/capabilities/taskworker.yaml
@@ -0,0 +1,37 @@
+version: v1-draft
+exports:
+ nd_task_execute:
+ description: |-
+ OnTaskExecute is called when a queued task is ready to run.
+ The returned string is a status/result message stored in the tasks table.
+ Return an error to trigger retry (if retries are configured).
+ input:
+ $ref: '#/components/schemas/TaskExecuteRequest'
+ contentType: application/json
+ output:
+ type: string
+ contentType: application/json
+components:
+ schemas:
+ TaskExecuteRequest:
+ description: TaskExecuteRequest is the request provided when a task is ready to execute.
+ properties:
+ queueName:
+ type: string
+ description: QueueName is the name of the queue this task belongs to.
+ taskId:
+ type: string
+ description: TaskID is the unique identifier for this task.
+ payload:
+ type: string
+ format: byte
+ description: Payload is the opaque data provided when the task was enqueued.
+ attempt:
+ type: integer
+ format: int32
+ description: 'Attempt is the current attempt number (1-based: first attempt = 1).'
+ required:
+ - queueName
+ - taskId
+ - payload
+ - attempt
diff --git a/plugins/capabilities/websocket_callback.go b/plugins/capabilities/websocket_callback.go
new file mode 100644
index 000000000..ddfc0fc95
--- /dev/null
+++ b/plugins/capabilities/websocket_callback.go
@@ -0,0 +1,61 @@
+package capabilities
+
+// WebSocketCallback provides WebSocket message handling.
+// This capability allows plugins to receive callbacks for WebSocket events
+// such as text messages, binary messages, errors, and connection closures.
+// Plugins that use the WebSocket host service must implement this capability
+// to handle incoming events.
+//
+//nd:capability name=websocket
+type WebSocketCallback interface {
+ // OnTextMessage is called when a text message is received on a WebSocket connection.
+ //nd:export name=nd_websocket_on_text_message
+ OnTextMessage(OnTextMessageRequest) error
+
+ // OnBinaryMessage is called when a binary message is received on a WebSocket connection.
+ //nd:export name=nd_websocket_on_binary_message
+ OnBinaryMessage(OnBinaryMessageRequest) error
+
+ // OnError is called when an error occurs on a WebSocket connection.
+ //nd:export name=nd_websocket_on_error
+ OnError(OnErrorRequest) error
+
+ // OnClose is called when a WebSocket connection is closed.
+ //nd:export name=nd_websocket_on_close
+ OnClose(OnCloseRequest) error
+}
+
+// OnTextMessageRequest is the request provided when a text message is received.
+type OnTextMessageRequest struct {
+ // ConnectionID is the unique identifier for the WebSocket connection that received the message.
+ ConnectionID string `json:"connectionId"`
+ // Message is the text message content received from the WebSocket.
+ Message string `json:"message"`
+}
+
+// OnBinaryMessageRequest is the request provided when a binary message is received.
+type OnBinaryMessageRequest struct {
+ // ConnectionID is the unique identifier for the WebSocket connection that received the message.
+ ConnectionID string `json:"connectionId"`
+ // Data is the binary data received from the WebSocket, encoded as base64.
+ Data []byte `json:"data"`
+}
+
+// OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
+type OnErrorRequest struct {
+ // ConnectionID is the unique identifier for the WebSocket connection where the error occurred.
+ ConnectionID string `json:"connectionId"`
+ // Error is the error message describing what went wrong.
+ Error string `json:"error"`
+}
+
+// OnCloseRequest is the request provided when a WebSocket connection is closed.
+type OnCloseRequest struct {
+ // ConnectionID is the unique identifier for the WebSocket connection that was closed.
+ ConnectionID string `json:"connectionId"`
+ // Code is the WebSocket close status code (e.g., 1000 for normal closure,
+ // 1001 for going away, 1006 for abnormal closure).
+ Code int32 `json:"code"`
+ // Reason is the human-readable reason for the connection closure, if provided.
+ Reason string `json:"reason"`
+}
diff --git a/plugins/capabilities/websocket_callback.yaml b/plugins/capabilities/websocket_callback.yaml
new file mode 100644
index 000000000..6cd0cff9f
--- /dev/null
+++ b/plugins/capabilities/websocket_callback.yaml
@@ -0,0 +1,80 @@
+version: v1-draft
+exports:
+ nd_websocket_on_text_message:
+ description: OnTextMessage is called when a text message is received on a WebSocket connection.
+ input:
+ $ref: '#/components/schemas/OnTextMessageRequest'
+ contentType: application/json
+ nd_websocket_on_binary_message:
+ description: OnBinaryMessage is called when a binary message is received on a WebSocket connection.
+ input:
+ $ref: '#/components/schemas/OnBinaryMessageRequest'
+ contentType: application/json
+ nd_websocket_on_error:
+ description: OnError is called when an error occurs on a WebSocket connection.
+ input:
+ $ref: '#/components/schemas/OnErrorRequest'
+ contentType: application/json
+ nd_websocket_on_close:
+ description: OnClose is called when a WebSocket connection is closed.
+ input:
+ $ref: '#/components/schemas/OnCloseRequest'
+ contentType: application/json
+components:
+ schemas:
+ OnBinaryMessageRequest:
+ description: OnBinaryMessageRequest is the request provided when a binary message is received.
+ properties:
+ connectionId:
+ type: string
+ description: ConnectionID is the unique identifier for the WebSocket connection that received the message.
+ data:
+ type: string
+ format: byte
+ description: Data is the binary data received from the WebSocket, encoded as base64.
+ required:
+ - connectionId
+ - data
+ OnCloseRequest:
+ description: OnCloseRequest is the request provided when a WebSocket connection is closed.
+ properties:
+ connectionId:
+ type: string
+ description: ConnectionID is the unique identifier for the WebSocket connection that was closed.
+ code:
+ type: integer
+ format: int32
+ description: |-
+ Code is the WebSocket close status code (e.g., 1000 for normal closure,
+ 1001 for going away, 1006 for abnormal closure).
+ reason:
+ type: string
+ description: Reason is the human-readable reason for the connection closure, if provided.
+ required:
+ - connectionId
+ - code
+ - reason
+ OnErrorRequest:
+ description: OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
+ properties:
+ connectionId:
+ type: string
+ description: ConnectionID is the unique identifier for the WebSocket connection where the error occurred.
+ error:
+ type: string
+ description: Error is the error message describing what went wrong.
+ required:
+ - connectionId
+ - error
+ OnTextMessageRequest:
+ description: OnTextMessageRequest is the request provided when a text message is received.
+ properties:
+ connectionId:
+ type: string
+ description: ConnectionID is the unique identifier for the WebSocket connection that received the message.
+ message:
+ type: string
+ description: Message is the text message content received from the WebSocket.
+ required:
+ - connectionId
+ - message
diff --git a/plugins/capabilities_test.go b/plugins/capabilities_test.go
new file mode 100644
index 000000000..35fc3910a
--- /dev/null
+++ b/plugins/capabilities_test.go
@@ -0,0 +1,81 @@
+package plugins
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// mockFunctionChecker implements functionExistsChecker for testing
+type mockFunctionChecker struct {
+ functions map[string]bool
+}
+
+func (m *mockFunctionChecker) FunctionExists(name string) bool {
+ return m.functions[name]
+}
+
+var _ = Describe("Capabilities", func() {
+ Describe("detectCapabilities", func() {
+ It("detects MetadataAgent capability when plugin exports artist biography function", func() {
+ checker := &mockFunctionChecker{
+ functions: map[string]bool{
+ FuncGetArtistBiography: true,
+ },
+ }
+
+ caps := detectCapabilities(checker)
+ Expect(caps).To(ContainElement(CapabilityMetadataAgent))
+ })
+
+ It("detects MetadataAgent capability when plugin exports multiple functions", func() {
+ checker := &mockFunctionChecker{
+ functions: map[string]bool{
+ FuncGetArtistMBID: true,
+ FuncGetArtistURL: true,
+ FuncGetAlbumInfo: true,
+ FuncGetAlbumImages: true,
+ },
+ }
+
+ caps := detectCapabilities(checker)
+ Expect(caps).To(ContainElement(CapabilityMetadataAgent))
+ Expect(caps).To(HaveLen(1)) // Should only have one MetadataAgent capability
+ })
+
+ It("returns empty slice when no capability functions are exported", func() {
+ checker := &mockFunctionChecker{
+ functions: map[string]bool{
+ "some_other_function": true,
+ },
+ }
+
+ caps := detectCapabilities(checker)
+ Expect(caps).To(BeEmpty())
+ })
+
+ It("returns empty slice when plugin exports no functions", func() {
+ checker := &mockFunctionChecker{
+ functions: map[string]bool{},
+ }
+
+ caps := detectCapabilities(checker)
+ Expect(caps).To(BeEmpty())
+ })
+ })
+
+ Describe("hasCapability", func() {
+ It("returns true when capability exists", func() {
+ caps := []Capability{CapabilityMetadataAgent}
+ Expect(hasCapability(caps, CapabilityMetadataAgent)).To(BeTrue())
+ })
+
+ It("returns false when capability does not exist", func() {
+ var caps []Capability
+ Expect(hasCapability(caps, CapabilityMetadataAgent)).To(BeFalse())
+ })
+
+ It("returns false when capabilities slice is nil", func() {
+ Expect(hasCapability(nil, CapabilityMetadataAgent)).To(BeFalse())
+ })
+ })
+})
diff --git a/plugins/capability_lifecycle.go b/plugins/capability_lifecycle.go
new file mode 100644
index 000000000..499e3916d
--- /dev/null
+++ b/plugins/capability_lifecycle.go
@@ -0,0 +1,38 @@
+package plugins
+
+import (
+ "context"
+
+ "github.com/navidrome/navidrome/log"
+)
+
+// CapabilityLifecycle indicates the plugin has lifecycle callback functions.
+// Detected when the plugin exports the nd_on_init function.
+const CapabilityLifecycle Capability = "Lifecycle"
+
+const FuncOnInit = "nd_on_init"
+
+func init() {
+ registerCapability(
+ CapabilityLifecycle,
+ FuncOnInit,
+ )
+}
+
+// callPluginInit calls the plugin's nd_on_init function if it has the Lifecycle capability.
+// This is called after the plugin is fully loaded with all services registered.
+func callPluginInit(ctx context.Context, instance *plugin) {
+ if !hasCapability(instance.capabilities, CapabilityLifecycle) {
+ return
+ }
+
+ log.Debug(ctx, "Calling plugin init function", "plugin", instance.name)
+
+ err := callPluginFunctionNoInput(ctx, instance, FuncOnInit)
+ if err != nil {
+ log.Error(ctx, "Plugin init function failed", "plugin", instance.name, err)
+ return
+ }
+
+ log.Debug(ctx, "Plugin init function completed", "plugin", instance.name)
+}
diff --git a/plugins/cmd/ndpgen/.gitignore b/plugins/cmd/ndpgen/.gitignore
new file mode 100644
index 000000000..315ccc05f
--- /dev/null
+++ b/plugins/cmd/ndpgen/.gitignore
@@ -0,0 +1 @@
+ndpgen
\ No newline at end of file
diff --git a/plugins/cmd/ndpgen/README.md b/plugins/cmd/ndpgen/README.md
new file mode 100644
index 000000000..d2f67a60c
--- /dev/null
+++ b/plugins/cmd/ndpgen/README.md
@@ -0,0 +1,198 @@
+# ndpgen
+
+Navidrome Plugin Development Kit (PDK) code generator. It reads Go interface definitions with special annotations and generates client wrappers for WASM plugins.
+
+This tool is the unified code generator that handle both host function wrappers and capability wrappers.
+
+## Usage
+
+```bash
+ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only] [-go] [-python] [-rust]
+```
+
+### Flags
+
+| Flag | Description | Default |
+|--------------|----------------------------------------------------------------|----------------------|
+| `-input` | Directory containing Go source files with annotated interfaces | Required |
+| `-output` | Directory where generated files will be written | Same as input |
+| `-package` | Package name for generated files | Inferred from output |
+| `-v` | Verbose output | `false` |
+| `-dry-run` | Parse and validate without writing files | `false` |
+| `-host-only` | Generate only host function wrappers (capability support TBD) | `true` |
+| `-go` | Generate Go client wrappers | `true`* |
+| `-python` | Generate Python client wrappers | `false` |
+| `-rust` | Generate Rust client wrappers | `false` |
+
+\* `-go` is enabled by default when neither `-python` nor `-rust` is specified. Use combinations like `-go -python -rust` to generate multiple languages.
+
+### Example
+
+```bash
+go run ./plugins/cmd/ndpgen \
+ -input ./plugins/host \
+ -output ./plugins/pdk
+```
+
+## Annotations
+
+### `//nd:hostservice`
+
+Marks an interface as a host service that will have wrappers generated.
+
+```go
+//nd:hostservice name= permission=
+type MyService interface { ... }
+```
+
+| Parameter | Description | Required |
+|--------------|-----------------------------------------------------------------|----------|
+| `name` | Service name used in generated type names and function prefixes | Yes |
+| `permission` | Permission required by plugins to use this service | Yes |
+
+### `//nd:hostfunc`
+
+Marks a method within a host service interface for export to plugins.
+
+```go
+//nd:hostfunc [name=]
+MethodName(ctx context.Context, ...) (result Type, err error)
+```
+
+| Parameter | Description | Required |
+|-----------|-------------------------------------------------------------------------|----------|
+| `name` | Custom export name (default: `_` in lowercase) | No |
+
+## Input Format
+
+Host service interfaces must follow these conventions:
+
+1. **First parameter must be `context.Context`** - Required for all methods
+2. **Last return value should be `error`** - For proper error handling
+3. **Annotations must be on consecutive lines** - No blank comment lines between doc and annotation
+
+### Example Interface
+
+```go
+package host
+
+import "context"
+
+// SubsonicAPIService provides access to Navidrome's Subsonic API.
+// This documentation becomes part of the generated code.
+//nd:hostservice name=SubsonicAPI permission=subsonicapi
+type SubsonicAPIService interface {
+ // Call executes a Subsonic API request and returns the response.
+ //nd:hostfunc
+ Call(ctx context.Context, uri string) (response string, err error)
+}
+```
+
+## Generated Output
+
+### Go Client Library (Go/TinyGo WASM)
+
+Generated files are named `nd_host_.go` (lowercase) and placed in `$output/go/host/`. The `$output/go/` directory becomes a complete Go module (`github.com/navidrome/navidrome/plugins/pdk/go`) with package name `host`, intended for import by Navidrome plugins built with TinyGo.
+
+The generator creates:
+- `nd_host_.go` - Client wrapper code (WASM build)
+- `nd_host__stub.go` - Mock implementations for non-WASM platforms (testing)
+- `doc.go` - Package documentation listing all available services
+- `go.mod` - Go module file with required dependencies
+
+Each service file includes:
+
+- `// Code generated by ndpgen. DO NOT EDIT.` header
+- Required imports (`encoding/json`, `errors`, `github.com/extism/go-pdk`)
+- `//go:wasmimport` declarations for each host function
+- Response struct types and any struct definitions from the service
+- Wrapper functions that handle memory allocation and JSON parsing
+
+### Testing Plugins with Mocks
+
+The stub files (`*_stub.go`) contain [testify/mock](https://github.com/stretchr/testify) implementations that allow plugin authors to unit test their code on non-WASM platforms.
+
+Each host service has:
+- A private mock struct embedding `mock.Mock`
+- An exported auto-instantiated mock instance (e.g., `host.CacheMock`, `host.ArtworkMock`)
+- Wrapper functions that delegate to the mock
+
+**Example: Testing a plugin that uses the Cache service**
+
+```go
+package myplugin
+
+import (
+ "testing"
+
+ "github.com/navidrome/navidrome/plugins/pdk/go/host"
+)
+
+func TestMyPluginFunction(t *testing.T) {
+ // Set expectations on the mock
+ host.CacheMock.On("GetString", "my-key").Return("cached-value", true, nil)
+ host.CacheMock.On("SetString", "new-key", "new-value", int64(3600)).Return(nil)
+
+ // Call your plugin code that uses host.CacheGetString and host.CacheSetString
+ result := myPluginFunction()
+
+ // Assert the result
+ if result != "expected" {
+ t.Errorf("unexpected result: %s", result)
+ }
+
+ // Verify all expected calls were made
+ host.CacheMock.AssertExpectations(t)
+}
+```
+
+**Resetting mocks between tests:**
+
+If you need to reset mock state between tests, testify's mock doesn't have a built-in reset. Either use separate test functions (testify automatically resets between test runs), or create a helper to set up fresh expectations.
+
+### Python Client Library
+
+When using `-python`, Python client files are generated in a `python/` subdirectory.
+
+### Rust Client Library
+
+When using `-rust`, Rust client files are generated in a `rust/` subdirectory.
+
+## Supported Types
+
+ndpgen supports these Go types in method signatures:
+
+| Type | JSON Representation |
+|-------------------------------|------------------------------------------|
+| `string`, `int`, `bool`, etc. | Native JSON types |
+| `[]T` (slices) | JSON arrays |
+| `map[K]V` (maps) | JSON objects |
+| `*T` (pointers) | Nullable fields |
+| `interface{}` / `any` | Converts to `any` |
+| Custom structs | JSON objects (must be JSON-serializable) |
+
+### Multiple Return Values
+
+Methods can return multiple values (plus error):
+
+```go
+//nd:hostfunc
+Search(ctx context.Context, query string) (results []string, total int, hasMore bool, err error)
+```
+
+Generates:
+
+```go
+type ServiceSearchResponse struct {
+ Results []string `json:"results,omitempty"`
+ Total int `json:"total,omitempty"`
+ HasMore bool `json:"hasMore,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+```
+
+## Running Tests
+
+```bash
+go test ./plugins/cmd/ndpgen/...
+```
diff --git a/plugins/cmd/ndpgen/go.mod b/plugins/cmd/ndpgen/go.mod
new file mode 100644
index 000000000..af9fce441
--- /dev/null
+++ b/plugins/cmd/ndpgen/go.mod
@@ -0,0 +1,26 @@
+module github.com/navidrome/navidrome/plugins/cmd/ndpgen
+
+go 1.25
+
+require (
+ github.com/extism/go-pdk v1.1.3
+ github.com/onsi/ginkgo/v2 v2.27.5
+ github.com/onsi/gomega v1.39.0
+ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
+ golang.org/x/tools v0.41.0
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+ github.com/Masterminds/semver/v3 v3.4.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
+ github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/mod v0.32.0 // indirect
+ golang.org/x/net v0.49.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/sys v0.40.0 // indirect
+ golang.org/x/text v0.33.0 // indirect
+)
diff --git a/plugins/cmd/ndpgen/go.sum b/plugins/cmd/ndpgen/go.sum
new file mode 100644
index 000000000..952672d0e
--- /dev/null
+++ b/plugins/cmd/ndpgen/go.sum
@@ -0,0 +1,75 @@
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
+github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
+github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
+github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
+github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
+github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
+github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
+github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
+github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
+github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
+github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
+github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
+github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
+github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
+github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
+github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
+github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
+github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
+golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
+golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
+golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
+google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
+google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go
new file mode 100644
index 000000000..db500c1fc
--- /dev/null
+++ b/plugins/cmd/ndpgen/integration_test.go
@@ -0,0 +1,534 @@
+package main
+
+import (
+ "fmt"
+ "go/format"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// normalizeGeneratedCode normalizes generated code for comparison with expected output.
+func normalizeGeneratedCode(code string) string {
+ // Replace package names (generated uses ndpdk, testdata may use ndhost)
+ code = strings.ReplaceAll(code, "package ndhost", "package ndpdk")
+ return code
+}
+
+var _ = Describe("ndpgen CLI", Ordered, func() {
+ var (
+ testDir string
+ outputDir string
+ ndpgenBin string
+ )
+
+ BeforeAll(func() {
+ // Set testdata directory (relative to ndpgen root)
+ testdataDir = filepath.Join(mustGetWd(GinkgoT()), "testdata")
+
+ // Build the ndpgen binary
+ ndpgenBin = filepath.Join(os.TempDir(), "ndpgen-test")
+ cmd := exec.Command("go", "build", "-o", ndpgenBin, ".")
+ cmd.Dir = mustGetWd(GinkgoT())
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Failed to build ndpgen: %s", output)
+ DeferCleanup(func() {
+ os.Remove(ndpgenBin)
+ })
+ })
+
+ BeforeEach(func() {
+ var err error
+ testDir, err = os.MkdirTemp("", "ndpgen-test-input-*")
+ Expect(err).ToNot(HaveOccurred())
+ outputDir, err = os.MkdirTemp("", "ndpgen-test-output-*")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ AfterEach(func() {
+ os.RemoveAll(testDir)
+ os.RemoveAll(outputDir)
+ })
+
+ Describe("CLI flags and behavior", func() {
+ BeforeEach(func() {
+ serviceCode := `package testpkg
+
+import "context"
+
+//nd:hostservice name=Test permission=test
+type TestService interface {
+ //nd:hostfunc
+ DoAction(ctx context.Context, input string) (output string, err error)
+}
+`
+ Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
+ })
+
+ It("supports verbose mode", func() {
+ cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-v")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ outputStr := string(output)
+ Expect(outputStr).To(ContainSubstring("Input directory:"))
+ Expect(outputStr).To(ContainSubstring("Base output directory:"))
+ Expect(outputStr).To(ContainSubstring("Go output directory:"))
+ Expect(outputStr).To(ContainSubstring("Found 1 host service(s)"))
+ Expect(outputStr).To(ContainSubstring("Generated"))
+ })
+
+ It("supports dry-run mode", func() {
+ cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-dry-run")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ Expect(string(output)).To(ContainSubstring("func TestDoAction("))
+ Expect(filepath.Join(outputDir, "nd_host_test.go")).ToNot(BeAnExistingFile())
+ })
+
+ It("uses default package name 'host'", func() {
+ customOutput, err := os.MkdirTemp("", "mypkg")
+ Expect(err).ToNot(HaveOccurred())
+ defer os.RemoveAll(customOutput)
+
+ cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", customOutput)
+ _, err = cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Go code goes to $output/go/host/
+ content, err := os.ReadFile(filepath.Join(customOutput, "go", "host", "nd_host_test.go"))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(content)).To(ContainSubstring("package host"))
+ })
+
+ It("returns error for invalid input directory", func() {
+ cmd := exec.Command(ndpgenBin, "-input", "/nonexistent/path")
+ output, err := cmd.CombinedOutput()
+ Expect(err).To(HaveOccurred())
+ Expect(string(output)).To(ContainSubstring("parsing source files"))
+ })
+
+ It("handles no annotated services gracefully", func() {
+ Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte("package testpkg\n"), 0600)).To(Succeed())
+
+ cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-v")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+ Expect(string(output)).To(ContainSubstring("No host services found"))
+ })
+
+ It("generates separate files for multiple services", func() {
+ // Remove service.go created by BeforeEach
+ Expect(os.Remove(filepath.Join(testDir, "service.go"))).To(Succeed())
+
+ service1 := `package testpkg
+import "context"
+//nd:hostservice name=ServiceA permission=a
+type ServiceA interface {
+ //nd:hostfunc
+ MethodA(ctx context.Context) error
+}
+`
+ service2 := `package testpkg
+import "context"
+//nd:hostservice name=ServiceB permission=b
+type ServiceB interface {
+ //nd:hostfunc
+ MethodB(ctx context.Context) error
+}
+`
+ Expect(os.WriteFile(filepath.Join(testDir, "a.go"), []byte(service1), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(testDir, "b.go"), []byte(service2), 0600)).To(Succeed())
+
+ cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-v")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+ Expect(string(output)).To(ContainSubstring("Found 2 host service(s)"))
+
+ // Go code goes to $output/go/host/
+ goHostDir := filepath.Join(outputDir, "go", "host")
+ Expect(filepath.Join(goHostDir, "nd_host_servicea.go")).To(BeAnExistingFile())
+ Expect(filepath.Join(goHostDir, "nd_host_serviceb.go")).To(BeAnExistingFile())
+ })
+
+ It("generates Go client code by default", func() {
+ cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ // Go client code goes to $output/go/host/
+ goHostDir := filepath.Join(outputDir, "go", "host")
+ Expect(filepath.Join(goHostDir, "nd_host_test.go")).To(BeAnExistingFile())
+ // Stub file also generated
+ Expect(filepath.Join(goHostDir, "nd_host_test_stub.go")).To(BeAnExistingFile())
+ // doc.go in host dir
+ Expect(filepath.Join(goHostDir, "doc.go")).To(BeAnExistingFile())
+ // go.mod at parent $output/go/ for consolidated module
+ goDir := filepath.Join(outputDir, "go")
+ Expect(filepath.Join(goDir, "go.mod")).To(BeAnExistingFile())
+ })
+ })
+
+ Describe("code generation", func() {
+ DescribeTable("generates correct client output",
+ func(serviceFile, goClientExpectedFile, pyClientExpectedFile, rsClientExpectedFile string) {
+ serviceCode := readTestdata(serviceFile)
+ goClientExpected := readTestdata(goClientExpectedFile)
+ pyClientExpected := readTestdata(pyClientExpectedFile)
+ rsClientExpected := readTestdata(rsClientExpectedFile)
+
+ Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
+
+ // Generate all client code (Go, Python, Rust)
+ cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python", "-rust")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ // Verify Go client code (now in $output/go/host/)
+ goHostDir := filepath.Join(outputDir, "go", "host")
+ entries, err := os.ReadDir(goHostDir)
+ Expect(err).ToNot(HaveOccurred())
+
+ var goClientFiles []string
+ for _, e := range entries {
+ if !e.IsDir() &&
+ !strings.HasSuffix(e.Name(), "_stub.go") &&
+ e.Name() != "doc.go" && e.Name() != "go.mod" {
+ goClientFiles = append(goClientFiles, e.Name())
+ }
+ }
+ Expect(goClientFiles).To(HaveLen(1), "Expected exactly one Go client file, got: %v", goClientFiles)
+
+ goClientActual, err := os.ReadFile(filepath.Join(goHostDir, goClientFiles[0]))
+ Expect(err).ToNot(HaveOccurred())
+
+ formattedGoClientActual, err := format.Source(goClientActual)
+ Expect(err).ToNot(HaveOccurred(), "Generated Go client code is not valid Go:\n%s", goClientActual)
+
+ // Normalize expected code to match ndpgen output format
+ normalizedExpected := normalizeGeneratedCode(goClientExpected)
+ formattedGoClientExpected, err := format.Source([]byte(normalizedExpected))
+ Expect(err).ToNot(HaveOccurred(), "Expected Go client code is not valid Go")
+
+ Expect(string(formattedGoClientActual)).To(Equal(string(formattedGoClientExpected)), "Go client code mismatch")
+
+ // Verify Python client code (now in $output/python/host/)
+ pythonHostDir := filepath.Join(outputDir, "python", "host")
+ pyClientEntries, err := os.ReadDir(pythonHostDir)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pyClientEntries).To(HaveLen(1), "Expected exactly one Python client file")
+
+ pyClientActual, err := os.ReadFile(filepath.Join(pythonHostDir, pyClientEntries[0].Name()))
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(string(pyClientActual)).To(Equal(pyClientExpected), "Python client code mismatch")
+
+ // Verify Rust client code (now in $output/rust/nd-pdk-host/src/)
+ rustSrcDir := filepath.Join(outputDir, "rust", "nd-pdk-host", "src")
+ rsClientEntries, err := os.ReadDir(rustSrcDir)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(rsClientEntries).To(HaveLen(2), "Expected Rust client file and lib.rs in src/")
+
+ // Find the client file (not lib.rs)
+ var rsClientName string
+ for _, entry := range rsClientEntries {
+ if entry.Name() != "lib.rs" {
+ rsClientName = entry.Name()
+ break
+ }
+ }
+ Expect(rsClientName).ToNot(BeEmpty(), "Expected to find Rust client file")
+
+ rsClientActual, err := os.ReadFile(filepath.Join(rustSrcDir, rsClientName))
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(string(rsClientActual)).To(Equal(rsClientExpected), "Rust client code mismatch")
+ },
+
+ Entry("simple string params",
+ "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.py", "echo_client_expected.rs"),
+
+ Entry("multiple simple params (int32)",
+ "math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.py", "math_client_expected.rs"),
+
+ Entry("struct param with request type",
+ "store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.py", "store_client_expected.rs"),
+
+ Entry("mixed simple and complex params",
+ "list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.py", "list_client_expected.rs"),
+
+ Entry("method without error",
+ "counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.py", "counter_client_expected.rs"),
+
+ Entry("no params, error only",
+ "ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.py", "ping_client_expected.rs"),
+
+ Entry("map and interface types",
+ "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.py", "meta_client_expected.rs"),
+
+ Entry("pointer types",
+ "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.py", "users_client_expected.rs"),
+
+ Entry("multiple returns",
+ "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.py", "search_client_expected.rs"),
+
+ Entry("bytes",
+ "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.py", "codec_client_expected.rs"),
+
+ Entry("option pattern (value, exists bool)",
+ "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"),
+ )
+
+ It("generates compilable client code for comprehensive service", func() {
+ serviceCode := readTestdata("comprehensive_service.go.txt")
+
+ Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
+
+ // Generate client code
+ cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output)
+
+ // Go code goes to $output/go/host/
+ goHostDir := filepath.Join(outputDir, "go", "host")
+
+ // Read generated client code
+ entries, err := os.ReadDir(goHostDir)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Find the client file
+ var clientFileName string
+ for _, entry := range entries {
+ name := entry.Name()
+ if name != "doc.go" && name != "go.mod" && !strings.HasSuffix(name, "_stub.go") && strings.HasSuffix(name, ".go") {
+ clientFileName = name
+ break
+ }
+ }
+ Expect(clientFileName).ToNot(BeEmpty(), "Expected to find Go client file")
+
+ content, err := os.ReadFile(filepath.Join(goHostDir, clientFileName))
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify key expected content
+ contentStr := string(content)
+ // Should have wasmimport declarations for all methods
+ Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_simpleparams"))
+ Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_structparam"))
+ Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noerror"))
+ Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparams"))
+ Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparamsnoreturns"))
+
+ // Should have response types for methods with complex returns (private types in client code)
+ Expect(contentStr).To(ContainSubstring("type comprehensiveSimpleParamsResponse struct"))
+ Expect(contentStr).To(ContainSubstring("type comprehensiveMultipleReturnsResponse struct"))
+
+ // Should have wrapper functions
+ Expect(contentStr).To(ContainSubstring("func ComprehensiveSimpleParams("))
+ Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParams()"))
+ Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParamsNoReturns()"))
+
+ // Create a plugin directory with proper import structure
+ pluginDir := filepath.Join(outputDir, "plugin")
+ Expect(os.MkdirAll(pluginDir, 0750)).To(Succeed())
+
+ // go.mod is at parent $output/go/ for consolidated module
+ goDir := filepath.Join(outputDir, "go")
+
+ // Create go.mod for the plugin that imports the generated library
+ goMod := fmt.Sprintf(`module testplugin
+
+go 1.25
+
+require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
+
+replace github.com/navidrome/navidrome/plugins/pdk/go => %s
+`, goDir)
+ Expect(os.WriteFile(filepath.Join(pluginDir, "go.mod"), []byte(goMod), 0600)).To(Succeed())
+
+ // Add a simple main function that imports and uses the ndpdk package
+ mainGo := `package main
+
+import ndpdk "github.com/navidrome/navidrome/plugins/pdk/go/host"
+
+func main() {}
+
+// Use some functions to ensure import is not unused
+var _ = ndpdk.ComprehensiveNoParams
+`
+ Expect(os.WriteFile(filepath.Join(pluginDir, "main.go"), []byte(mainGo), 0600)).To(Succeed())
+
+ // Tidy dependencies for the generated go library
+ goTidyLibCmd := exec.Command("go", "mod", "tidy")
+ goTidyLibCmd.Dir = goDir
+ goTidyLibOutput, err := goTidyLibCmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "go mod tidy (library) failed: %s", goTidyLibOutput)
+
+ // Tidy dependencies for the plugin
+ goTidyCmd := exec.Command("go", "mod", "tidy")
+ goTidyCmd.Dir = pluginDir
+ goTidyOutput, err := goTidyCmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "go mod tidy (plugin) failed: %s", goTidyOutput)
+
+ // Build as WASM plugin - this validates the client code compiles correctly
+ buildCmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", "plugin.wasm", ".")
+ buildCmd.Dir = pluginDir
+ buildCmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm")
+ buildOutput, err := buildCmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "WASM build failed: %s", buildOutput)
+
+ // Verify .wasm file was created
+ Expect(filepath.Join(pluginDir, "plugin.wasm")).To(BeAnExistingFile())
+ })
+
+ It("generates Python client code with -python flag", func() {
+ serviceCode := `package testpkg
+
+import "context"
+
+//nd:hostservice name=Test permission=test
+type TestService interface {
+ //nd:hostfunc
+ DoAction(ctx context.Context, input string) (output string, err error)
+}
+`
+ Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
+
+ cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ // Verify Python client code exists in $output/python/host/
+ pythonHostDir := filepath.Join(outputDir, "python", "host")
+ Expect(pythonHostDir).To(BeADirectory())
+
+ pythonFile := filepath.Join(pythonHostDir, "nd_host_test.py")
+ Expect(pythonFile).To(BeAnExistingFile())
+
+ content, err := os.ReadFile(pythonFile)
+ Expect(err).ToNot(HaveOccurred())
+
+ contentStr := string(content)
+ Expect(contentStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
+ Expect(contentStr).To(ContainSubstring("class HostFunctionError(Exception):"))
+ Expect(contentStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "test_doaction")`))
+ Expect(contentStr).To(ContainSubstring("def test_do_action(input: str) -> str:"))
+ })
+
+ It("generates both Go and Python client code with -go -python flags", func() {
+ serviceCode := `package testpkg
+
+import "context"
+
+//nd:hostservice name=Test permission=test
+type TestService interface {
+ //nd:hostfunc
+ DoAction(ctx context.Context, input string) (output string, err error)
+}
+`
+ Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
+
+ cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ // Verify Go client code exists in $output/go/host/
+ goHostDir := filepath.Join(outputDir, "go", "host")
+ Expect(filepath.Join(goHostDir, "nd_host_test.go")).To(BeAnExistingFile())
+
+ // Verify Python client code exists in $output/python/host/
+ pythonHostDir := filepath.Join(outputDir, "python", "host")
+ Expect(pythonHostDir).To(BeADirectory())
+ Expect(filepath.Join(pythonHostDir, "nd_host_test.py")).To(BeAnExistingFile())
+ })
+
+ It("generates Python code with dataclass for multi-value returns", func() {
+ serviceCode := `package testpkg
+
+import "context"
+
+//nd:hostservice name=Cache permission=cache
+type CacheService interface {
+ //nd:hostfunc
+ GetString(ctx context.Context, key string) (value string, exists bool, err error)
+}
+`
+ Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
+
+ cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ content, err := os.ReadFile(filepath.Join(outputDir, "python", "host", "nd_host_cache.py"))
+ Expect(err).ToNot(HaveOccurred())
+
+ contentStr := string(content)
+ Expect(contentStr).To(ContainSubstring("@dataclass"))
+ Expect(contentStr).To(ContainSubstring("class CacheGetStringResult:"))
+ Expect(contentStr).To(ContainSubstring("value: str"))
+ Expect(contentStr).To(ContainSubstring("exists: bool"))
+ Expect(contentStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:"))
+ })
+
+ It("generates Python code for methods with no parameters", func() {
+ serviceCode := `package testpkg
+
+import "context"
+
+//nd:hostservice name=Test permission=test
+type TestService interface {
+ //nd:hostfunc
+ Ping(ctx context.Context) (status string, err error)
+}
+`
+ Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
+
+ cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ content, err := os.ReadFile(filepath.Join(outputDir, "python", "host", "nd_host_test.py"))
+ Expect(err).ToNot(HaveOccurred())
+
+ contentStr := string(content)
+ Expect(contentStr).To(ContainSubstring("def test_ping() -> str:"))
+ Expect(contentStr).To(ContainSubstring(`request_bytes = b"{}"`))
+ })
+ })
+})
+
+var testdataDir string
+
+func readTestdata(filename string) string {
+ content, err := os.ReadFile(filepath.Join(testdataDir, filename))
+ Expect(err).ToNot(HaveOccurred(), "Failed to read testdata file: %s", filename)
+ return string(content)
+}
+
+func mustGetWd(t FullGinkgoTInterface) string {
+ dir, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Look for ndpgen's own go.mod (the subproject root)
+ for {
+ goModPath := filepath.Join(dir, "go.mod")
+ if _, err := os.Stat(goModPath); err == nil {
+ // Check if this is the ndpgen go.mod by reading it
+ content, err := os.ReadFile(goModPath)
+ if err == nil && strings.Contains(string(content), "plugins/cmd/ndpgen") {
+ return dir
+ }
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ t.Fatal("could not find ndpgen project root")
+ }
+ dir = parent
+ }
+}
diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go
new file mode 100644
index 000000000..705cd4d36
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/generator.go
@@ -0,0 +1,889 @@
+package internal
+
+import (
+ "bytes"
+ "embed"
+ "fmt"
+ "strings"
+ "text/template"
+)
+
+//go:embed templates/*.tmpl
+var templatesFS embed.FS
+
+// hostFuncMap returns the template functions for host code generation.
+func hostFuncMap(svc Service) template.FuncMap {
+ return template.FuncMap{
+ "lower": strings.ToLower,
+ "title": strings.Title,
+ "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
+ "requestType": func(m Method) string { return m.RequestTypeName(svc.Name) },
+ "responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) },
+ }
+}
+
+// clientFuncMap returns the template functions for client code generation.
+// Uses private (lowercase) type names for request/response structs.
+func clientFuncMap(svc Service) template.FuncMap {
+ return template.FuncMap{
+ "lower": strings.ToLower,
+ "title": strings.Title,
+ "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
+ "requestType": func(m Method) string { return m.ClientRequestTypeName(svc.Name) },
+ "responseType": func(m Method) string { return m.ClientResponseTypeName(svc.Name) },
+ "formatDoc": formatDoc,
+ "mockReturnValues": mockReturnValues,
+ }
+}
+
+// mockReturnValues generates the testify mock return value accessors for a method.
+// For example: args.String(0), args.Bool(1), args.Error(2)
+func mockReturnValues(m Method) string {
+ var parts []string
+ idx := 0
+
+ for _, r := range m.Returns {
+ parts = append(parts, mockAccessor(r.Type, idx))
+ idx++
+ }
+
+ if m.HasError {
+ parts = append(parts, fmt.Sprintf("args.Error(%d)", idx))
+ }
+
+ return strings.Join(parts, ", ")
+}
+
+// mockAccessor returns the testify mock accessor call for a given type and index.
+func mockAccessor(typ string, idx int) string {
+ switch {
+ case typ == "string":
+ return fmt.Sprintf("args.String(%d)", idx)
+ case typ == "bool":
+ return fmt.Sprintf("args.Bool(%d)", idx)
+ case typ == "int":
+ return fmt.Sprintf("args.Int(%d)", idx)
+ case typ == "int64":
+ return fmt.Sprintf("args.Get(%d).(int64)", idx)
+ case typ == "int32":
+ return fmt.Sprintf("args.Get(%d).(int32)", idx)
+ case typ == "float64":
+ return fmt.Sprintf("args.Get(%d).(float64)", idx)
+ case typ == "float32":
+ return fmt.Sprintf("args.Get(%d).(float32)", idx)
+ case typ == "[]byte":
+ return fmt.Sprintf("args.Get(%d).([]byte)", idx)
+ default:
+ // For slices, maps, pointers, and custom types, use Get with type assertion
+ return fmt.Sprintf("args.Get(%d).(%s)", idx, typ)
+ }
+}
+
+// pythonFuncMap returns the template functions for Python client code generation.
+func pythonFuncMap(svc Service) template.FuncMap {
+ return template.FuncMap{
+ "lower": strings.ToLower,
+ "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
+ "pythonFunc": func(m Method) string { return m.PythonFunctionName(svc.ExportPrefix()) },
+ "pythonResultType": func(m Method) string { return m.PythonResultTypeName(svc.Name) },
+ "pythonDefault": pythonDefaultValue,
+ }
+}
+
+// GenerateHost generates the host function wrapper code for a service.
+func GenerateHost(svc Service, pkgName string) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/host.go.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading host template: %w", err)
+ }
+
+ tmpl, err := template.New("host").Funcs(hostFuncMap(svc)).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ data := templateData{
+ Package: pkgName,
+ Service: svc,
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+// GenerateClientGo generates client wrapper code for plugins to call host functions.
+func GenerateClientGo(svc Service, pkgName string) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/client.go.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading client template: %w", err)
+ }
+
+ tmpl, err := template.New("client").Funcs(clientFuncMap(svc)).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ data := templateData{
+ Package: pkgName,
+ Service: svc,
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+// GenerateClientGoStub generates stub code for non-WASM platforms.
+// These stubs provide type definitions and function signatures for IDE support,
+// but panic at runtime since host functions are only available in WASM plugins.
+func GenerateClientGoStub(svc Service, pkgName string) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/client_stub.go.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading client stub template: %w", err)
+ }
+
+ tmpl, err := template.New("client_stub").Funcs(clientFuncMap(svc)).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ data := templateData{
+ Package: pkgName,
+ Service: svc,
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+type templateData struct {
+ Package string
+ Service Service
+}
+
+// formatDoc formats a documentation string for Go comments.
+// It prefixes each line with "// " and trims trailing whitespace.
+func formatDoc(doc string) string {
+ if doc == "" {
+ return ""
+ }
+ lines := strings.Split(strings.TrimSpace(doc), "\n")
+ var result []string
+ for _, line := range lines {
+ result = append(result, "// "+strings.TrimRight(line, " \t"))
+ }
+ return strings.Join(result, "\n")
+}
+
+// GenerateClientPython generates Python client wrapper code for plugins.
+func GenerateClientPython(svc Service) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/client.py.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading Python client template: %w", err)
+ }
+
+ tmpl, err := template.New("client_py").Funcs(pythonFuncMap(svc)).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ data := templateData{
+ Service: svc,
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+// pythonDefaultValue returns a Python default value for response.get() calls.
+func pythonDefaultValue(p Param) string {
+ switch p.Type {
+ case "string":
+ return `, ""`
+ case "int", "int32", "int64":
+ return ", 0"
+ case "float32", "float64":
+ return ", 0.0"
+ case "bool":
+ return ", False"
+ case "[]byte":
+ return ", b\"\""
+ default:
+ return ", None"
+ }
+}
+
+// rustFuncMap returns the template functions for Rust client code generation.
+func rustFuncMap(svc Service) template.FuncMap {
+ knownStructs := svc.KnownStructs()
+ return template.FuncMap{
+ "lower": strings.ToLower,
+ "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
+ "requestType": func(m Method) string { return m.RequestTypeName(svc.Name) },
+ "responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) },
+ "rustFunc": func(m Method) string { return m.RustFunctionName(svc.ExportPrefix()) },
+ "rustDocComment": RustDocComment,
+ "rustType": func(p Param) string { return p.RustTypeWithStructs(knownStructs) },
+ "rustParamType": func(p Param) string { return p.RustParamTypeWithStructs(knownStructs) },
+ "fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) },
+ }
+}
+
+// GenerateClientRust generates Rust client wrapper code for plugins.
+func GenerateClientRust(svc Service) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/client.rs.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading Rust client template: %w", err)
+ }
+
+ tmpl, err := template.New("client_rs").Funcs(rustFuncMap(svc)).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading base64_bytes partial: %w", err)
+ }
+ tmpl, err = tmpl.Parse(string(partialContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing base64_bytes partial: %w", err)
+ }
+
+ data := templateData{
+ Service: svc,
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+// firstLine returns the first line of a multi-line string, with the first word removed.
+func firstLine(s string) string {
+ line := s
+ if idx := strings.Index(s, "\n"); idx >= 0 {
+ line = s[:idx]
+ }
+ // Remove the first word (service name like "ArtworkService")
+ if idx := strings.Index(line, " "); idx >= 0 {
+ line = line[idx+1:]
+ }
+ return line
+}
+
+// GenerateRustLib generates the lib.rs file that exposes all service modules.
+func GenerateRustLib(services []Service) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/lib.rs.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading Rust lib template: %w", err)
+ }
+
+ tmpl, err := template.New("lib_rs").Funcs(template.FuncMap{
+ "lower": strings.ToLower,
+ "firstLine": firstLine,
+ }).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ data := struct {
+ Services []Service
+ }{
+ Services: services,
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+// GenerateGoDoc generates the doc.go file that provides package documentation.
+func GenerateGoDoc(services []Service, pkgName string) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/doc.go.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading Go doc template: %w", err)
+ }
+
+ tmpl, err := template.New("doc_go").Funcs(template.FuncMap{
+ "firstLine": firstLine,
+ }).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ data := struct {
+ Package string
+ Services []Service
+ }{
+ Package: pkgName,
+ Services: services,
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+// GenerateGoMod generates the go.mod file for the Go client library.
+func GenerateGoMod() ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/go.mod.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading go.mod template: %w", err)
+ }
+ return tmplContent, nil
+}
+
+// capabilityTemplateData holds data for capability template execution.
+type capabilityTemplateData struct {
+ Package string
+ Capability Capability
+}
+
+// capabilityFuncMap returns template functions for capability code generation.
+func capabilityFuncMap(cap Capability) template.FuncMap {
+ return template.FuncMap{
+ "formatDoc": formatDoc,
+ "indent": indentText,
+ "agentName": capabilityAgentName,
+ "providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
+ "implVar": func(e Export) string { return e.ImplVarName() },
+ "exportFunc": func(e Export) string { return e.ExportFuncName() },
+ }
+}
+
+// indentText adds n tabs to each line of text.
+func indentText(n int, s string) string {
+ indent := strings.Repeat("\t", n)
+ lines := strings.Split(s, "\n")
+ for i, line := range lines {
+ if line != "" {
+ lines[i] = indent + line
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+// capabilityAgentName returns the interface name for a capability.
+// Uses the Go interface name stripped of common suffixes.
+func capabilityAgentName(cap Capability) string {
+ name := cap.Interface
+ // Remove common suffixes to get a clean name
+ for _, suffix := range []string{"Agent", "Callback", "Service"} {
+ if strings.HasSuffix(name, suffix) {
+ name = name[:len(name)-len(suffix)]
+ break
+ }
+ }
+ // Use the shortened name or the original if no suffix found
+ if name == "" {
+ name = cap.Interface
+ }
+ return name
+}
+
+// GenerateCapabilityGo generates Go export wrapper code for a capability.
+func GenerateCapabilityGo(cap Capability, pkgName string) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/capability.go.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading capability template: %w", err)
+ }
+
+ tmpl, err := template.New("capability").Funcs(capabilityFuncMap(cap)).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ data := capabilityTemplateData{
+ Package: pkgName,
+ Capability: cap,
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+// GenerateCapabilityGoStub generates stub code for non-WASM platforms.
+func GenerateCapabilityGoStub(cap Capability, pkgName string) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/capability_stub.go.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading capability stub template: %w", err)
+ }
+
+ tmpl, err := template.New("capability_stub").Funcs(capabilityFuncMap(cap)).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ data := capabilityTemplateData{
+ Package: pkgName,
+ Capability: cap,
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+// rustCapabilityFuncMap returns template functions for Rust capability code generation.
+func rustCapabilityFuncMap(cap Capability) template.FuncMap {
+ knownStructs := cap.KnownStructs()
+ return template.FuncMap{
+ "rustDocComment": RustDocComment,
+ "rustTypeAlias": rustTypeAlias,
+ "rustConstType": rustConstType,
+ "rustConstName": rustConstName,
+ "rustFieldName": func(name string) string { return ToSnakeCase(name) },
+ "rustMethodName": func(name string) string { return ToSnakeCase(name) },
+ "fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) },
+ "rustOutputType": rustOutputType,
+ "isPrimitiveRust": isPrimitiveRustType,
+ "skipSerializingFunc": skipSerializingFunc,
+ "hasHashMap": hasHashMap,
+ "agentName": capabilityAgentName,
+ "providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
+ "registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) },
+ "snakeCase": ToSnakeCase,
+ "indent": func(spaces int, s string) string {
+ indent := strings.Repeat(" ", spaces)
+ lines := strings.Split(s, "\n")
+ for i, line := range lines {
+ if line != "" {
+ lines[i] = indent + line
+ }
+ }
+ return strings.Join(lines, "\n")
+ },
+ }
+}
+
+// rustTypeAlias converts a Go type to its Rust equivalent for type aliases.
+// For string types used as error sentinels/constants, we use &'static str
+// since Rust consts can't be heap-allocated String values.
+func rustTypeAlias(goType string) string {
+ switch goType {
+ case "string":
+ return "&'static str"
+ case "int", "int32":
+ return "i32"
+ case "int64":
+ return "i64"
+ default:
+ return goType
+ }
+}
+
+// rustConstType converts a Go type to its Rust equivalent for const declarations.
+// For String types, it returns &'static str since Rust consts can't be heap-allocated.
+func rustConstType(goType string) string {
+ switch goType {
+ case "string", "String":
+ return "&'static str"
+ case "int", "int32":
+ return "i32"
+ case "int64":
+ return "i64"
+ default:
+ return goType
+ }
+}
+
+// rustOutputType converts a Go type to Rust for capability method signatures.
+// It handles pointer types specially - for capability outputs, pointers become the base type
+// (not Option) because Rust's Result already provides optional semantics.
+//
+// TODO: Pointer to primitive types (e.g., *string, *int32) are not handled correctly.
+// Currently "*string" returns "string" instead of "String". This would generate invalid
+// Rust code. No current capability uses this pattern, but it should be fixed if needed.
+func rustOutputType(goType string) string {
+ // Strip pointer prefix - capability outputs use Result for optionality
+ if strings.HasPrefix(goType, "*") {
+ return goType[1:]
+ }
+ // Convert Go primitives to Rust primitives
+ switch goType {
+ case "bool":
+ return "bool"
+ case "string":
+ return "String"
+ case "int", "int32":
+ return "i32"
+ case "int64":
+ return "i64"
+ case "float32":
+ return "f32"
+ case "float64":
+ return "f64"
+ }
+ return goType
+}
+
+// isPrimitiveRustType returns true if the Go type maps to a Rust primitive type.
+func isPrimitiveRustType(goType string) bool {
+ // Strip pointer prefix first
+ if strings.HasPrefix(goType, "*") {
+ goType = goType[1:]
+ }
+ switch goType {
+ case "bool", "string", "int", "int32", "int64", "float32", "float64":
+ return true
+ }
+ return false
+}
+
+// rustConstName converts a Go const name to Rust convention (SCREAMING_SNAKE_CASE).
+func rustConstName(name string) string {
+ return strings.ToUpper(ToSnakeCase(name))
+}
+
+// skipSerializingFunc returns the appropriate skip_serializing_if function name.
+func skipSerializingFunc(goType string) string {
+ if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") {
+ return "Option::is_none"
+ }
+ switch goType {
+ case "string":
+ return "String::is_empty"
+ case "bool":
+ return "std::ops::Not::not"
+ case "int32":
+ return "is_zero_i32"
+ case "uint32":
+ return "is_zero_u32"
+ case "int64":
+ return "is_zero_i64"
+ case "uint64":
+ return "is_zero_u64"
+ case "float32":
+ return "is_zero_f32"
+ case "float64":
+ return "is_zero_f64"
+ default:
+ return "Option::is_none"
+ }
+}
+
+// hasHashMap returns true if any struct in the capability uses HashMap.
+func hasHashMap(cap Capability) bool {
+ for _, st := range cap.Structs {
+ for _, f := range st.Fields {
+ if strings.HasPrefix(f.Type, "map[") {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// registerMacroName returns the macro name for registering an optional method.
+// For package "websocket" and method "OnClose", returns "register_websocket_close".
+func registerMacroName(pkg, name string) string {
+ // Remove common prefixes from method name
+ for _, prefix := range []string{"Get", "On"} {
+ if strings.HasPrefix(name, prefix) {
+ name = name[len(prefix):]
+ break
+ }
+ }
+ return "register_" + ToSnakeCase(pkg) + "_" + ToSnakeCase(name)
+}
+
+// GenerateCapabilityRust generates Rust export wrapper code for a capability.
+func GenerateCapabilityRust(cap Capability) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/capability.rs.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading Rust capability template: %w", err)
+ }
+
+ tmpl, err := template.New("capability_rust").Funcs(rustCapabilityFuncMap(cap)).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading base64_bytes partial: %w", err)
+ }
+ tmpl, err = tmpl.Parse(string(partialContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing base64_bytes partial: %w", err)
+ }
+
+ data := capabilityTemplateData{
+ Package: cap.Name,
+ Capability: cap,
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+// GenerateCapabilityRustLib generates the lib.rs file for the Rust capabilities crate.
+func GenerateCapabilityRustLib(capabilities []Capability) ([]byte, error) {
+ var buf bytes.Buffer
+ buf.WriteString("// Code generated by ndpgen. DO NOT EDIT.\n\n")
+ buf.WriteString("//! Navidrome Plugin Development Kit - Capability Wrappers\n")
+ buf.WriteString("//!\n")
+ buf.WriteString("//! This crate provides type definitions, traits, and registration macros\n")
+ buf.WriteString("//! for implementing Navidrome plugin capabilities in Rust.\n\n")
+
+ // Module declarations
+ for _, cap := range capabilities {
+ moduleName := ToSnakeCase(cap.Name)
+ buf.WriteString(fmt.Sprintf("pub mod %s;\n", moduleName))
+ }
+
+ return buf.Bytes(), nil
+}
+
+// pdkFuncMap returns the template functions for PDK code generation.
+func pdkFuncMap() template.FuncMap {
+ return template.FuncMap{
+ "firstSentence": firstSentence,
+ "paramList": pdkParamList,
+ "returnList": pdkReturnList,
+ "argList": pdkArgList,
+ "argListWithReceiver": pdkArgListWithReceiver,
+ "mockReturns": pdkMockReturns,
+ "constValue": pdkConstValue,
+ "stubTypeUnderlying": stubTypeUnderlying,
+ "methodReceiver": pdkMethodReceiver,
+ }
+}
+
+// stubTypeUnderlying returns the appropriate stub type for non-WASM builds.
+// For types that reference internal packages (like memory.Memory), returns "struct{}".
+func stubTypeUnderlying(t PDKType) string {
+ underlying := t.Underlying
+ // If the underlying type references a package (contains a dot), use a stub struct
+ if strings.Contains(underlying, ".") {
+ return "struct{}"
+ }
+ // For simple types like int, int32, return as-is
+ return underlying
+}
+
+// firstSentence returns the first sentence of a doc string, normalized to a single line.
+func firstSentence(doc string) string {
+ if doc == "" {
+ return ""
+ }
+ // Normalize whitespace (replace newlines with spaces, collapse multiple spaces)
+ doc = strings.Join(strings.Fields(doc), " ")
+
+ // Find first period followed by space or end
+ for i, r := range doc {
+ if r == '.' && (i+1 >= len(doc) || doc[i+1] == ' ') {
+ return doc[:i+1]
+ }
+ }
+ return doc
+}
+
+// pdkParamList generates a parameter list string for function signature.
+func pdkParamList(params []PDKParam) string {
+ var parts []string
+ for _, p := range params {
+ if p.Name != "" {
+ parts = append(parts, p.Name+" "+p.Type)
+ } else {
+ parts = append(parts, p.Type)
+ }
+ }
+ return strings.Join(parts, ", ")
+}
+
+// pdkReturnList generates a return list string for function signature.
+func pdkReturnList(returns []PDKReturn) string {
+ if len(returns) == 0 {
+ return ""
+ }
+ if len(returns) == 1 && returns[0].Name == "" {
+ return " " + returns[0].Type
+ }
+ var parts []string
+ for _, r := range returns {
+ if r.Name != "" {
+ parts = append(parts, r.Name+" "+r.Type)
+ } else {
+ parts = append(parts, r.Type)
+ }
+ }
+ return " (" + strings.Join(parts, ", ") + ")"
+}
+
+// pdkArgList generates an argument list string for function call.
+func pdkArgList(params []PDKParam) string {
+ var parts []string
+ for _, p := range params {
+ if p.Name != "" {
+ parts = append(parts, p.Name)
+ } else {
+ parts = append(parts, "_")
+ }
+ }
+ return strings.Join(parts, ", ")
+}
+
+// pdkArgListWithReceiver generates an argument list that includes the receiver variable
+// as the first argument to PDKMock.Called(). This allows tests to verify which instance
+// a method was called on.
+func pdkArgListWithReceiver(params []PDKParam, typeName string) string {
+ // Use lowercase first letter of type name as receiver variable
+ receiverVar := strings.ToLower(typeName[:1])
+ parts := []string{receiverVar}
+ for _, p := range params {
+ if p.Name != "" {
+ parts = append(parts, p.Name)
+ } else {
+ parts = append(parts, "_")
+ }
+ }
+ return strings.Join(parts, ", ")
+}
+
+// pdkMethodReceiver generates the receiver declaration for a method.
+// Example: "r *HTTPRequest" or "m Memory"
+func pdkMethodReceiver(receiver, typeName string) string {
+ receiverVar := strings.ToLower(typeName[:1])
+ if strings.HasPrefix(receiver, "*") {
+ return receiverVar + " *" + typeName
+ }
+ return receiverVar + " " + typeName
+}
+
+// pdkMockReturns generates the mock return accessors for a function.
+func pdkMockReturns(returns []PDKReturn) string {
+ var parts []string
+ for i, r := range returns {
+ parts = append(parts, mockAccessorForType(r.Type, i))
+ }
+ return strings.Join(parts, ", ")
+}
+
+// mockAccessorForType returns the testify mock accessor for a type.
+func mockAccessorForType(typ string, idx int) string {
+ switch typ {
+ case "string":
+ return fmt.Sprintf("args.String(%d)", idx)
+ case "bool":
+ return fmt.Sprintf("args.Bool(%d)", idx)
+ case "int":
+ return fmt.Sprintf("args.Int(%d)", idx)
+ case "error":
+ return fmt.Sprintf("args.Error(%d)", idx)
+ case "[]byte":
+ return fmt.Sprintf("args.Get(%d).([]byte)", idx)
+ case "uint64":
+ return fmt.Sprintf("args.Get(%d).(uint64)", idx)
+ case "uint32":
+ return fmt.Sprintf("args.Get(%d).(uint32)", idx)
+ case "uint16":
+ return fmt.Sprintf("args.Get(%d).(uint16)", idx)
+ default:
+ return fmt.Sprintf("args.Get(%d).(%s)", idx, typ)
+ }
+}
+
+// pdkConstValue returns the value expression for a constant.
+func pdkConstValue(c PDKConst) string {
+ if c.Value == "" || c.Value == "iota" {
+ return "iota"
+ }
+ return c.Value
+}
+
+// GeneratePDKGo generates the WASM implementation of the PDK wrapper package.
+func GeneratePDKGo(symbols *PDKSymbols) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/pdk.go.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading pdk template: %w", err)
+ }
+
+ tmpl, err := template.New("pdk").Funcs(pdkFuncMap()).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, symbols); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+// GeneratePDKGoStub generates the native stub implementation of the PDK wrapper package.
+func GeneratePDKGoStub(symbols *PDKSymbols) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/pdk_stub.go.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading pdk stub template: %w", err)
+ }
+
+ tmpl, err := template.New("pdk_stub").Funcs(pdkFuncMap()).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, symbols); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+// GeneratePDKTypesStub generates the native type definitions for the PDK wrapper package.
+func GeneratePDKTypesStub(symbols *PDKSymbols) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/types_stub.go.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading types stub template: %w", err)
+ }
+
+ tmpl, err := template.New("types_stub").Funcs(pdkFuncMap()).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, symbols); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go
new file mode 100644
index 000000000..34c2c2886
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/generator_test.go
@@ -0,0 +1,1664 @@
+package internal
+
+import (
+ "go/format"
+ "os"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Generator", func() {
+ Describe("GenerateHost", func() {
+ It("should generate valid Go code for a simple service with strings", func() {
+ // All methods use JSON request/response types
+ svc := Service{
+ Name: "SubsonicAPI",
+ Permission: "subsonicapi",
+ Interface: "SubsonicAPIService",
+ Methods: []Method{
+ {
+ Name: "Call",
+ HasError: true,
+ Params: []Param{NewParam("uri", "string")},
+ Returns: []Param{NewParam("response", "string")},
+ },
+ },
+ }
+
+ code, err := GenerateHost(svc, "host")
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify the code is valid Go
+ _, err = format.Source(code)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check for generated header
+ Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
+
+ // Check for package declaration
+ Expect(codeStr).To(ContainSubstring("package host"))
+
+ // All methods now use request type for JSON protocol
+ Expect(codeStr).To(ContainSubstring("type SubsonicAPICallRequest struct"))
+ Expect(codeStr).To(ContainSubstring(`Uri string `))
+
+ // Response type with error handling
+ Expect(codeStr).To(ContainSubstring("type SubsonicAPICallResponse struct"))
+ Expect(codeStr).To(ContainSubstring(`Response string `))
+ Expect(codeStr).To(ContainSubstring(`Error string `))
+
+ // Check for registration function
+ Expect(codeStr).To(ContainSubstring("func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService)"))
+
+ // Check for host function name
+ Expect(codeStr).To(ContainSubstring(`"subsonicapi_call"`))
+
+ // Check for JSON unmarshal (all methods use JSON now)
+ Expect(codeStr).To(ContainSubstring("json.Unmarshal"))
+ })
+
+ It("should generate code for methods without parameters", func() {
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "NoParams",
+ HasError: true,
+ Returns: []Param{NewParam("result", "string")},
+ },
+ },
+ }
+
+ code, err := GenerateHost(svc, "host")
+ Expect(err).NotTo(HaveOccurred())
+
+ _, err = format.Source(code)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+ // Methods without params don't need a request type - no params to serialize
+ Expect(codeStr).NotTo(ContainSubstring("type TestNoParamsRequest struct"))
+ // But still uses PTR input/output for consistency
+ Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
+ })
+
+ It("should generate code for methods without return values", func() {
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "NoReturn",
+ HasError: true,
+ Params: []Param{NewParam("input", "string")},
+ },
+ },
+ }
+
+ code, err := GenerateHost(svc, "host")
+ Expect(err).NotTo(HaveOccurred())
+
+ _, err = format.Source(code)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("should generate code for multiple methods", func() {
+ svc := Service{
+ Name: "Scheduler",
+ Permission: "scheduler",
+ Interface: "SchedulerService",
+ Methods: []Method{
+ {
+ Name: "ScheduleRecurring",
+ HasError: true,
+ Params: []Param{NewParam("cronExpression", "string")},
+ Returns: []Param{NewParam("scheduleID", "string")},
+ },
+ {
+ Name: "ScheduleOneTime",
+ HasError: true,
+ Params: []Param{NewParam("delaySeconds", "int32")},
+ Returns: []Param{NewParam("scheduleID", "string")},
+ },
+ {
+ Name: "CancelSchedule",
+ HasError: true,
+ Params: []Param{NewParam("scheduleID", "string")},
+ Returns: []Param{NewParam("canceled", "bool")},
+ },
+ },
+ }
+
+ code, err := GenerateHost(svc, "host")
+ Expect(err).NotTo(HaveOccurred())
+
+ _, err = format.Source(code)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+ Expect(codeStr).To(ContainSubstring("scheduler_schedulerecurring"))
+ Expect(codeStr).To(ContainSubstring("scheduler_scheduleonetime"))
+ Expect(codeStr).To(ContainSubstring("scheduler_cancelschedule"))
+ })
+
+ It("should handle multiple simple parameters with JSON", func() {
+ // All params use JSON - single PTR input
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "MultiParam",
+ HasError: true,
+ Params: []Param{
+ NewParam("name", "string"),
+ NewParam("count", "int32"),
+ NewParam("enabled", "bool"),
+ },
+ Returns: []Param{NewParam("result", "string")},
+ },
+ },
+ }
+
+ code, err := GenerateHost(svc, "host")
+ Expect(err).NotTo(HaveOccurred())
+
+ _, err = format.Source(code)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+ // All methods use request type with JSON protocol
+ Expect(codeStr).To(ContainSubstring("type TestMultiParamRequest struct"))
+ // Check for JSON unmarshal (all methods use JSON now)
+ Expect(codeStr).To(ContainSubstring("json.Unmarshal"))
+ // Check that input/output ValueType both use PTR (JSON)
+ Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
+ })
+
+ It("should use single PTR for mixed simple and complex params", func() {
+ // When any param needs JSON, all are bundled into one request struct
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "MixedParam",
+ HasError: true,
+ Params: []Param{
+ NewParam("id", "string"), // simple (PTR for string)
+ NewParam("tags", "[]string"), // complex - needs JSON
+ },
+ Returns: []Param{NewParam("count", "int32")}, // simple
+ },
+ },
+ }
+
+ code, err := GenerateHost(svc, "host")
+ Expect(err).NotTo(HaveOccurred())
+
+ _, err = format.Source(code)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+ // Request type IS needed because of complex param
+ Expect(codeStr).To(ContainSubstring("type TestMixedParamRequest struct"))
+ // When using request type, only ONE PTR for input (the JSON request)
+ Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
+ })
+
+ It("should generate proper JSON tags for complex types", func() {
+ // Complex types (structs, slices, maps) need JSON serialization
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "Method",
+ HasError: true,
+ Params: []Param{NewParam("inputValue", "[]string")}, // slice needs JSON
+ Returns: []Param{NewParam("outputValue", "map[string]string")}, // map needs JSON
+ },
+ },
+ }
+
+ code, err := GenerateHost(svc, "host")
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+ // Complex params need request type with JSON tags
+ Expect(codeStr).To(ContainSubstring(`json:"inputValue"`))
+ // Complex returns need response type with JSON tags
+ Expect(codeStr).To(ContainSubstring(`json:"outputValue,omitempty"`))
+ })
+
+ It("should include required imports", func() {
+ // Service with complex types needs JSON import
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "Method",
+ HasError: true,
+ Params: []Param{NewParam("data", "MyStruct")}, // struct needs JSON
+ },
+ },
+ }
+
+ code, err := GenerateHost(svc, "host")
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+ Expect(codeStr).To(ContainSubstring(`"context"`))
+ Expect(codeStr).To(ContainSubstring(`"encoding/json"`))
+ Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
+ })
+
+ It("should always include json import for JSON protocol", func() {
+ // All services use JSON protocol, so json import is always needed
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "Method",
+ Params: []Param{NewParam("count", "int32")},
+ Returns: []Param{NewParam("result", "int64")},
+ },
+ },
+ }
+
+ code, err := GenerateHost(svc, "host")
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+ Expect(codeStr).To(ContainSubstring(`"context"`))
+ Expect(codeStr).To(ContainSubstring(`"encoding/json"`))
+ Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
+ })
+ })
+
+ Describe("toJSONName", func() {
+ It("should convert to camelCase matching Rust serde behavior", func() {
+ Expect(toJSONName("InputValue")).To(Equal("inputValue"))
+ Expect(toJSONName("URI")).To(Equal("uri"))
+ Expect(toJSONName("id")).To(Equal("id"))
+ Expect(toJSONName("ID")).To(Equal("id"))
+ Expect(toJSONName("ConnectionID")).To(Equal("connectionId"))
+ Expect(toJSONName("NewConnectionID")).To(Equal("newConnectionId"))
+ Expect(toJSONName("XMLHTTPRequest")).To(Equal("xmlhttpRequest"))
+ Expect(toJSONName("APIKey")).To(Equal("apiKey"))
+ })
+
+ It("should handle empty string", func() {
+ Expect(toJSONName("")).To(Equal(""))
+ })
+ })
+
+ Describe("NewParam", func() {
+ It("should create param with auto-generated JSON name", func() {
+ p := NewParam("MyParam", "string")
+ Expect(p.Name).To(Equal("MyParam"))
+ Expect(p.Type).To(Equal("string"))
+ Expect(p.JSONName).To(Equal("myParam"))
+ })
+ })
+
+ Describe("Method.IsOptionPattern", func() {
+ It("should return true for (value, exists bool) pattern", func() {
+ m := Method{
+ Returns: []Param{
+ {Name: "value", Type: "string"},
+ {Name: "exists", Type: "bool"},
+ },
+ }
+ Expect(m.IsOptionPattern()).To(BeTrue())
+ })
+
+ It("should return true for (value, ok bool) pattern", func() {
+ m := Method{
+ Returns: []Param{
+ {Name: "value", Type: "int64"},
+ {Name: "ok", Type: "bool"},
+ },
+ }
+ Expect(m.IsOptionPattern()).To(BeTrue())
+ })
+
+ It("should return true for (value, found bool) pattern", func() {
+ m := Method{
+ Returns: []Param{
+ {Name: "data", Type: "[]byte"},
+ {Name: "found", Type: "bool"},
+ },
+ }
+ Expect(m.IsOptionPattern()).To(BeTrue())
+ })
+
+ It("should be case insensitive for bool name", func() {
+ m := Method{
+ Returns: []Param{
+ {Name: "value", Type: "string"},
+ {Name: "EXISTS", Type: "bool"},
+ },
+ }
+ Expect(m.IsOptionPattern()).To(BeTrue())
+ })
+
+ It("should return false for single return", func() {
+ m := Method{
+ Returns: []Param{
+ {Name: "value", Type: "string"},
+ },
+ }
+ Expect(m.IsOptionPattern()).To(BeFalse())
+ })
+
+ It("should return false for more than two returns", func() {
+ m := Method{
+ Returns: []Param{
+ {Name: "value", Type: "string"},
+ {Name: "count", Type: "int"},
+ {Name: "exists", Type: "bool"},
+ },
+ }
+ Expect(m.IsOptionPattern()).To(BeFalse())
+ })
+
+ It("should return false when second return is not bool", func() {
+ m := Method{
+ Returns: []Param{
+ {Name: "value", Type: "string"},
+ {Name: "count", Type: "int"},
+ },
+ }
+ Expect(m.IsOptionPattern()).To(BeFalse())
+ })
+
+ It("should return false when bool is not named exists/ok/found", func() {
+ m := Method{
+ Returns: []Param{
+ {Name: "value", Type: "string"},
+ {Name: "success", Type: "bool"},
+ },
+ }
+ Expect(m.IsOptionPattern()).To(BeFalse())
+ })
+
+ It("should return false for Has() pattern where first return is bool", func() {
+ // Has(key) -> (exists bool) should NOT be treated as Option pattern
+ m := Method{
+ Returns: []Param{
+ {Name: "exists", Type: "bool"},
+ },
+ }
+ Expect(m.IsOptionPattern()).To(BeFalse())
+ })
+
+ It("should return false when first return is bool (preserves Has-like methods)", func() {
+ // Even with two returns, if first is bool, don't convert to Option
+ m := Method{
+ Returns: []Param{
+ {Name: "result", Type: "bool"},
+ {Name: "exists", Type: "bool"},
+ },
+ }
+ Expect(m.IsOptionPattern()).To(BeFalse())
+ })
+ })
+
+ Describe("Python type and name helpers", func() {
+ Describe("ToPythonType", func() {
+ It("should map Go types to Python types", func() {
+ Expect(ToPythonType("string")).To(Equal("str"))
+ Expect(ToPythonType("int")).To(Equal("int"))
+ Expect(ToPythonType("int32")).To(Equal("int"))
+ Expect(ToPythonType("int64")).To(Equal("int"))
+ Expect(ToPythonType("float32")).To(Equal("float"))
+ Expect(ToPythonType("float64")).To(Equal("float"))
+ Expect(ToPythonType("bool")).To(Equal("bool"))
+ Expect(ToPythonType("[]byte")).To(Equal("bytes"))
+ Expect(ToPythonType("unknown")).To(Equal("Any"))
+ })
+ })
+
+ Describe("ToSnakeCase", func() {
+ It("should convert PascalCase to snake_case", func() {
+ Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring"))
+ Expect(ToSnakeCase("GetString")).To(Equal("get_string"))
+ Expect(ToSnakeCase("simple")).To(Equal("simple"))
+ })
+
+ It("should handle acronyms correctly", func() {
+ Expect(ToSnakeCase("ID")).To(Equal("id"))
+ Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id"))
+ Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id"))
+ Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser"))
+ Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response"))
+ })
+ })
+
+ Describe("Method.PythonFunctionName", func() {
+ It("should generate snake_case function name with service prefix", func() {
+ m := Method{Name: "GetString"}
+ Expect(m.PythonFunctionName("cache")).To(Equal("cache_get_string"))
+ })
+ })
+
+ Describe("Param.PythonType", func() {
+ It("should return Python type for parameter", func() {
+ p := NewParam("value", "string")
+ Expect(p.PythonType()).To(Equal("str"))
+ })
+ })
+
+ Describe("Param.PythonName", func() {
+ It("should return snake_case name for parameter", func() {
+ p := NewParam("ttlSeconds", "int64")
+ Expect(p.PythonName()).To(Equal("ttl_seconds"))
+ })
+ })
+ })
+
+ Describe("GenerateClientPython", func() {
+ It("should generate valid Python code for a simple service", func() {
+ svc := Service{
+ Name: "SubsonicAPI",
+ Permission: "subsonicapi",
+ Interface: "SubsonicAPIService",
+ Methods: []Method{
+ {
+ Name: "Call",
+ HasError: true,
+ Params: []Param{NewParam("uri", "string")},
+ Returns: []Param{NewParam("responseJSON", "string")},
+ },
+ },
+ }
+
+ code, err := GenerateClientPython(svc)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check for generated header
+ Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
+
+ // Check for imports
+ Expect(codeStr).To(ContainSubstring("from dataclasses import dataclass"))
+ Expect(codeStr).To(ContainSubstring("import extism"))
+ Expect(codeStr).To(ContainSubstring("import json"))
+
+ // Check for exception class
+ Expect(codeStr).To(ContainSubstring("class HostFunctionError(Exception):"))
+
+ // Check for raw import function
+ Expect(codeStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "subsonicapi_call")`))
+ Expect(codeStr).To(ContainSubstring("def _subsonicapi_call(offset: int) -> int:"))
+
+ // Check for wrapper function with type hints
+ Expect(codeStr).To(ContainSubstring("def subsonicapi_call(uri: str) -> str:"))
+
+ // Check for error handling
+ Expect(codeStr).To(ContainSubstring("raise HostFunctionError(response["))
+ })
+
+ It("should generate dataclass for multi-value returns", func() {
+ svc := Service{
+ Name: "Cache",
+ Permission: "cache",
+ Interface: "CacheService",
+ Methods: []Method{
+ {
+ Name: "GetString",
+ HasError: true,
+ Params: []Param{NewParam("key", "string")},
+ Returns: []Param{
+ NewParam("value", "string"),
+ NewParam("exists", "bool"),
+ },
+ },
+ },
+ }
+
+ code, err := GenerateClientPython(svc)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check for dataclass
+ Expect(codeStr).To(ContainSubstring("@dataclass"))
+ Expect(codeStr).To(ContainSubstring("class CacheGetStringResult:"))
+ Expect(codeStr).To(ContainSubstring("value: str"))
+ Expect(codeStr).To(ContainSubstring("exists: bool"))
+
+ // Check that function returns dataclass
+ Expect(codeStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:"))
+ Expect(codeStr).To(ContainSubstring("return CacheGetStringResult("))
+ })
+
+ It("should handle methods with no parameters", func() {
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "NoParams",
+ HasError: true,
+ Returns: []Param{NewParam("result", "string")},
+ },
+ },
+ }
+
+ code, err := GenerateClientPython(svc)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Function with no params
+ Expect(codeStr).To(ContainSubstring("def test_no_params() -> str:"))
+ // Empty request
+ Expect(codeStr).To(ContainSubstring(`request_bytes = b"{}"`))
+ })
+
+ It("should handle methods with no return values", func() {
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "NoReturn",
+ HasError: true,
+ Params: []Param{NewParam("input", "string")},
+ },
+ },
+ }
+
+ code, err := GenerateClientPython(svc)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Function returns None
+ Expect(codeStr).To(ContainSubstring("def test_no_return(input: str) -> None:"))
+ })
+
+ It("should generate correct Python defaults for different types", func() {
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "AllTypes",
+ HasError: true,
+ Returns: []Param{
+ NewParam("strVal", "string"),
+ NewParam("intVal", "int64"),
+ NewParam("floatVal", "float64"),
+ NewParam("boolVal", "bool"),
+ },
+ },
+ },
+ }
+
+ code, err := GenerateClientPython(svc)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check defaults in response.get() calls
+ Expect(codeStr).To(ContainSubstring(`response.get("strVal", "")`))
+ Expect(codeStr).To(ContainSubstring(`response.get("intVal", 0)`))
+ Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`))
+ Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
+ })
+
+ It("should not import base64 for non-byte services", func() {
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "Call",
+ HasError: true,
+ Params: []Param{NewParam("uri", "string")},
+ Returns: []Param{NewParam("response", "string")},
+ },
+ },
+ }
+
+ code, err := GenerateClientPython(svc)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ Expect(codeStr).NotTo(ContainSubstring("import base64"))
+ })
+
+ It("should generate base64 encoding/decoding for byte fields", func() {
+ svc := Service{
+ Name: "Codec",
+ Permission: "codec",
+ Interface: "CodecService",
+ Methods: []Method{
+ {
+ Name: "Encode",
+ HasError: true,
+ Params: []Param{NewParam("data", "[]byte")},
+ Returns: []Param{NewParam("result", "[]byte")},
+ },
+ },
+ }
+
+ code, err := GenerateClientPython(svc)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Should import base64
+ Expect(codeStr).To(ContainSubstring("import base64"))
+
+ // Should base64-encode byte params in request
+ Expect(codeStr).To(ContainSubstring(`base64.b64encode(data).decode("ascii")`))
+
+ // Should base64-decode byte returns in response
+ Expect(codeStr).To(ContainSubstring(`base64.b64decode(response.get("result", ""))`))
+ })
+ })
+
+ Describe("GenerateGoDoc", func() {
+ It("should generate valid doc.go content for multiple services", func() {
+ services := []Service{
+ {
+ Name: "Cache",
+ Permission: "cache",
+ Interface: "CacheService",
+ Doc: "CacheService provides temporary key-value storage with TTL.",
+ },
+ {
+ Name: "Scheduler",
+ Permission: "scheduler",
+ Interface: "SchedulerService",
+ Doc: "SchedulerService manages scheduled tasks.",
+ },
+ }
+
+ code, err := GenerateGoDoc(services, "ndpdk")
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify it's valid Go code
+ _, err = format.Source(code)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check for generated header
+ Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
+
+ // Check for package declaration
+ Expect(codeStr).To(ContainSubstring("package ndpdk"))
+
+ // Check for package documentation
+ Expect(codeStr).To(ContainSubstring("Package ndpdk provides Navidrome Plugin Development Kit wrappers"))
+
+ // Check that services are listed
+ Expect(codeStr).To(ContainSubstring("Cache:"))
+ Expect(codeStr).To(ContainSubstring("Scheduler:"))
+ })
+ })
+
+ Describe("GenerateGoMod", func() {
+ It("should generate valid go.mod content", func() {
+ code, err := GenerateGoMod()
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check for module declaration (consolidated PDK path at pdk/go level)
+ Expect(codeStr).To(ContainSubstring("module github.com/navidrome/navidrome/plugins/pdk/go"))
+ // Ensure it's not the old host-specific path
+ Expect(codeStr).NotTo(ContainSubstring("module github.com/navidrome/navidrome/plugins/pdk/go/host"))
+
+ // Check for Go version
+ Expect(codeStr).To(ContainSubstring("go 1.25"))
+
+ // Check for extism-go-pdk dependency
+ Expect(codeStr).To(ContainSubstring("github.com/extism/go-pdk"))
+ })
+ })
+
+ Describe("GenerateClientGo", func() {
+ It("should include errors import when service has methods with errors", func() {
+ svc := Service{
+ Name: "Cache",
+ Permission: "cache",
+ Interface: "CacheService",
+ Methods: []Method{
+ {
+ Name: "Get",
+ HasError: true,
+ Params: []Param{NewParam("key", "string")},
+ Returns: []Param{NewParam("value", "string")},
+ },
+ },
+ }
+
+ code, err := GenerateClientGo(svc, "host")
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify the code is valid Go (can't actually compile without wasip1)
+ codeStr := string(code)
+
+ // Check for errors import when methods have errors
+ Expect(codeStr).To(ContainSubstring(`"errors"`))
+ Expect(codeStr).To(ContainSubstring("errors.New"))
+ })
+
+ It("should not include errors import when service has no methods with errors", func() {
+ svc := Service{
+ Name: "Config",
+ Permission: "config",
+ Interface: "ConfigService",
+ Methods: []Method{
+ {
+ Name: "Get",
+ HasError: false,
+ Params: []Param{NewParam("key", "string")},
+ Returns: []Param{NewParam("value", "string"), NewParam("exists", "bool")},
+ },
+ {
+ Name: "List",
+ HasError: false,
+ Params: []Param{NewParam("prefix", "string")},
+ Returns: []Param{NewParam("keys", "[]string")},
+ },
+ },
+ }
+
+ code, err := GenerateClientGo(svc, "host")
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check that errors is NOT imported when no methods have errors
+ Expect(codeStr).NotTo(ContainSubstring(`"errors"`))
+ Expect(codeStr).NotTo(ContainSubstring("errors.New"))
+ })
+
+ It("should generate valid Go code structure", func() {
+ svc := Service{
+ Name: "SubsonicAPI",
+ Permission: "subsonicapi",
+ Interface: "SubsonicAPIService",
+ Methods: []Method{
+ {
+ Name: "Call",
+ HasError: true,
+ Params: []Param{NewParam("uri", "string")},
+ Returns: []Param{NewParam("response", "string")},
+ },
+ },
+ }
+
+ code, err := GenerateClientGo(svc, "host")
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check for generated header
+ Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
+
+ // Check for build tag
+ Expect(codeStr).To(ContainSubstring("//go:build wasip1"))
+
+ // Check for package declaration
+ Expect(codeStr).To(ContainSubstring("package host"))
+
+ // Check for wasmimport directive
+ Expect(codeStr).To(ContainSubstring("//go:wasmimport extism:host/user"))
+
+ // Check for PDK import
+ Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk"))
+ })
+
+ })
+
+ Describe("GenerateClientGoStub", func() {
+ It("should generate valid mock code with testify/mock", func() {
+ svc := Service{
+ Name: "Cache",
+ Permission: "cache",
+ Interface: "CacheService",
+ Doc: "CacheService provides caching capabilities.",
+ Methods: []Method{
+ {
+ Name: "Get",
+ Doc: "Get retrieves a value from the cache.",
+ Params: []Param{
+ {Name: "key", Type: "string"},
+ },
+ Returns: []Param{
+ {Name: "value", Type: "string"},
+ {Name: "exists", Type: "bool"},
+ },
+ },
+ },
+ }
+
+ code, err := GenerateClientGoStub(svc, "ndpdk")
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify it's valid Go code
+ _, err = format.Source(code)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check for build tag (non-WASM)
+ Expect(codeStr).To(ContainSubstring("//go:build !wasip1"))
+
+ // Check for package declaration
+ Expect(codeStr).To(ContainSubstring("package ndpdk"))
+
+ // Check for mock comment
+ Expect(codeStr).To(ContainSubstring("mock implementations for non-WASM builds"))
+
+ // Check for testify/mock import
+ Expect(codeStr).To(ContainSubstring(`"github.com/stretchr/testify/mock"`))
+
+ // Check for private mock struct
+ Expect(codeStr).To(ContainSubstring("type mockCacheService struct"))
+ Expect(codeStr).To(ContainSubstring("mock.Mock"))
+
+ // Check for exported mock instance
+ Expect(codeStr).To(ContainSubstring("var CacheMock = &mockCacheService{}"))
+
+ // Check for mock method
+ Expect(codeStr).To(ContainSubstring("func (m *mockCacheService) Get(key string)"))
+ Expect(codeStr).To(ContainSubstring("m.Called(key)"))
+
+ // Check for wrapper function delegating to mock
+ Expect(codeStr).To(ContainSubstring("func CacheGet(key string)"))
+ Expect(codeStr).To(ContainSubstring("return CacheMock.Get(key)"))
+
+ // Stub files should NOT have request/response types (they're not needed)
+ Expect(codeStr).NotTo(ContainSubstring("Request struct"))
+ Expect(codeStr).NotTo(ContainSubstring("Response struct"))
+ })
+
+ It("should generate correct mock return values for different types", func() {
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "GetString",
+ Params: []Param{
+ {Name: "key", Type: "string"},
+ },
+ Returns: []Param{
+ {Name: "value", Type: "string"},
+ },
+ HasError: true,
+ },
+ {
+ Name: "GetInt64",
+ Params: []Param{
+ {Name: "key", Type: "string"},
+ },
+ Returns: []Param{
+ {Name: "value", Type: "int64"},
+ {Name: "exists", Type: "bool"},
+ },
+ HasError: true,
+ },
+ {
+ Name: "GetBytes",
+ Params: []Param{
+ {Name: "key", Type: "string"},
+ },
+ Returns: []Param{
+ {Name: "value", Type: "[]byte"},
+ },
+ HasError: true,
+ },
+ },
+ }
+
+ code, err := GenerateClientGoStub(svc, "ndpdk")
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify it's valid Go code
+ _, err = format.Source(code)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check string return uses args.String(0)
+ Expect(codeStr).To(ContainSubstring("args.String(0)"))
+
+ // Check int64 return uses args.Get(0).(int64)
+ Expect(codeStr).To(ContainSubstring("args.Get(0).(int64)"))
+
+ // Check bool return uses args.Bool(1)
+ Expect(codeStr).To(ContainSubstring("args.Bool(1)"))
+
+ // Check []byte return uses args.Get(0).([]byte)
+ Expect(codeStr).To(ContainSubstring("args.Get(0).([]byte)"))
+
+ // Check error returns use args.Error(N)
+ Expect(codeStr).To(ContainSubstring("args.Error("))
+ })
+ })
+
+ Describe("Integration", func() {
+ It("should generate compilable code from parsed source", func() {
+ // This is an integration test that verifies the full pipeline
+ src := `package host
+
+import "context"
+
+// TestService is a test service.
+//nd:hostservice name=Test permission=test
+type TestService interface {
+ // DoSomething does something.
+ //nd:hostfunc
+ DoSomething(ctx context.Context, input string) (output string, err error)
+}
+`
+ // Create temporary directory
+ tmpDir := GinkgoT().TempDir()
+ path := tmpDir + "/test.go"
+ err := writeFile(path, src)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Parse
+ services, err := ParseDirectory(tmpDir)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(services).To(HaveLen(1))
+
+ // Generate
+ code, err := GenerateHost(services[0], "host")
+ Expect(err).NotTo(HaveOccurred())
+
+ // Format (validates syntax)
+ formatted, err := format.Source(code)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify key elements
+ codeStr := string(formatted)
+ Expect(codeStr).To(ContainSubstring("RegisterTestHostFunctions"))
+ Expect(codeStr).To(ContainSubstring(`"test_dosomething"`))
+ })
+ })
+
+ Describe("GenerateCapabilityGo", func() {
+ It("should generate valid Go code for a non-required capability", func() {
+ cap := Capability{
+ Name: "metadata",
+ Interface: "MetadataAgent",
+ Required: false,
+ Doc: "MetadataAgent provides metadata retrieval.",
+ Methods: []Export{
+ {
+ Name: "GetArtistBiography",
+ ExportName: "nd_get_artist_biography",
+ Input: Param{Type: "ArtistInput"},
+ Output: Param{Type: "ArtistBiographyOutput"},
+ Doc: "Returns artist biography",
+ },
+ {
+ Name: "GetArtistImages",
+ ExportName: "nd_get_artist_images",
+ Input: Param{Type: "ArtistInput"},
+ Output: Param{Type: "ArtistImagesOutput"},
+ Doc: "Returns artist images",
+ },
+ },
+ Structs: []StructDef{
+ {
+ Name: "ArtistInput",
+ Fields: []FieldDef{
+ {Name: "ID", Type: "string", JSONTag: "id"},
+ {Name: "Name", Type: "string", JSONTag: "name"},
+ },
+ },
+ {
+ Name: "ArtistBiographyOutput",
+ Fields: []FieldDef{
+ {Name: "Biography", Type: "string", JSONTag: "biography"},
+ },
+ },
+ {
+ Name: "ArtistImagesOutput",
+ Fields: []FieldDef{
+ {Name: "Images", Type: "[]ImageInfo", JSONTag: "images"},
+ },
+ },
+ {
+ Name: "ImageInfo",
+ Fields: []FieldDef{
+ {Name: "URL", Type: "string", JSONTag: "url"},
+ {Name: "Size", Type: "int32", JSONTag: "size"},
+ },
+ },
+ },
+ }
+
+ code, err := GenerateCapabilityGo(cap, "metadata")
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check for build tag
+ Expect(codeStr).To(ContainSubstring("//go:build wasip1"))
+
+ // Check for package declaration
+ Expect(codeStr).To(ContainSubstring("package metadata"))
+
+ // Check for marker interface (non-required)
+ Expect(codeStr).To(ContainSubstring("type Metadata interface{}"))
+
+ // Check for provider interfaces
+ Expect(codeStr).To(ContainSubstring("type ArtistBiographyProvider interface"))
+ Expect(codeStr).To(ContainSubstring("type ArtistImagesProvider interface"))
+
+ // Check for Register function with type assertions
+ Expect(codeStr).To(ContainSubstring("func Register(impl Metadata)"))
+ Expect(codeStr).To(ContainSubstring("impl.(ArtistBiographyProvider)"))
+
+ // Check for export wrappers
+ Expect(codeStr).To(ContainSubstring("//go:wasmexport nd_get_artist_biography"))
+ Expect(codeStr).To(ContainSubstring("func _NdGetArtistBiography()"))
+
+ // Check for NotImplementedCode handling
+ Expect(codeStr).To(ContainSubstring("NotImplementedCode"))
+ Expect(codeStr).To(ContainSubstring("return NotImplementedCode"))
+
+ // Check struct definitions
+ Expect(codeStr).To(ContainSubstring("type ArtistInput struct"))
+ Expect(codeStr).To(ContainSubstring("type ImageInfo struct"))
+ })
+
+ It("should generate valid Go code for a required capability", func() {
+ cap := Capability{
+ Name: "scrobbler",
+ Interface: "Scrobbler",
+ Required: true,
+ Methods: []Export{
+ {
+ Name: "IsAuthorized",
+ ExportName: "nd_scrobbler_is_authorized",
+ Input: Param{Type: "AuthInput"},
+ Output: Param{Type: "AuthOutput"},
+ },
+ {
+ Name: "Scrobble",
+ ExportName: "nd_scrobbler_scrobble",
+ Input: Param{Type: "ScrobbleInput"},
+ Output: Param{Type: "ScrobblerOutput"},
+ },
+ },
+ Structs: []StructDef{
+ {Name: "AuthInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}},
+ {Name: "AuthOutput", Fields: []FieldDef{{Name: "Authorized", Type: "bool", JSONTag: "authorized"}}},
+ {Name: "ScrobbleInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}},
+ {Name: "ScrobblerOutput", Fields: []FieldDef{{Name: "Error", Type: "*string", JSONTag: "error", OmitEmpty: true}}},
+ },
+ }
+
+ code, err := GenerateCapabilityGo(cap, "scrobbler")
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check for full interface (required capability)
+ Expect(codeStr).To(ContainSubstring("type Scrobbler interface {"))
+ Expect(codeStr).To(ContainSubstring("IsAuthorized(AuthInput) (AuthOutput, error)"))
+ Expect(codeStr).To(ContainSubstring("Scrobble(ScrobbleInput) (ScrobblerOutput, error)"))
+
+ // Should NOT have provider interfaces for required capability
+ Expect(codeStr).NotTo(ContainSubstring("AuthProvider interface"))
+
+ // Register should directly assign methods
+ Expect(codeStr).To(ContainSubstring("func Register(impl Scrobbler)"))
+ Expect(codeStr).To(ContainSubstring("impl.IsAuthorized"))
+ })
+
+ It("should include type aliases and consts", func() {
+ cap := Capability{
+ Name: "scrobbler",
+ Interface: "Scrobbler",
+ Required: true,
+ Methods: []Export{
+ {
+ Name: "Scrobble",
+ ExportName: "nd_scrobble",
+ Input: Param{Type: "ScrobbleInput"},
+ Output: Param{Type: "ScrobblerOutput"},
+ },
+ },
+ Structs: []StructDef{
+ {Name: "ScrobbleInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}},
+ {Name: "ScrobblerOutput", Fields: []FieldDef{{Name: "ErrorType", Type: "*ScrobblerErrorType", JSONTag: "errorType", OmitEmpty: true}}},
+ },
+ TypeAliases: []TypeAlias{
+ {Name: "ScrobblerErrorType", Type: "string", Doc: "ScrobblerErrorType indicates error handling."},
+ },
+ Consts: []ConstGroup{
+ {
+ Type: "ScrobblerErrorType",
+ Values: []ConstDef{
+ {Name: "ScrobblerErrorNone", Value: `"none"`, Doc: "No error"},
+ {Name: "ScrobblerErrorRetry", Value: `"retry"`, Doc: "Retry later"},
+ },
+ },
+ },
+ }
+
+ code, err := GenerateCapabilityGo(cap, "scrobbler")
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check type alias
+ Expect(codeStr).To(ContainSubstring("type ScrobblerErrorType string"))
+
+ // Check consts - all consts should have type annotation
+ Expect(codeStr).To(ContainSubstring("ScrobblerErrorNone ScrobblerErrorType ="))
+ Expect(codeStr).To(ContainSubstring(`"none"`))
+ Expect(codeStr).To(ContainSubstring("ScrobblerErrorRetry ScrobblerErrorType ="))
+ Expect(codeStr).To(ContainSubstring(`"retry"`))
+ })
+ })
+
+ Describe("GenerateCapabilityGoStub", func() {
+ It("should generate valid stub code for non-WASM builds", func() {
+ cap := Capability{
+ Name: "metadata",
+ Interface: "MetadataAgent",
+ Required: false,
+ Methods: []Export{
+ {
+ Name: "GetArtistBiography",
+ ExportName: "nd_get_artist_biography",
+ Input: Param{Type: "ArtistInput"},
+ Output: Param{Type: "ArtistBiographyOutput"},
+ },
+ },
+ Structs: []StructDef{
+ {Name: "ArtistInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
+ {Name: "ArtistBiographyOutput", Fields: []FieldDef{{Name: "Biography", Type: "string", JSONTag: "biography"}}},
+ },
+ }
+
+ code, err := GenerateCapabilityGoStub(cap, "metadata")
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check for non-WASM build tag
+ Expect(codeStr).To(ContainSubstring("//go:build !wasip1"))
+
+ // Check for package declaration
+ Expect(codeStr).To(ContainSubstring("package metadata"))
+
+ // Check for no-op Register
+ Expect(codeStr).To(ContainSubstring("func Register(_ Metadata) {}"))
+
+ // Check struct definitions are present
+ Expect(codeStr).To(ContainSubstring("type ArtistInput struct"))
+
+ // Check there are no export wrappers
+ Expect(codeStr).NotTo(ContainSubstring("//go:wasmexport"))
+ Expect(codeStr).NotTo(ContainSubstring("pdk.InputJSON"))
+ })
+ })
+
+ Describe("End-to-end capability generation", func() {
+ It("should parse and generate capability code from source", func() {
+ src := `package capabilities
+
+// Lifecycle provides plugin lifecycle hooks.
+//nd:capability name=lifecycle
+type Lifecycle interface {
+ // OnInit is called when the plugin is loaded.
+ //nd:export name=nd_on_init
+ OnInit(OnInitInput) (OnInitOutput, error)
+}
+
+// OnInitInput is the input for OnInit.
+type OnInitInput struct {
+}
+
+// OnInitOutput is the output for OnInit.
+type OnInitOutput struct {
+ // Error is the error message if initialization failed.
+ Error *string ` + "`json:\"error,omitempty\"`" + `
+}
+`
+ // Create temporary directory
+ tmpDir := GinkgoT().TempDir()
+ path := tmpDir + "/lifecycle.go"
+ err := writeFile(path, src)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Parse
+ capabilities, err := ParseCapabilities(tmpDir)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(capabilities).To(HaveLen(1))
+
+ cap := capabilities[0]
+ Expect(cap.Name).To(Equal("lifecycle"))
+ Expect(cap.Methods).To(HaveLen(1))
+
+ // Generate WASM code
+ code, err := GenerateCapabilityGo(cap, "lifecycle")
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+ Expect(codeStr).To(ContainSubstring("//go:wasmexport nd_on_init"))
+ Expect(codeStr).To(ContainSubstring("type InitProvider interface"))
+
+ // Generate stub code
+ stubCode, err := GenerateCapabilityGoStub(cap, "lifecycle")
+ Expect(err).NotTo(HaveOccurred())
+
+ stubStr := string(stubCode)
+ Expect(stubStr).To(ContainSubstring("//go:build !wasip1"))
+ Expect(stubStr).To(ContainSubstring("func Register(_ Lifecycle) {}"))
+ })
+ })
+})
+
+var _ = Describe("Rust Generation", func() {
+ Describe("skipSerializingFunc", func() {
+ It("should return Option::is_none for pointer, slice, and map types", func() {
+ Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none"))
+ Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none"))
+ Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none"))
+ Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none"))
+ Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none"))
+ })
+
+ It("should return String::is_empty for string type", func() {
+ Expect(skipSerializingFunc("string")).To(Equal("String::is_empty"))
+ })
+
+ It("should return std::ops::Not::not for bool type", func() {
+ Expect(skipSerializingFunc("bool")).To(Equal("std::ops::Not::not"))
+ })
+
+ It("should return is_zero_* functions for numeric types", func() {
+ Expect(skipSerializingFunc("int32")).To(Equal("is_zero_i32"))
+ Expect(skipSerializingFunc("uint32")).To(Equal("is_zero_u32"))
+ Expect(skipSerializingFunc("int64")).To(Equal("is_zero_i64"))
+ Expect(skipSerializingFunc("uint64")).To(Equal("is_zero_u64"))
+ Expect(skipSerializingFunc("float32")).To(Equal("is_zero_f32"))
+ Expect(skipSerializingFunc("float64")).To(Equal("is_zero_f64"))
+ })
+
+ It("should return Option::is_none for unknown types", func() {
+ Expect(skipSerializingFunc("CustomType")).To(Equal("Option::is_none"))
+ })
+ })
+
+ Describe("rustOutputType", func() {
+ It("should convert Go primitives to Rust primitives", func() {
+ Expect(rustOutputType("bool")).To(Equal("bool"))
+ Expect(rustOutputType("string")).To(Equal("String"))
+ Expect(rustOutputType("int")).To(Equal("i32"))
+ Expect(rustOutputType("int32")).To(Equal("i32"))
+ Expect(rustOutputType("int64")).To(Equal("i64"))
+ Expect(rustOutputType("float32")).To(Equal("f32"))
+ Expect(rustOutputType("float64")).To(Equal("f64"))
+ })
+
+ It("should strip pointer prefix", func() {
+ // NOTE: This behavior is incorrect for pointer to primitives.
+ // "*string" returns "string" instead of "String", which would generate
+ // invalid Rust code. No current capability uses this pattern.
+ // See TODO in rustOutputType function.
+ Expect(rustOutputType("*string")).To(Equal("string"))
+ Expect(rustOutputType("*MyStruct")).To(Equal("MyStruct"))
+ })
+
+ It("should pass through unknown types", func() {
+ Expect(rustOutputType("CustomType")).To(Equal("CustomType"))
+ Expect(rustOutputType("MyStruct")).To(Equal("MyStruct"))
+ })
+ })
+
+ Describe("isPrimitiveRustType", func() {
+ It("should return true for primitive Go types", func() {
+ Expect(isPrimitiveRustType("bool")).To(BeTrue())
+ Expect(isPrimitiveRustType("string")).To(BeTrue())
+ Expect(isPrimitiveRustType("int")).To(BeTrue())
+ Expect(isPrimitiveRustType("int32")).To(BeTrue())
+ Expect(isPrimitiveRustType("int64")).To(BeTrue())
+ Expect(isPrimitiveRustType("float32")).To(BeTrue())
+ Expect(isPrimitiveRustType("float64")).To(BeTrue())
+ })
+
+ It("should return false for non-primitive types", func() {
+ Expect(isPrimitiveRustType("MyStruct")).To(BeFalse())
+ Expect(isPrimitiveRustType("CustomType")).To(BeFalse())
+ Expect(isPrimitiveRustType("[]string")).To(BeFalse())
+ Expect(isPrimitiveRustType("map[string]int")).To(BeFalse())
+ })
+
+ It("should handle pointer types by stripping prefix", func() {
+ Expect(isPrimitiveRustType("*string")).To(BeTrue())
+ Expect(isPrimitiveRustType("*int64")).To(BeTrue())
+ Expect(isPrimitiveRustType("*MyStruct")).To(BeFalse())
+ })
+ })
+
+ Describe("GenerateCapabilityRust", func() {
+ It("should generate valid Rust code with primitive output types", func() {
+ cap := Capability{
+ Name: "test",
+ Interface: "TestAgent",
+ Required: true,
+ SourceFile: "test",
+ Methods: []Export{
+ {
+ Name: "GetBool",
+ ExportName: "nd_get_bool",
+ Input: Param{Type: "BoolInput"},
+ Output: Param{Type: "bool"},
+ },
+ {
+ Name: "GetString",
+ ExportName: "nd_get_string",
+ Input: Param{Type: "StrInput"},
+ Output: Param{Type: "string"},
+ },
+ {
+ Name: "GetInt",
+ ExportName: "nd_get_int",
+ Input: Param{Type: "IntInput"},
+ Output: Param{Type: "int32"},
+ },
+ },
+ Structs: []StructDef{
+ {Name: "BoolInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
+ {Name: "StrInput", Fields: []FieldDef{{Name: "Key", Type: "string", JSONTag: "key"}}},
+ {Name: "IntInput", Fields: []FieldDef{{Name: "Index", Type: "int32", JSONTag: "index"}}},
+ },
+ }
+
+ code, err := GenerateCapabilityRust(cap)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check that primitive output types are not prefixed with $crate::
+ // The template should use isPrimitiveRust to determine this
+ Expect(codeStr).To(ContainSubstring("FnResult>"))
+ Expect(codeStr).To(ContainSubstring("FnResult>"))
+ Expect(codeStr).To(ContainSubstring("FnResult>"))
+
+ // Verify that primitive output types don't use $crate:: prefix in FnResult
+ // The pattern "$crate::test::bool>" would indicate incorrect generation
+ Expect(codeStr).NotTo(ContainSubstring("$crate::test::bool>"))
+ Expect(codeStr).NotTo(ContainSubstring("$crate::test::String>"))
+ Expect(codeStr).NotTo(ContainSubstring("$crate::test::i32>"))
+ })
+
+ It("should generate valid Rust code with struct output types", func() {
+ cap := Capability{
+ Name: "metadata",
+ Interface: "MetadataAgent",
+ Required: true,
+ SourceFile: "metadata",
+ Methods: []Export{
+ {
+ Name: "GetArtist",
+ ExportName: "nd_get_artist",
+ Input: Param{Type: "ArtistInput"},
+ Output: Param{Type: "ArtistOutput"},
+ },
+ },
+ Structs: []StructDef{
+ {Name: "ArtistInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
+ {Name: "ArtistOutput", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}},
+ },
+ }
+
+ code, err := GenerateCapabilityRust(cap)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Non-primitive struct types should use $crate:: prefix
+ Expect(codeStr).To(ContainSubstring("$crate::metadata::ArtistOutput"))
+ })
+
+ It("should generate valid Rust code with pointer output types", func() {
+ cap := Capability{
+ Name: "test",
+ Interface: "TestAgent",
+ Required: true,
+ SourceFile: "test",
+ Methods: []Export{
+ {
+ Name: "GetOptionalStruct",
+ ExportName: "nd_get_optional_struct",
+ Input: Param{Type: "Input"},
+ Output: Param{Type: "*Output"},
+ },
+ },
+ Structs: []StructDef{
+ {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
+ {Name: "Output", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}},
+ },
+ }
+
+ code, err := GenerateCapabilityRust(cap)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Pointer to struct should strip pointer and use struct type with $crate::
+ Expect(codeStr).To(ContainSubstring("$crate::test::Output>"))
+ // Pointer output types should NOT have Option<> wrapping - Result handles optionality
+ Expect(codeStr).NotTo(ContainSubstring("Option<"))
+ })
+
+ It("should include all float types correctly", func() {
+ cap := Capability{
+ Name: "test",
+ Interface: "TestAgent",
+ Required: true,
+ SourceFile: "test",
+ Methods: []Export{
+ {
+ Name: "GetFloat32",
+ ExportName: "nd_get_float32",
+ Input: Param{Type: "Input"},
+ Output: Param{Type: "float32"},
+ },
+ {
+ Name: "GetFloat64",
+ ExportName: "nd_get_float64",
+ Input: Param{Type: "Input"},
+ Output: Param{Type: "float64"},
+ },
+ },
+ Structs: []StructDef{
+ {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
+ },
+ }
+
+ code, err := GenerateCapabilityRust(cap)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ Expect(codeStr).To(ContainSubstring("FnResult>"))
+ Expect(codeStr).To(ContainSubstring("FnResult>"))
+ })
+ })
+
+ Describe("GenerateClientRust", func() {
+ It("should generate Option for (value, exists bool) pattern", func() {
+ svc := Service{
+ Name: "Config",
+ Permission: "config",
+ Interface: "ConfigService",
+ Methods: []Method{
+ {
+ Name: "Get",
+ Params: []Param{
+ {Name: "key", Type: "string", JSONName: "key"},
+ },
+ Returns: []Param{
+ {Name: "value", Type: "string", JSONName: "value"},
+ {Name: "exists", Type: "bool", JSONName: "exists"},
+ },
+ },
+ },
+ }
+
+ code, err := GenerateClientRust(svc)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Should generate Option return type, not (String, bool)
+ Expect(codeStr).To(ContainSubstring("Result