diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 4fc7a5b73..078267fae 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -9,12 +9,21 @@ 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
+ && apt-get -y install --no-install-recommends ffmpeg
-# [Optional] Uncomment the next line to use go get to install anything else you need
-# RUN go get -x
+# Install TagLib from cross-taglib releases
+ARG CROSS_TAGLIB_VERSION="2.1.1-1"
+ARG TARGETARCH
+RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
+ && wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
+ && tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \
+ && mv /usr/include/taglib/* /usr/include/ \
+ && rmdir /usr/include/taglib \
+ && rm /tmp/cross-taglib.tar.gz /usr/provenance.json
+
+ENV CGO_CFLAGS_ALLOW="--define-prefix"
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index f339f62f7..0519f25fc 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -4,10 +4,11 @@
"dockerfile": "Dockerfile",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
- "VARIANT": "1.24",
+ "VARIANT": "1.25",
// Options
"INSTALL_NODE": "true",
- "NODE_VERSION": "v20"
+ "NODE_VERSION": "v24",
+ "CROSS_TAGLIB_VERSION": "2.1.1-1"
}
},
"workspaceMount": "",
@@ -54,12 +55,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/.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/pipeline.yml b/.github/workflows/pipeline.yml
index 4ac1b2c6b..5c57fdaa5 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -14,7 +14,8 @@ concurrency:
cancel-in-progress: true
env:
- CROSS_TAGLIB_VERSION: "2.0.2-1"
+ CROSS_TAGLIB_VERSION: "2.1.1-1"
+ CGO_CFLAGS_ALLOW: "--define-prefix"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
jobs:
@@ -25,7 +26,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,7 +64,7 @@ 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
@@ -71,14 +72,14 @@ jobs:
version: ${{ env.CROSS_TAGLIB_VERSION }}
- 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,12 +89,22 @@ 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
@@ -106,7 +117,14 @@ jobs:
- name: Test
run: |
pkg-config --define-prefix --cflags --libs taglib # for debugging
- go test -shuffle=on -tags netgo -race -cover ./... -v
+ go test -shuffle=on -tags netgo -race ./... -v
+
+ - name: Test ndpgen
+ run: |
+ cd plugins/cmd/ndpgen
+ go test -shuffle=on -v
+ go build -o ndpgen .
+ ./ndpgen --help
js:
name: Test JS code
@@ -114,10 +132,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 +163,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 +175,8 @@ jobs:
exit 1
fi
done
+ - run: ./.github/workflows/validate-translations.sh -v
+
check-push-enabled:
name: Check Docker configuration
@@ -189,7 +209,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
@@ -215,7 +235,7 @@ jobs:
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
@@ -246,7 +266,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
@@ -254,18 +274,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@v7
+ 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@v7
with:
path: /tmp/digests
pattern: digests-*
@@ -280,28 +337,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@v3
+ 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 +372,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@v7
with:
path: ./binaries
pattern: navidrome-windows*
@@ -337,7 +393,7 @@ jobs:
du -h binaries/msi/*.msi
- name: Upload MSI files
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
@@ -350,12 +406,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@v7
with:
path: ./binaries
pattern: navidrome-*
@@ -381,7 +437,7 @@ jobs:
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: packages
path: dist/navidrome_0*
@@ -404,13 +460,13 @@ jobs:
item: ${{ fromJson(needs.release.outputs.package_list) }}
steps:
- name: Download all-packages artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: packages
path: ./dist
- name: Upload all-packages artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index c8bf3ae7f..69e6ac99e 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
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..27c02da32 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
/navidrome
/iTunes*.xml
/tmp
+/bin
data/*
vendor/*/
wiki
@@ -16,6 +17,7 @@ master.zip
testDB
cache/*
*.swp
+coverage.out
dist
music
*.db*
@@ -23,7 +25,15 @@ music
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/
+go.work*
\ No newline at end of file
diff --git a/.nvmrc b/.nvmrc
index 9a2a0e219..54c65116f 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v20
+v24
diff --git a/Dockerfile b/Dockerfile
index 4b4c3d18c..64b1c768a 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 && \
@@ -26,12 +26,14 @@ COPY --from=xx-build /out/ /usr/bin/
########################################################################################################################
### Get TagLib
-FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 AS taglib-build
+FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
ARG TARGETPLATFORM
-ARG CROSS_TAGLIB_VERSION=2.0.2-1
+ARG CROSS_TAGLIB_VERSION=2.1.1-1
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
+# wget in busybox can't follow redirects
RUN < /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=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \
+ CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
+ REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
+ if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
+ echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
+ 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=$$PATH:./bin golangci-lint run --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code
@@ -64,7 +103,7 @@ 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
@@ -72,6 +111,15 @@ wire: check_go_env ##@Development Update Dependency Injection
go tool wire gen -tags=netgo ./...
.PHONY: wire
+gen: check_go_env ##@Development Run go generate for code generation
+ go generate ./...
+ cd plugins/cmd/ndpgen && go run . -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
@@ -153,6 +201,20 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows
@du -h binaries/msi/*.msi
.PHONY: docker-msi
+run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag=
+ @if [ -z "$(tag)" ]; then echo "Usage: make run-docker 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: run-docker
+
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
goreleaser release -f release/goreleaser.yml --clean --skip=publish --snapshot
diff --git a/adapters/deezer/client.go b/adapters/deezer/client.go
new file mode 100644
index 000000000..6c97745a3
--- /dev/null
+++ b/adapters/deezer/client.go
@@ -0,0 +1,219 @@
+package deezer
+
+import (
+ bytes "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
+ language string
+ jwt jwtToken
+}
+
+func newClient(hc httpDoer, language string) *client {
+ return &client{
+ httpDoer: hc,
+ language: language,
+ }
+}
+
+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) (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", c.language)
+ req.Header.Set("Authorization", "Bearer "+jwt)
+
+ log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", c.language)
+ 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..c88c2bcb6
--- /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/v2/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"`
+ }
+
+ 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 := token.Expiration()
+ if 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..b0c2d195d
--- /dev/null
+++ b/adapters/deezer/client_auth_test.go
@@ -0,0 +1,293 @@
+package deezer
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v2/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, "en")
+ 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
+ Expect(testToken.Expiration().IsZero()).To(BeTrue())
+
+ 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 := 0; i < 100; i++ {
+ cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
+ time.Sleep(1 * time.Millisecond)
+ }
+ })
+
+ // Reader goroutine
+ wg.Go(func() {
+ for i := 0; i < 100; i++ {
+ 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..7e4f7a49f
--- /dev/null
+++ b/adapters/deezer/client_test.go
@@ -0,0 +1,195 @@
+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, "en")
+ })
+
+ 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("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.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)
+ Expect(err).To(BeNil())
+ Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
+ Expect(bio).ToNot(ContainSubstring(""))
+ Expect(bio).ToNot(ContainSubstring("
"))
+ })
+
+ It("uses the configured language", func() {
+ client = newClient(httpClient, "fr")
+ // Mock JWT token for the new client instance with a valid JWT
+ 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))),
+ })
+ f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
+ Expect(err).To(BeNil())
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
+
+ _, err = client.getArtistBio(GinkgoT().Context(), 27)
+ 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.json")
+ Expect(err).To(BeNil())
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
+
+ _, err = client.getArtistBio(GinkgoT().Context(), 27)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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..7ec48b38d
--- /dev/null
+++ b/adapters/deezer/deezer.go
@@ -0,0 +1,159 @@
+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
+}
+
+func deezerConstructor(dataStore model.DataStore) agents.Interface {
+ agent := &deezerAgent{dataStore: dataStore}
+ httpClient := &http.Client{
+ Timeout: consts.DefaultHttpClientTimeOut,
+ }
+ cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
+ agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language)
+ 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,
+ }
+ })
+ 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
+ }
+
+ return s.client.getArtistBio(ctx, artist.ID)
+}
+
+func init() {
+ conf.AddHook(func() {
+ if conf.Server.Deezer.Enabled {
+ agents.Register(deezerAgentName, deezerConstructor)
+ }
+ })
+}
diff --git a/adapters/deezer/deezer_suite_test.go b/adapters/deezer/deezer_suite_test.go
new file mode 100644
index 000000000..a42282da7
--- /dev/null
+++ b/adapters/deezer/deezer_suite_test.go
@@ -0,0 +1,17 @@
+package deezer
+
+import (
+ "testing"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestDeezer(t *testing.T) {
+ tests.Init(t, false)
+ log.SetLevel(log.LevelFatal)
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Deezer Test Suite")
+}
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/gotaglib/end_to_end_test.go b/adapters/gotaglib/end_to_end_test.go
new file mode 100644
index 000000000..4a93f5b83
--- /dev/null
+++ b/adapters/gotaglib/end_to_end_test.go
@@ -0,0 +1,274 @@
+package gotaglib
+
+import (
+ "io/fs"
+ "os"
+ "time"
+
+ "github.com/djherbis/times"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/metadata"
+ "github.com/navidrome/navidrome/utils/gg"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+type testFileInfo struct {
+ fs.FileInfo
+}
+
+func (t testFileInfo) BirthTime() time.Time {
+ if ts := times.Get(t.FileInfo); ts.HasBirthTime() {
+ return ts.BirthTime()
+ }
+ return t.FileInfo.ModTime()
+}
+
+var _ = Describe("Extractor", func() {
+ toP := func(name, sortName, mbid string) model.Participant {
+ return model.Participant{
+ Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid},
+ }
+ }
+
+ roles := []struct {
+ model.Role
+ model.ParticipantList
+ }{
+ {model.RoleComposer, model.ParticipantList{
+ toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"),
+ toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"),
+ }},
+ {model.RoleLyricist, model.ParticipantList{
+ toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"),
+ toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"),
+ }},
+ {model.RoleArranger, model.ParticipantList{
+ toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"),
+ toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"),
+ }},
+ {model.RoleConductor, model.ParticipantList{
+ toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"),
+ toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"),
+ }},
+ {model.RoleDirector, model.ParticipantList{
+ toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"),
+ toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"),
+ }},
+ {model.RoleEngineer, model.ParticipantList{
+ toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"),
+ toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"),
+ }},
+ {model.RoleProducer, model.ParticipantList{
+ toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"),
+ toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"),
+ }},
+ {model.RoleRemixer, model.ParticipantList{
+ toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"),
+ toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"),
+ }},
+ {model.RoleDJMixer, model.ParticipantList{
+ toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"),
+ toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"),
+ }},
+ {model.RoleMixer, model.ParticipantList{
+ toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"),
+ toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"),
+ }},
+ }
+
+ var e *extractor
+
+ parseTestFile := func(path string) *model.MediaFile {
+ mds, err := e.Parse(path)
+ Expect(err).ToNot(HaveOccurred())
+
+ info, ok := mds[path]
+ Expect(ok).To(BeTrue())
+
+ fileInfo, err := os.Stat(path)
+ Expect(err).ToNot(HaveOccurred())
+ info.FileInfo = testFileInfo{FileInfo: fileInfo}
+
+ metadata := metadata.New(path, info)
+ mf := metadata.ToMediaFile(1, "folderID")
+ return &mf
+ }
+
+ BeforeEach(func() {
+ e = &extractor{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", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
+ )
+ })
+
+ Describe("lyrics", func() {
+ makeLyrics := func(code, secondLine string) model.Lyrics {
+ return model.Lyrics{
+ DisplayArtist: "",
+ DisplayTitle: "",
+ Lang: code,
+ Line: []model.Line{
+ {Start: gg.P(int64(0)), Value: "This is"},
+ {Start: gg.P(int64(2500)), Value: secondLine},
+ },
+ Offset: nil,
+ Synced: true,
+ }
+ }
+
+ It("should fetch both synced and unsynced lyrics in mixed flac", func() {
+ mf := parseTestFile("tests/fixtures/mixed-lyrics.flac")
+
+ lyrics, err := mf.StructuredLyrics()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lyrics).To(HaveLen(2))
+
+ Expect(lyrics[0].Synced).To(BeTrue())
+ Expect(lyrics[1].Synced).To(BeFalse())
+ })
+
+ It("should handle mp3 with uslt and sylt", func() {
+ mf := parseTestFile("tests/fixtures/test.mp3")
+
+ lyrics, err := mf.StructuredLyrics()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lyrics).To(HaveLen(4))
+
+ engSylt := makeLyrics("eng", "English SYLT")
+ engUslt := makeLyrics("eng", "English")
+ unsSylt := makeLyrics("xxx", "unspecified SYLT")
+ unsUslt := makeLyrics("xxx", "unspecified")
+
+ Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
+ })
+
+ DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
+ mf := parseTestFile("tests/fixtures/" + file)
+
+ lyrics, err := mf.StructuredLyrics()
+ Expect(err).To(Not(HaveOccurred()))
+ Expect(lyrics).To(HaveLen(2))
+
+ unspec := makeLyrics("xxx", "unspecified")
+ eng := makeLyrics("xxx", "English")
+
+ if isId3 {
+ eng.Lang = "eng"
+ }
+
+ Expect(lyrics).To(Or(
+ Equal(model.LyricList{unspec, eng}),
+ Equal(model.LyricList{eng, unspec})))
+ },
+ Entry("flac", "test.flac", false),
+ Entry("m4a", "test.m4a", false),
+ Entry("ogg", "test.ogg", false),
+ Entry("wma", "test.wma", false),
+ Entry("wv", "test.wv", false),
+ Entry("wav", "test.wav", true),
+ Entry("aiff", "test.aiff", true),
+ )
+ })
+
+ Describe("Participants", func() {
+ DescribeTable("test tags consistent across formats", func(format string) {
+ mf := parseTestFile("tests/fixtures/test." + format)
+
+ for _, data := range roles {
+ role := data.Role
+ artists := data.ParticipantList
+
+ actual := mf.Participants[role]
+ Expect(actual).To(HaveLen(len(artists)))
+
+ for i := range artists {
+ actualArtist := actual[i]
+ expectedArtist := artists[i]
+
+ Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
+ Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName))
+ Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID))
+ }
+ }
+
+ if format != "m4a" {
+ performers := mf.Participants[model.RolePerformer]
+ Expect(performers).To(HaveLen(8))
+
+ rules := map[string][]string{
+ "pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"},
+ "pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""},
+ "pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"},
+ "pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"},
+ "pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"},
+ }
+
+ for name, rule := range rules {
+ mbid := rule[0]
+ for i := 1; i < len(rule); i++ {
+ found := false
+
+ for _, mapped := range performers {
+ if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] {
+ found = true
+ break
+ }
+ }
+
+ Expect(found).To(BeTrue(), "Could not find matching artist")
+ }
+ }
+ }
+ },
+ Entry("FLAC format", "flac"),
+ Entry("M4a format", "m4a"),
+ Entry("OGG format", "ogg"),
+ Entry("WV format", "wv"),
+
+ Entry("MP3 format", "mp3"),
+ Entry("WAV format", "wav"),
+ Entry("AIFF format", "aiff"),
+ )
+
+ It("should parse wma", func() {
+ mf := parseTestFile("tests/fixtures/test.wma")
+
+ for _, data := range roles {
+ role := data.Role
+ artists := data.ParticipantList
+ actual := mf.Participants[role]
+
+ // WMA has no Arranger role
+ if role == model.RoleArranger {
+ Expect(actual).To(HaveLen(0))
+ continue
+ }
+
+ Expect(actual).To(HaveLen(len(artists)), role.String())
+
+ // For some bizarre reason, the order is inverted. We also don't get
+ // sort names or MBIDs
+ for i := range artists {
+ idx := len(artists) - 1 - i
+
+ actualArtist := actual[i]
+ expectedArtist := artists[idx]
+
+ Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
+ }
+ }
+ })
+ })
+})
diff --git a/adapters/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go
new file mode 100644
index 000000000..f68985a07
--- /dev/null
+++ b/adapters/gotaglib/gotaglib.go
@@ -0,0 +1,263 @@
+// 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 "gotaglib". 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"
+ "io"
+ "io/fs"
+ "strings"
+ "time"
+
+ "github.com/navidrome/navidrome/core/storage/local"
+ "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 {
+ return "go-taglib (TagLib 2.1.1 WASM)"
+}
+
+func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
+ f, close, err := e.openFile(filePath)
+ if err != nil {
+ 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),
+ }
+
+ // 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) (*taglib.File, func(), 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")
+ }
+ f, err := taglib.OpenStream(rs, taglib.WithReadStyle(taglib.ReadStyleFast))
+ 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.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(fsys fs.FS, baseDir string) local.Extractor {
+ return &extractor{fsys}
+ })
+}
diff --git a/adapters/gotaglib/gotaglib_suite_test.go b/adapters/gotaglib/gotaglib_suite_test.go
new file mode 100644
index 000000000..cc7ddc471
--- /dev/null
+++ b/adapters/gotaglib/gotaglib_suite_test.go
@@ -0,0 +1,17 @@
+package gotaglib
+
+import (
+ "testing"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestGoTagLib(t *testing.T) {
+ tests.Init(t, true)
+ log.SetLevel(log.LevelFatal)
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "GoTagLib Suite")
+}
diff --git a/adapters/gotaglib/gotaglib_test.go b/adapters/gotaglib/gotaglib_test.go
new file mode 100644
index 000000000..529a8110a
--- /dev/null
+++ b/adapters/gotaglib/gotaglib_test.go
@@ -0,0 +1,302 @@
+package gotaglib
+
+import (
+ "io/fs"
+ "os"
+ "strings"
+
+ "github.com/navidrome/navidrome/utils"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Extractor", func() {
+ var e *extractor
+
+ BeforeEach(func() {
+ e = &extractor{fs: os.DirFS(".")}
+ })
+
+ Describe("Parse", func() {
+ It("correctly parses metadata from all files in folder", func() {
+ mds, err := e.Parse(
+ "tests/fixtures/test.mp3",
+ "tests/fixtures/test.ogg",
+ )
+ Expect(err).NotTo(HaveOccurred())
+ Expect(mds).To(HaveLen(2))
+
+ // Test MP3
+ m := mds["tests/fixtures/test.mp3"]
+ Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"}))
+ Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
+ Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
+ Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
+
+ Expect(m.HasPicture).To(BeTrue())
+ Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s"))
+ Expect(m.AudioProperties.BitRate).To(Equal(192))
+ Expect(m.AudioProperties.Channels).To(Equal(2))
+ Expect(m.AudioProperties.SampleRate).To(Equal(44100))
+
+ Expect(m.Tags).To(Or(
+ HaveKeyWithValue("compilation", []string{"1"}),
+ HaveKeyWithValue("tcmp", []string{"1"})),
+ )
+ Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
+ Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"}))
+ Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
+ Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"}))
+ Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"}))
+ Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
+ Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
+ Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
+ Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
+ Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
+ Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}))
+ Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}))
+
+ Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"}))
+ Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
+
+ Expect(m.Tags).ToNot(HaveKey("lyrics"))
+ Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{
+ "[00:00.00]This is\n[00:02.50]English SYLT\n",
+ "[00:00.00]This is\n[00:02.50]English",
+ }), HaveKeyWithValue("lyrics:eng", []string{
+ "[00:00.00]This is\n[00:02.50]English",
+ "[00:00.00]This is\n[00:02.50]English SYLT\n",
+ })))
+ Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{
+ "[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
+ "[00:00.00]This is\n[00:02.50]unspecified",
+ }), HaveKeyWithValue("lyrics:xxx", []string{
+ "[00:00.00]This is\n[00:02.50]unspecified",
+ "[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
+ })))
+
+ // Test OGG
+ m = mds["tests/fixtures/test.ogg"]
+ Expect(err).To(BeNil())
+ Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
+
+ // TagLib 1.12 returns 18, previous versions return 39.
+ // See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
+ Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
+ Expect(m.AudioProperties.Channels).To(BeElementOf(2))
+ Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
+ Expect(m.HasPicture).To(BeTrue())
+ })
+
+ DescribeTable("Format-Specific tests",
+ func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
+ file = "tests/fixtures/" + file
+ mds, err := e.Parse(file)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(mds).To(HaveLen(1))
+
+ m := mds[file]
+
+ Expect(m.HasPicture).To(Equal(image))
+ Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
+ Expect(m.AudioProperties.Channels).To(Equal(channels))
+ Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
+ Expect(m.AudioProperties.BitDepth).To(Equal(bitdepth))
+
+ Expect(m.Tags).To(Or(
+ HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
+ HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
+ ))
+
+ Expect(m.Tags).To(Or(
+ HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}),
+ HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}),
+ ))
+ Expect(m.Tags).To(Or(
+ HaveKeyWithValue("replaygain_track_gain", []string{trackGain}),
+ HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}),
+ ))
+ Expect(m.Tags).To(Or(
+ HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}),
+ HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}),
+ ))
+
+ Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"}))
+ Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
+ Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
+ Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
+ Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
+ Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
+
+ Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
+ Expect(m.Tags).To(Or(
+ HaveKeyWithValue("tracknumber", []string{"3"}),
+ HaveKeyWithValue("tracknumber", []string{"3/10"}),
+ ))
+ if !strings.HasSuffix(file, "test.wma") {
+ // TODO Not sure why this is not working for WMA
+ Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
+ }
+ Expect(m.Tags).To(Or(
+ HaveKeyWithValue("discnumber", []string{"1"}),
+ HaveKeyWithValue("discnumber", []string{"1/2"}),
+ ))
+ Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
+
+ // WMA does not have a "compilation" tag, but "wm/iscompilation"
+ Expect(m.Tags).To(Or(
+ HaveKeyWithValue("compilation", []string{"1"}),
+ HaveKeyWithValue("wm/iscompilation", []string{"1"})),
+ )
+
+ if id3Lyrics {
+ Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{
+ "[00:00.00]This is\n[00:02.50]English",
+ }))
+ Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
+ "[00:00.00]This is\n[00:02.50]unspecified",
+ }))
+ } else {
+ Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
+ "[00:00.00]This is\n[00:02.50]unspecified",
+ "[00:00.00]This is\n[00:02.50]English",
+ }))
+ }
+
+ Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
+ },
+
+ // ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
+ Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true),
+
+ Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
+ Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
+ Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
+
+ // ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
+ // Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
+ Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
+
+ // ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
+ Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true),
+
+ // ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
+ Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
+
+ // ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
+ Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true),
+ )
+
+ // Skip these tests when running as root
+ Context("Access Forbidden", func() {
+ var accessForbiddenFile string
+ var RegularUserContext = XContext
+ var isRegularUser = os.Getuid() != 0
+ if isRegularUser {
+ RegularUserContext = Context
+ }
+
+ // Only run permission tests if we are not root
+ RegularUserContext("when run without root privileges", func() {
+ BeforeEach(func() {
+ // 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)
+ Expect(err).ToNot(HaveOccurred())
+
+ DeferCleanup(func() {
+ Expect(f.Close()).To(Succeed())
+ Expect(os.Remove(accessForbiddenFile)).To(Succeed())
+ })
+ })
+
+ It("correctly handle unreadable file due to insufficient read permission", func() {
+ // 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{
+ 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[1:]))
+ })
+ })
+ })
+
+ })
+
+ Describe("Error Checking", func() {
+ It("returns a generic ErrPath if file does not exist", func() {
+ testFilePath := "tests/fixtures/NON_EXISTENT.ogg"
+ _, err := e.extractMetadata(testFilePath)
+ Expect(err).To(MatchError(fs.ErrNotExist))
+ })
+ It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() {
+ // File has an empty TDAT frame
+ md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"}))
+ })
+ })
+
+ Describe("parseTIPL", func() {
+ var tags map[string][]string
+
+ BeforeEach(func() {
+ tags = make(map[string][]string)
+ })
+
+ Context("when the TIPL string is populated", func() {
+ It("correctly parses roles and names", func() {
+ tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"}
+ parseTIPL(tags)
+ Expect(tags["arranger"]).To(ConsistOf("Andrew Powell"))
+ Expect(tags["engineer"]).To(ConsistOf("Chris Blair"))
+ Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe"))
+ })
+
+ It("handles multiple names for a single role", func() {
+ tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"}
+ parseTIPL(tags)
+ Expect(tags["producer"]).To(ConsistOf("Eric Woolfson"))
+ Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
+ })
+
+ It("discards roles without names", func() {
+ tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"}
+ parseTIPL(tags)
+ Expect(tags).ToNot(HaveKey("producer"))
+ Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
+ })
+ })
+
+ Context("when the TIPL string is empty", func() {
+ It("does nothing", func() {
+ tags["tipl"] = []string{""}
+ parseTIPL(tags)
+ Expect(tags).To(BeEmpty())
+ })
+ })
+
+ Context("when the TIPL is not present", func() {
+ It("does nothing", func() {
+ parseTIPL(tags)
+ Expect(tags).To(BeEmpty())
+ })
+ })
+ })
+
+})
diff --git a/core/agents/lastfm/agent.go b/adapters/lastfm/agent.go
similarity index 85%
rename from core/agents/lastfm/agent.go
rename to adapters/lastfm/agent.go
index ec732f17a..e3e53b234 100644
--- a/core/agents/lastfm/agent.go
+++ b/adapters/lastfm/agent.go
@@ -38,6 +38,7 @@ type lastfmAgent struct {
secret string
lang string
client *client
+ httpClient httpDoer
getInfoMutex sync.Mutex
}
@@ -56,6 +57,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
+ l.httpClient = chc
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
return l
}
@@ -72,16 +74,23 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
return nil, err
}
- response := agents.AlbumInfo{
+ return &agents.AlbumInfo{
Name: a.Name,
MBID: a.MBID,
Description: a.Description.Summary,
URL: a.URL,
- Images: make([]agents.ExternalImage, 0),
+ }, nil
+}
+
+func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
+ a, err := l.callAlbumGetInfo(ctx, name, artist, mbid)
+ if err != nil {
+ return nil, err
}
// 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,23 +101,20 @@ 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) {
@@ -186,13 +192,13 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi
return res, nil
}
-var artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
+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)
if err != nil {
return nil, fmt.Errorf("get artist info: %w", err)
@@ -201,7 +207,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,11 +224,16 @@ 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
@@ -279,27 +290,27 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
return t.Track, nil
}
-func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile) string {
- if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[model.RoleArtist]) > 0 {
- return track.Participants[model.RoleArtist][0].Name
+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 track.Artist
+ return displayName
}
-func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
+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: l.getArtistForScrobble(track),
+ 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)
@@ -319,13 +330,13 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
return nil
}
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
- artist: l.getArtistForScrobble(&s.MediaFile),
+ 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 {
diff --git a/core/agents/lastfm/agent_test.go b/adapters/lastfm/agent_test.go
similarity index 81%
rename from core/agents/lastfm/agent_test.go
rename to adapters/lastfm/agent_test.go
index 8790f0327..fc6238408 100644
--- a/core/agents/lastfm/agent_test.go
+++ b/adapters/lastfm/agent_test.go
@@ -201,6 +201,10 @@ var _ = Describe("lastfmAgent", func() {
{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"}},
+ },
},
}
})
@@ -209,7 +213,7 @@ 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))
@@ -226,9 +230,26 @@ 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())
+ sentParams := httpClient.SavedRequest.URL.Query()
+ Expect(sentParams.Get("artist")).To(Equal("First Artist"))
+ Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
+ })
+ })
})
Describe("scrobble", func() {
@@ -267,6 +288,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(err).ToNot(HaveOccurred())
sentParams := httpClient.SavedRequest.URL.Query()
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
+ Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
})
})
@@ -345,24 +367,6 @@ var _ = Describe("lastfmAgent", func() {
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.",
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"))
@@ -372,9 +376,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"))
@@ -412,4 +415,73 @@ 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", "pt", 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"))
+ })
+ })
})
diff --git a/core/agents/lastfm/auth_router.go b/adapters/lastfm/auth_router.go
similarity index 100%
rename from core/agents/lastfm/auth_router.go
rename to adapters/lastfm/auth_router.go
diff --git a/core/agents/lastfm/client.go b/adapters/lastfm/client.go
similarity index 100%
rename from core/agents/lastfm/client.go
rename to adapters/lastfm/client.go
diff --git a/core/agents/lastfm/client_test.go b/adapters/lastfm/client_test.go
similarity index 100%
rename from core/agents/lastfm/client_test.go
rename to adapters/lastfm/client_test.go
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/core/agents/lastfm/responses.go b/adapters/lastfm/responses.go
similarity index 100%
rename from core/agents/lastfm/responses.go
rename to adapters/lastfm/responses.go
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 98%
rename from core/agents/listenbrainz/agent.go
rename to adapters/listenbrainz/agent.go
index 200e9f63c..769b0f5a6 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)
diff --git a/core/agents/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go
similarity index 98%
rename from core/agents/listenbrainz/agent_test.go
rename to adapters/listenbrainz/agent_test.go
index 86a95d5bf..e99b442de 100644
--- a/core/agents/listenbrainz/agent_test.go
+++ b/adapters/listenbrainz/agent_test.go
@@ -79,12 +79,12 @@ var _ = Describe("listenBrainzAgent", 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)
+ 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)
+ err := agent.NowPlaying(ctx, "user-2", track, 0)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
})
diff --git a/core/agents/listenbrainz/auth_router.go b/adapters/listenbrainz/auth_router.go
similarity index 100%
rename from core/agents/listenbrainz/auth_router.go
rename to adapters/listenbrainz/auth_router.go
diff --git a/core/agents/listenbrainz/auth_router_test.go b/adapters/listenbrainz/auth_router_test.go
similarity index 100%
rename from core/agents/listenbrainz/auth_router_test.go
rename to adapters/listenbrainz/auth_router_test.go
diff --git a/core/agents/listenbrainz/client.go b/adapters/listenbrainz/client.go
similarity index 100%
rename from core/agents/listenbrainz/client.go
rename to adapters/listenbrainz/client.go
diff --git a/core/agents/listenbrainz/client_test.go b/adapters/listenbrainz/client_test.go
similarity index 100%
rename from core/agents/listenbrainz/client_test.go
rename to adapters/listenbrainz/client_test.go
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/core/agents/spotify/client.go b/adapters/spotify/client.go
similarity index 100%
rename from core/agents/spotify/client.go
rename to adapters/spotify/client.go
diff --git a/core/agents/spotify/client_test.go b/adapters/spotify/client_test.go
similarity index 100%
rename from core/agents/spotify/client_test.go
rename to adapters/spotify/client_test.go
diff --git a/core/agents/spotify/responses.go b/adapters/spotify/responses.go
similarity index 100%
rename from core/agents/spotify/responses.go
rename to adapters/spotify/responses.go
diff --git a/core/agents/spotify/responses_test.go b/adapters/spotify/responses_test.go
similarity index 100%
rename from core/agents/spotify/responses_test.go
rename to adapters/spotify/responses_test.go
diff --git a/core/agents/spotify/spotify.go b/adapters/spotify/spotify.go
similarity index 100%
rename from core/agents/spotify/spotify.go
rename to adapters/spotify/spotify.go
diff --git a/core/agents/spotify/spotify_suite_test.go b/adapters/spotify/spotify_suite_test.go
similarity index 100%
rename from core/agents/spotify/spotify_suite_test.go
rename to adapters/spotify/spotify_suite_test.go
diff --git a/adapters/taglib/end_to_end_test.go b/adapters/taglib/end_to_end_test.go
index 08fc1a506..265f258f5 100644
--- a/adapters/taglib/end_to_end_test.go
+++ b/adapters/taglib/end_to_end_test.go
@@ -8,6 +8,7 @@ import (
"github.com/djherbis/times"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
+ "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -78,22 +79,112 @@ 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)
+ mf := metadata.ToMediaFile(1, "folderID")
+ return &mf
+ }
+
BeforeEach(func() {
e = &extractor{}
})
+ Describe("ReplayGain", func() {
+ DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) {
+ mf := parseTestFile("tests/fixtures/" + file)
+
+ Expect(mf.RGTrackGain).To(Equal(trackGain))
+ Expect(mf.RGTrackPeak).To(Equal(trackPeak))
+ Expect(mf.RGAlbumGain).To(Equal(albumGain))
+ Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
+ },
+ Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
+ Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
+ )
+ })
+
+ Describe("lyrics", func() {
+ makeLyrics := func(code, secondLine string) model.Lyrics {
+ return model.Lyrics{
+ DisplayArtist: "",
+ DisplayTitle: "",
+ Lang: code,
+ Line: []model.Line{
+ {Start: gg.P(int64(0)), Value: "This is"},
+ {Start: gg.P(int64(2500)), Value: secondLine},
+ },
+ Offset: nil,
+ Synced: true,
+ }
+ }
+
+ It("should fetch both synced and unsynced lyrics in mixed flac", func() {
+ mf := parseTestFile("tests/fixtures/mixed-lyrics.flac")
+
+ lyrics, err := mf.StructuredLyrics()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lyrics).To(HaveLen(2))
+
+ Expect(lyrics[0].Synced).To(BeTrue())
+ Expect(lyrics[1].Synced).To(BeFalse())
+ })
+
+ It("should handle mp3 with uslt and sylt", func() {
+ mf := parseTestFile("tests/fixtures/test.mp3")
+
+ lyrics, err := mf.StructuredLyrics()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lyrics).To(HaveLen(4))
+
+ engSylt := makeLyrics("eng", "English SYLT")
+ engUslt := makeLyrics("eng", "English")
+ unsSylt := makeLyrics("xxx", "unspecified SYLT")
+ unsUslt := makeLyrics("xxx", "unspecified")
+
+ Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
+ })
+
+ DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
+ mf := parseTestFile("tests/fixtures/" + file)
+
+ lyrics, err := mf.StructuredLyrics()
+ Expect(err).To(Not(HaveOccurred()))
+ Expect(lyrics).To(HaveLen(2))
+
+ unspec := makeLyrics("xxx", "unspecified")
+ eng := makeLyrics("xxx", "English")
+
+ if isId3 {
+ eng.Lang = "eng"
+ }
+
+ Expect(lyrics).To(Or(
+ Equal(model.LyricList{unspec, eng}),
+ Equal(model.LyricList{eng, unspec})))
+ },
+ Entry("flac", "test.flac", false),
+ Entry("m4a", "test.m4a", false),
+ Entry("ogg", "test.ogg", false),
+ Entry("wma", "test.wma", false),
+ Entry("wv", "test.wv", false),
+ Entry("wav", "test.wav", true),
+ Entry("aiff", "test.aiff", true),
+ )
+ })
+
Describe("Participants", func() {
DescribeTable("test tags consistent across formats", func(format string) {
- 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 +235,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/taglib/taglib.go b/adapters/taglib/taglib.go
index c89dabf62..ac299ea2b 100644
--- a/adapters/taglib/taglib.go
+++ b/adapters/taglib/taglib.go
@@ -7,6 +7,7 @@ import (
"strings"
"time"
+ "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
@@ -42,23 +43,21 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
// 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)
+ ap.BitRate = parseProp(tags, "__bitrate")
+ ap.Channels = parseProp(tags, "__channels")
+ ap.SampleRate = parseProp(tags, "__samplerate")
+ ap.BitDepth = parseProp(tags, "__bitspersample")
+ length := parseProp(tags, "__lengthinmilliseconds")
+ ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10)
+
+ // Extract basic tags
+ parseBasicTag(tags, "__title", "title")
+ parseBasicTag(tags, "__artist", "artist")
+ parseBasicTag(tags, "__album", "album")
+ parseBasicTag(tags, "__comment", "comment")
+ parseBasicTag(tags, "__genre", "genre")
+ parseBasicTag(tags, "__year", "year")
+ parseBasicTag(tags, "__track", "tracknumber")
// Parse track/disc totals
parseTuple := func(prop string) {
@@ -106,6 +105,31 @@ var tiplMapping = map[string]string{
"DJ-mix": "djmixer",
}
+// parseProp parses a property from the tags map and sets it to the target integer.
+// It also deletes the property from the tags map after parsing.
+func parseProp(tags map[string][]string, prop string) int {
+ if value, ok := tags[prop]; ok && len(value) > 0 {
+ v, _ := strconv.Atoi(value[0])
+ delete(tags, prop)
+ return v
+ }
+ return 0
+}
+
+// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map.
+// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.),
+// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag.
+func parseBasicTag(tags map[string][]string, basicName string, tagName string) {
+ basicValue := tags[basicName]
+ if len(basicValue) == 0 {
+ return
+ }
+ delete(tags, basicName)
+ if len(tags[tagName]) == 0 {
+ tags[tagName] = basicValue
+ }
+}
+
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
//
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
@@ -144,8 +168,11 @@ func parseTIPL(tags map[string][]string) {
var _ local.Extractor = (*extractor)(nil)
func init() {
- local.RegisterExtractor("taglib", func(_ fs.FS, baseDir string) local.Extractor {
+ local.RegisterExtractor("legacy-taglib", func(_ fs.FS, baseDir string) local.Extractor {
// ignores fs, as taglib extractor only works with local files
return &extractor{baseDir}
})
+ conf.AddHook(func() {
+ log.Debug("TagLib version", "version", Version())
+ })
}
diff --git a/adapters/taglib/taglib_test.go b/adapters/taglib/taglib_test.go
index 37b012763..f524f77ec 100644
--- a/adapters/taglib/taglib_test.go
+++ b/adapters/taglib/taglib_test.go
@@ -80,12 +80,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 +105,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(
@@ -179,7 +178,7 @@ var _ = Describe("Extractor", func() {
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),
diff --git a/adapters/taglib/taglib_wrapper.cpp b/adapters/taglib/taglib_wrapper.cpp
index 17c95bfc0..2985e8f18 100644
--- a/adapters/taglib/taglib_wrapper.cpp
+++ b/adapters/taglib/taglib_wrapper.cpp
@@ -1,6 +1,5 @@
#include
#include
-#include
#define TAGLIB_STATIC
#include
@@ -46,31 +45,63 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
// 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());
+ goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds());
+ goPutInt(id, (char *)"__bitrate", props->bitrate());
+ goPutInt(id, (char *)"__channels", props->channels());
+ goPutInt(id, (char *)"__samplerate", props->sampleRate());
+ // Extract bits per sample for supported formats
+ int bitsPerSample = 0;
if (const auto* apeProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", apeProperties->bitsPerSample());
- if (const auto* asfProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", asfProperties->bitsPerSample());
+ bitsPerSample = apeProperties->bitsPerSample();
+ else if (const auto* asfProperties{ dynamic_cast(props) })
+ bitsPerSample = asfProperties->bitsPerSample();
else if (const auto* flacProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", flacProperties->bitsPerSample());
+ bitsPerSample = flacProperties->bitsPerSample();
else if (const auto* mp4Properties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", mp4Properties->bitsPerSample());
+ bitsPerSample = mp4Properties->bitsPerSample();
else if (const auto* wavePackProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", wavePackProperties->bitsPerSample());
+ bitsPerSample = wavePackProperties->bitsPerSample();
else if (const auto* aiffProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", aiffProperties->bitsPerSample());
+ bitsPerSample = aiffProperties->bitsPerSample();
else if (const auto* wavProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", wavProperties->bitsPerSample());
+ bitsPerSample = wavProperties->bitsPerSample();
else if (const auto* dsfProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", dsfProperties->bitsPerSample());
+ bitsPerSample = dsfProperties->bitsPerSample();
+
+ if (bitsPerSample > 0) {
+ goPutInt(id, (char *)"__bitspersample", bitsPerSample);
+ }
// Send all properties to the Go map
TagLib::PropertyMap tags = f.file()->properties();
+ // Make sure at least the basic properties are extracted
+ TagLib::Tag *basic = f.file()->tag();
+ if (!basic->isEmpty()) {
+ if (!basic->title().isEmpty()) {
+ tags.insert("__title", basic->title());
+ }
+ if (!basic->artist().isEmpty()) {
+ tags.insert("__artist", basic->artist());
+ }
+ if (!basic->album().isEmpty()) {
+ tags.insert("__album", basic->album());
+ }
+ if (!basic->comment().isEmpty()) {
+ tags.insert("__comment", basic->comment());
+ }
+ if (!basic->genre().isEmpty()) {
+ tags.insert("__genre", basic->genre());
+ }
+ if (basic->year() > 0) {
+ tags.insert("__year", TagLib::String::number(basic->year()));
+ }
+ if (basic->track() > 0) {
+ tags.insert("__track", TagLib::String::number(basic->track()));
+ }
+ }
+
TagLib::ID3v2::Tag *id3Tags = NULL;
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
@@ -113,7 +144,7 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
strncpy(language, bv.data(), 3);
}
- char *val = (char *)frame->text().toCString(true);
+ char *val = const_cast(frame->text().toCString(true));
goPutLyrics(id, language, val);
}
@@ -132,7 +163,7 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) {
for (const auto &line: frame->synchedText()) {
- char *text = (char *)line.text.toCString(true);
+ char *text = const_cast(line.text.toCString(true));
goPutLyricLine(id, language, text, line.time);
}
} else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) {
@@ -141,7 +172,7 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
if (sampleRate != 0) {
for (const auto &line: frame->synchedText()) {
const int timeInMs = (line.time * 1000) / sampleRate;
- char *text = (char *)line.text.toCString(true);
+ char *text = const_cast(line.text.toCString(true));
goPutLyricLine(id, language, text, timeInMs);
}
}
@@ -160,9 +191,9 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
if (m4afile != NULL) {
const auto itemListMap = m4afile->tag()->itemMap();
for (const auto item: itemListMap) {
- char *key = (char *)item.first.toCString(true);
+ char *key = const_cast(item.first.toCString(true));
for (const auto value: item.second.toStringList()) {
- char *val = (char *)value.toCString(true);
+ char *val = const_cast(value.toCString(true));
goPutM4AStr(id, key, val);
}
}
@@ -174,17 +205,24 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
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());
+ char *key = const_cast(item.first.toCString(true));
+
+ for (auto j = item.second.begin();
+ j != item.second.end(); ++j) {
+
+ char *val = const_cast(j->toString().toCString(true));
+ goPutStr(id, key, val);
+ }
}
}
// Send all collected tags to the Go map
for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end();
++i) {
- char *key = (char *)i->first.toCString(true);
+ char *key = const_cast(i->first.toCString(true));
for (TagLib::StringList::ConstIterator j = i->second.begin();
j != i->second.end(); ++j) {
- char *val = (char *)(*j).toCString(true);
+ char *val = const_cast((*j).toCString(true));
goPutStr(id, key, val);
}
}
@@ -242,7 +280,19 @@ char has_cover(const TagLib::FileRef f) {
// ----- 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");
+ hasCover = tag && tag->attributeListMap().contains("WM/Picture");
+ }
+ // ----- DSF
+ else if (TagLib::DSF::File * dsffile{ dynamic_cast(f.file())}) {
+ const TagLib::ID3v2::Tag *tag { dsffile->tag() };
+ hasCover = tag && !tag->frameListMap()["APIC"].isEmpty();
+ }
+ // ----- WAVPAK (APE tag)
+ else if (TagLib::WavPack::File * wvFile{dynamic_cast(f.file())}) {
+ if (wvFile->hasAPETag()) {
+ // This is the particular string that Picard uses
+ hasCover = !wvFile->APETag()->itemListMap()["COVER ART (FRONT)"].isEmpty();
+ }
}
return hasCover;
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/pls.go b/cmd/pls.go
index fc0f22fba..9b94c9e8f 100644
--- a/cmd/pls.go
+++ b/cmd/pls.go
@@ -10,11 +10,8 @@ import (
"strconv"
"github.com/Masterminds/squirrel"
- "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/persistence"
"github.com/spf13/cobra"
)
@@ -52,7 +49,7 @@ var (
Short: "Export playlists",
Long: "Export Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
- runExporter()
+ runExporter(cmd.Context())
},
}
@@ -60,15 +57,13 @@ var (
Use: "list",
Short: "List playlists",
Run: func(cmd *cobra.Command, args []string) {
- runList()
+ runList(cmd.Context())
},
}
)
-func runExporter() {
- sqlDB := db.Db()
- ds := persistence.New(sqlDB)
- ctx := auth.WithAdminUser(context.Background(), ds)
+func runExporter(ctx context.Context) {
+ ds, ctx := getAdminContext(ctx)
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving playlist", "name", playlistID, err)
@@ -100,31 +95,19 @@ func runExporter() {
}
}
-func runList() {
+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)
-
+ ds, ctx := getAdminContext(ctx)
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)
+ user, err := getUser(ctx, userID, ds)
+ if err != nil {
+ log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
-
- 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}
}
diff --git a/cmd/root.go b/cmd/root.go
index e1e92228f..74a15abc1 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,14 @@ 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"
+ _ "github.com/navidrome/navidrome/adapters/spotify"
+ _ "github.com/navidrome/navidrome/adapters/taglib"
)
var (
@@ -82,8 +89,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 +117,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 +155,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 +180,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()
@@ -190,7 +199,7 @@ func runInitialScan(ctx context.Context) func() error {
scanNeeded := conf.Server.Scanner.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 +213,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 +252,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 +280,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 +334,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 +378,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 +402,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 d37ccd69f..daf58b29c 100644
--- a/cmd/scan.go
+++ b/cmd/scan.go
@@ -1,13 +1,17 @@
package cmd
import (
+ "bufio"
"context"
"encoding/gob"
+ "fmt"
"os"
+ "strings"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/utils/pl"
@@ -17,11 +21,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,7 +76,25 @@ func runScanner(ctx context.Context) {
ds := persistence.New(sqlDB)
pls := core.NewPlaylists(ds)
- progress, err := scanner.CallScan(ctx, ds, pls, 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)
}
@@ -80,3 +106,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/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..7a9d38d92 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -9,10 +9,10 @@ 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"
@@ -22,6 +22,7 @@ import (
"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,6 +32,11 @@ import (
)
import (
+ _ "github.com/navidrome/navidrome/adapters/deezer"
+ _ "github.com/navidrome/navidrome/adapters/gotaglib"
+ _ "github.com/navidrome/navidrome/adapters/lastfm"
+ _ "github.com/navidrome/navidrome/adapters/listenbrainz"
+ _ "github.com/navidrome/navidrome/adapters/spotify"
_ "github.com/navidrome/navidrome/adapters/taglib"
)
@@ -51,13 +57,27 @@ 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)
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)
+ provider := external.NewProvider(dataStore, agentsAgents)
+ artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
+ cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
+ modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
+ watcher := scanner.GetWatcher(dataStore, modelScanner)
+ library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
+ user := core.NewUser(dataStore, manager)
+ maintenance := core.NewMaintenance(dataStore)
+ router := nativeapi.New(dataStore, share, playlists, insights, library, user, maintenance, manager)
return router
}
@@ -66,7 +86,10 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
- agentsAgents := agents.GetAgents(dataStore)
+ broker := events.GetBroker()
+ metricsMetrics := metrics.GetPrometheusInstance(dataStore)
+ manager := plugins.GetManager(dataStore, broker, metricsMetrics)
+ agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := core.GetTranscodingCache()
@@ -75,13 +98,11 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
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)
+ modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, 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)
+ router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
return router
}
@@ -90,7 +111,10 @@ func CreatePublicRouter() *public.Router {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
- agentsAgents := agents.GetAgents(dataStore)
+ broker := events.GetBroker()
+ metricsMetrics := metrics.GetPrometheusInstance(dataStore)
+ manager := plugins.GetManager(dataStore, broker, metricsMetrics)
+ agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := core.GetTranscodingCache()
@@ -125,24 +149,25 @@ 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)
+ broker := events.GetBroker()
+ metricsMetrics := metrics.GetPrometheusInstance(dataStore)
+ manager := plugins.GetManager(dataStore, broker, metricsMetrics)
+ agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
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
+ modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
+ return modelScanner
}
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
@@ -150,15 +175,16 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
- agentsAgents := agents.GetAgents(dataStore)
+ broker := events.GetBroker()
+ metricsMetrics := metrics.GetPrometheusInstance(dataStore)
+ manager := plugins.GetManager(dataStore, broker, metricsMetrics)
+ agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
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)
+ modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
+ watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
}
@@ -169,6 +195,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, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
+
+func GetPluginManager(ctx context.Context) *plugins.Manager {
+ manager := getPluginManager()
+ manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
+ return manager
+}
diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go
index c431945dc..56206feb6 100644
--- a/cmd/wire_injectors.go
+++ b/cmd/wire_injectors.go
@@ -6,15 +6,18 @@ 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/metrics"
"github.com/navidrome/navidrome/core/playback"
+ "github.com/navidrome/navidrome/core/scrobbler"
"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 +38,16 @@ var allProviders = wire.NewSet(
listenbrainz.NewRouter,
events.GetBroker,
scanner.New,
- scanner.NewWatcher,
- metrics.NewPrometheusInstance,
+ scanner.GetWatcher,
+ metrics.GetPrometheusInstance,
db.Db,
+ plugins.GetManager,
+ wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
+ wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
+ wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
+ wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
+ wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
+ wire.Bind(new(core.Watcher), new(scanner.Watcher)),
)
func CreateDataStore() model.DataStore {
@@ -52,7 +62,7 @@ func CreateServer() *server.Server {
))
}
-func CreateNativeAPIRouter() *nativeapi.Router {
+func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
panic(wire.Build(
allProviders,
))
@@ -94,7 +104,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 +121,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/configuration.go b/conf/configuration.go
index c3a08bbfa..5d3660397 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -14,7 +14,7 @@ import (
"github.com/kr/pretty"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/utils/chain"
+ "github.com/navidrome/navidrome/utils/run"
"github.com/robfig/cron/v3"
"github.com/spf13/viper"
)
@@ -41,6 +41,7 @@ type configOptions struct {
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
+ EnableTranscodingCancellation bool
EnableDownloads bool
EnableExternalServices bool
EnableInsightsCollector bool
@@ -80,14 +81,15 @@ type configOptions struct {
DefaultUIVolume int
EnableReplayGain bool
EnableCoverAnimation bool
+ EnableNowPlaying bool
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
AuthWindowLength time.Duration
PasswordEncryptionKey string
- ReverseProxyUserHeader string
- ReverseProxyWhitelist string
- HTTPSecurityHeaders secureOptions `json:",omitzero"`
+ ExtAuth extAuthOptions
+ Plugins pluginsOptions
+ HTTPHeaders httpHeaderOptions `json:",omitzero"`
Prometheus prometheusOptions `json:",omitzero"`
Scanner scannerOptions `json:",omitzero"`
Jukebox jukeboxOptions `json:",omitzero"`
@@ -97,31 +99,41 @@ type configOptions struct {
Subsonic subsonicOptions `json:",omitzero"`
LastFM lastfmOptions `json:",omitzero"`
Spotify spotifyOptions `json:",omitzero"`
+ Deezer deezerOptions `json:",omitzero"`
ListenBrainz listenBrainzOptions `json:",omitzero"`
- Tags map[string]TagConf `json:",omitempty"`
+ EnableScrobbleHistory bool
+ Tags map[string]TagConf `json:",omitempty"`
Agents string
// DevFlags. These are used to enable/disable debugging and incomplete features
- 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
- 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
+ DevArtistInfoTimeToLive time.Duration
+ DevAlbumInfoTimeToLive time.Duration
+ DevExternalScanner bool
+ DevScannerThreads uint
+ DevSelectiveWatcher bool
+ DevLegacyEmbedImage bool
+ DevInsightsInitialDelay time.Duration
+ DevEnablePlayerInsights bool
+ DevEnablePluginsInsights bool
+ DevPluginCompilationTimeout time.Duration
+ DevExternalArtistFetchMultiplier float64
+ DevOptimizeDB bool
+ DevPreserveUnicodeInExternalCalls bool
}
type scannerOptions struct {
@@ -141,7 +153,9 @@ type subsonicOptions struct {
AppendSubtitle bool
ArtistParticipations bool
DefaultReportRealPath bool
+ EnableAverageRating bool
LegacyClients string
+ MinimalClients string
}
type TagConf struct {
@@ -166,13 +180,18 @@ type spotifyOptions struct {
Secret string
}
+type deezerOptions struct {
+ Enabled bool
+ Language string
+}
+
type listenBrainzOptions struct {
Enabled bool
BaseURL string
}
-type secureOptions struct {
- CustomFrameOptionsValue string
+type httpHeaderOptions struct {
+ FrameOptions string
}
type prometheusOptions struct {
@@ -208,6 +227,19 @@ type inspectOptions struct {
BacklogTimeout int
}
+type pluginsOptions struct {
+ Enabled bool
+ Folder string
+ CacheSize string
+ AutoReload bool
+ LogLevel string
+}
+
+type extAuthOptions struct {
+ TrustedSources string
+ UserHeader string
+}
+
var (
Server = &configOptions{}
hooks []func()
@@ -226,6 +258,11 @@ func LoadFromFile(confFile string) {
func Load(noConfigDump bool) {
parseIniFileConfiguration()
+ // Map deprecated options to their new names for backwards compatibility
+ mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
+ mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
+ mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
+
err := viper.Unmarshal(&Server)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
@@ -247,6 +284,17 @@ func Load(noConfigDump bool) {
os.Exit(1)
}
+ if Server.Plugins.Enabled {
+ if Server.Plugins.Folder == "" {
+ Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
+ }
+ err = os.MkdirAll(Server.Plugins.Folder, 0700)
+ if err != nil {
+ _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
+ os.Exit(1)
+ }
+ }
+
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
if Server.DbPath == "" {
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
@@ -275,7 +323,7 @@ func Load(noConfigDump bool) {
log.SetLogSourceLine(Server.DevLogSourceLine)
log.SetRedacting(Server.EnableLogRedacting)
- err = chain.RunSequentially(
+ err = run.Sequentially(
validateScanSchedule,
validateBackupSchedule,
validatePlaylistsPath,
@@ -298,9 +346,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)
}
@@ -311,13 +368,12 @@ 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
- }
- logDeprecatedOptions("Scanner.GenreSeparators")
- logDeprecatedOptions("Scanner.GroupAlbumReleases")
- logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
+ logDeprecatedOptions("Scanner.GenreSeparators", "")
+ logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
+ logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
+ logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
+ logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
+ logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
// Call init hooks
for _, hook := range hooks {
@@ -325,16 +381,30 @@ func Load(noConfigDump bool) {
}
}
-func logDeprecatedOptions(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))
- }
- if viper.InConfig(option) {
- log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", option))
+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)
+ }
+}
+
+// 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))
+ }
}
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
@@ -367,6 +437,7 @@ func disableExternalServices() {
Server.EnableInsightsCollector = false
Server.LastFM.Enabled = false
Server.Spotify.ID = ""
+ Server.Deezer.Enabled = false
Server.ListenBrainz.Enabled = false
Server.Agents = ""
if Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL {
@@ -395,7 +466,7 @@ func validatePurgeMissingOption() error {
}
}
if !valid {
- err := fmt.Errorf("Invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
+ err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
log.Error(err.Error())
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
return err
@@ -442,6 +513,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", "")
@@ -459,6 +540,7 @@ func setViperDefaults() {
viper.SetDefault("uiwelcomemessage", "")
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
viper.SetDefault("enabletranscodingconfig", false)
+ viper.SetDefault("enabletranscodingcancellation", false)
viper.SetDefault("transcodingcachesize", "100MB")
viper.SetDefault("imagecachesize", "100MB")
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
@@ -482,6 +564,7 @@ func setViperDefaults() {
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
viper.SetDefault("coverjpegquality", 75)
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
+ viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
viper.SetDefault("enablegravatar", false)
viper.SetDefault("enablefavourites", true)
viper.SetDefault("enablestarrating", true)
@@ -491,6 +574,7 @@ func setViperDefaults() {
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true)
+ viper.SetDefault("enablenowplaying", true)
viper.SetDefault("enablesharing", false)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
@@ -501,8 +585,8 @@ 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("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "")
@@ -519,12 +603,13 @@ 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.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false)
- viper.SetDefault("subsonic.legacyclients", "DSub")
- viper.SetDefault("agents", "lastfm,spotify")
+ viper.SetDefault("subsonic.enableaveragerating", true)
+ viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
+ viper.SetDefault("agents", "lastfm,spotify,deezer")
viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", "en")
viper.SetDefault("lastfm.apikey", "")
@@ -532,9 +617,12 @@ func setViperDefaults() {
viper.SetDefault("lastfm.scrobblefirstartistonly", false)
viper.SetDefault("spotify.id", "")
viper.SetDefault("spotify.secret", "")
+ viper.SetDefault("deezer.enabled", true)
+ viper.SetDefault("deezer.language", "en")
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
- viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
+ viper.SetDefault("enablescrobblehistory", true)
+ viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("backup.path", "")
viper.SetDefault("backup.schedule", "")
viper.SetDefault("backup.count", 0)
@@ -544,7 +632,12 @@ 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", false)
+ viper.SetDefault("plugins.cachesize", "200MB")
+ viper.SetDefault("plugins.autoreload", false)
+
+ // DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
viper.SetDefault("devenableprofiler", false)
viper.SetDefault("devautocreateadminpassword", "")
@@ -554,6 +647,7 @@ func setViperDefaults() {
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("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
@@ -562,17 +656,28 @@ func setViperDefaults() {
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)
}
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)
@@ -586,10 +691,12 @@ 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 {
diff --git a/conf/configuration_test.go b/conf/configuration_test.go
index 5b54e4975..06973456f 100644
--- a/conf/configuration_test.go
+++ b/conf/configuration_test.go
@@ -31,7 +31,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 +39,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/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/consts/consts.go b/consts/consts.go
index fbb2c9429..2d342f909 100644
--- a/consts/consts.go
+++ b/consts/consts.go
@@ -150,6 +150,8 @@ var (
}
)
+var HTTPUserAgent = "Navidrome" + "/" + Version
+
var (
VariousArtists = "Various Artists"
// TODO This will be dynamic when using disambiguation
diff --git a/core/agents/agents.go b/core/agents/agents.go
index 50a1e04ad..c82d77b14 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,108 @@ 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 {
+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 {
@@ -64,15 +130,19 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
return "", nil
}
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.(ArtistMBIDRetriever)
+ retriever, ok := ag.(ArtistMBIDRetriever)
if !ok {
continue
}
- mbid, err := agent.GetArtistMBID(ctx, id, name)
+ mbid, err := retriever.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
@@ -89,15 +159,19 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
return "", nil
}
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.(ArtistURLRetriever)
+ retriever, ok := ag.(ArtistURLRetriever)
if !ok {
continue
}
- url, err := agent.GetArtistURL(ctx, id, name, mbid)
+ url, err := retriever.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
@@ -114,15 +188,19 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
return "", nil
}
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.(ArtistBiographyRetriever)
+ retriever, ok := ag.(ArtistBiographyRetriever)
if !ok {
continue
}
- bio, err := agent.GetArtistBiography(ctx, id, name, mbid)
+ bio, err := retriever.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
@@ -131,6 +209,8 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
return "", ErrNotFound
}
+// 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 +218,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))
@@ -168,15 +255,19 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
return nil, nil
}
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.(ArtistImageRetriever)
+ retriever, ok := ag.(ArtistImageRetriever)
if !ok {
continue
}
- images, err := agent.GetArtistImages(ctx, id, name, mbid)
+ images, err := retriever.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
@@ -185,6 +276,8 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
return nil, ErrNotFound
}
+// 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,16 +285,23 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
case consts.VariousArtistsID:
return nil, nil
}
+
+ overLimit := int(float64(count) * 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.(ArtistTopSongsRetriever)
+ retriever, ok := ag.(ArtistTopSongsRetriever)
if !ok {
continue
}
- songs, err := agent.GetArtistTopSongs(ctx, id, artistName, mbid, count)
+ songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
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
@@ -215,15 +315,19 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
return nil, ErrNotFound
}
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.(AlbumInfoRetriever)
+ retriever, ok := ag.(AlbumInfoRetriever)
if !ok {
continue
}
- album, err := agent.GetAlbumInfo(ctx, name, artist, mbid)
+ album, err := retriever.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))
@@ -233,6 +337,36 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
return nil, ErrNotFound
}
+func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) {
+ if name == consts.UnknownAlbum {
+ return nil, ErrNotFound
+ }
+ start := time.Now()
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
+ if ag == nil {
+ continue
+ }
+ if utils.IsCtxDone(ctx) {
+ break
+ }
+ retriever, ok := ag.(AlbumImageRetriever)
+ if !ok {
+ continue
+ }
+ images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
+ if err != nil {
+ log.Trace(ctx, "Agent GetAlbumImages failed", "agent", ag.AgentName(), "album", name, "artist", artist, "mbid", mbid, err)
+ }
+ if len(images) > 0 && err == nil {
+ log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist,
+ "mbid", mbid, "elapsed", time.Since(start))
+ return images, nil
+ }
+ }
+ return nil, ErrNotFound
+}
+
var _ Interface = (*Agents)(nil)
var _ ArtistMBIDRetriever = (*Agents)(nil)
var _ ArtistURLRetriever = (*Agents)(nil)
@@ -241,3 +375,4 @@ var _ ArtistSimilarRetriever = (*Agents)(nil)
var _ ArtistImageRetriever = (*Agents)(nil)
var _ ArtistTopSongsRetriever = (*Agents)(nil)
var _ AlbumInfoRetriever = (*Agents)(nil)
+var _ AlbumImageRetriever = (*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..0b7eec282 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"))
})
@@ -333,18 +374,6 @@ 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
}
@@ -355,3 +384,17 @@ type emptyAgent struct {
func (e *emptyAgent) AgentName() string {
return "empty"
}
+
+type testImageAgent struct {
+ Name string
+ Images []ExternalImage
+ Err error
+ Args []interface{}
+}
+
+func (t *testImageAgent) AgentName() string { return t.Name }
+
+func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
+ t.Args = []interface{}{id, name, mbid}
+ return t.Images, t.Err
+}
diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go
index 00f75627d..054a14c51 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,6 +33,7 @@ type ExternalImage struct {
}
type Song struct {
+ ID string
Name string
MBID string
}
@@ -40,11 +42,16 @@ 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)
}
diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go
index 2e60ca00b..909d299d8 100644
--- a/core/artwork/cache_warmer.go
+++ b/core/artwork/cache_warmer.go
@@ -96,8 +96,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
}
diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go
index d35fb6e82..7ae3a16e0 100644
--- a/core/artwork/cache_warmer_test.go
+++ b/core/artwork/cache_warmer_test.go
@@ -80,6 +80,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 +90,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"))
@@ -214,3 +216,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/reader_album.go b/core/artwork/reader_album.go
index 55d8b4352..cb4db97fe 100644
--- a/core/artwork/reader_album.go
+++ b/core/artwork/reader_album.go
@@ -1,6 +1,7 @@
package artwork
import (
+ "cmp"
"context"
"crypto/md5"
"fmt"
@@ -11,6 +12,7 @@ import (
"time"
"github.com/Masterminds/squirrel"
+ "github.com/maruel/natural"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external"
@@ -116,8 +118,30 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
}
// 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
}
+
+// compareImageFiles compares two image file paths for sorting.
+// It extracts the base filename (without extension) and compares case-insensitively.
+// This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1".
+// Note: This function is called O(n log n) times during sorting, but in practice albums
+// typically have only 1-20 image files, making the repeated string operations negligible.
+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(filepath.Base(a), filepath.Ext(a))
+ baseB := strings.TrimSuffix(filepath.Base(b), filepath.Ext(b))
+
+ // Compare base names first, then full paths if equal
+ return cmp.Or(
+ natural.Compare(baseA, baseB),
+ natural.Compare(a, b),
+ )
+}
diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go
index 2665632b9..fd5f8a2be 100644
--- a/core/artwork/reader_album_test.go
+++ b/core/artwork/reader_album_test.go
@@ -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,82 @@ 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
+ // 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(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")))
+ Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg")))
+ Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg")))
+ Expect(imgFiles[4]).To(Equal(filepath.FromSlash("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(filepath.FromSlash("Artist/Album/cover.jpg")))
+ Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.1.jpg")))
+ Expect(imgFiles[2]).To(Equal(filepath.FromSlash("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(filepath.FromSlash("Artist/Album/BACK.jpg")))
+ Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
+ Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg")))
})
})
})
diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go
index cb029a16e..da8141a2d 100644
--- a/core/artwork/reader_artist.go
+++ b/core/artwork/reader_artist.go
@@ -8,6 +8,7 @@ import (
"io/fs"
"os"
"path/filepath"
+ "slices"
"strings"
"time"
@@ -139,11 +140,22 @@ func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadClos
return nil, "", err
}
+ // Filter to valid image files
+ var imagePaths []string
for _, m := range matches {
if !model.IsImageFile(m) {
continue
}
- filePath := filepath.Join(folder, m)
+ 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)
+
+ // Try to open files in sorted order
+ for _, p := range imagePaths {
+ filePath := filepath.Join(folder, p)
f, err := os.Open(filePath)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go
index 527b0849f..e6a0168f8 100644
--- a/core/artwork/reader_artist_test.go
+++ b/core/artwork/reader_artist_test.go
@@ -240,24 +240,79 @@ var _ = Describe("artistArtworkReader", func() {
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
// Create multiple matching files
- Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
+ 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.txt"), []byte("text file"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
})
- It("returns the first valid image file", func() {
+ 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, not the text file
- Expect(path).To(SatisfyAny(
- ContainSubstring("artist.jpg"),
- ContainSubstring("artist.png"),
- ))
- Expect(path).ToNot(ContainSubstring("artist.txt"))
+ // 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, 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, 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()
})
})
diff --git a/core/artwork/sources.go b/core/artwork/sources.go
index 121e6c38b..c7da7b19b 100644
--- a/core/artwork/sources.go
+++ b/core/artwork/sources.go
@@ -16,12 +16,14 @@ import (
"time"
"github.com/dhowden/tag"
+ "github.com/navidrome/navidrome/conf"
"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) {
@@ -84,6 +86,13 @@ var picTypeRegexes = []*regexp.Regexp{
}
func fromTag(ctx context.Context, path string) sourceFunc {
+ if conf.Server.DevLegacyEmbedImage {
+ return fromTagLegacy(ctx, path)
+ }
+ return fromTagGoTaglib(ctx, path)
+}
+
+func fromTagLegacy(ctx context.Context, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {
return nil, "", nil
@@ -128,6 +137,44 @@ func fromTag(ctx context.Context, path string) sourceFunc {
}
}
+func fromTagGoTaglib(ctx context.Context, path string) sourceFunc {
+ return func() (io.ReadCloser, string, error) {
+ if path == "" {
+ return nil, "", nil
+ }
+ f, err := taglib.OpenReadOnly(path, taglib.WithReadStyle(taglib.ReadStyleFast))
+ if err != nil {
+ return nil, "", err
+ }
+ defer f.Close()
+
+ images := f.Properties().Images
+ if len(images) == 0 {
+ return nil, "", fmt.Errorf("no embedded image found in %s", path)
+ }
+
+ imageIndex := findBestImageIndex(ctx, images, path)
+ data, err := f.Image(imageIndex)
+ if err != nil || len(data) == 0 {
+ return nil, "", fmt.Errorf("could not load embedded image from %s", path)
+ }
+ return io.NopCloser(bytes.NewReader(data)), path, 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
+}
+
func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {
@@ -182,13 +229,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)
+ req.Header.Set("User-Agent", consts.HTTPUserAgent)
resp, err := hc.Do(req)
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/auth/auth.go b/core/auth/auth.go
index fd2b670a4..ddd12767b 100644
--- a/core/auth/auth.go
+++ b/core/auth/auth.go
@@ -113,9 +113,9 @@ 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{}
}
diff --git a/core/external/extdata_helper_test.go b/core/external/extdata_helper_test.go
index 367437815..29975e5c5 100644
--- a/core/external/extdata_helper_test.go
+++ b/core/external/extdata_helper_test.go
@@ -190,10 +190,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 +271,14 @@ 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)
+}
diff --git a/core/external/provider.go b/core/external/provider.go
index f27ded11b..a6eb848a0 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,6 @@ 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/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
@@ -34,7 +32,7 @@ const (
type Provider interface {
UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error)
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
- SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
+ ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error)
TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error)
ArtistImage(ctx context.Context, id string) (*url.URL, error)
AlbumImage(ctx context.Context, id string) (*url.URL, error)
@@ -49,16 +47,33 @@ type provider struct {
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
@@ -85,7 +100,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 +117,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 +128,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,12 +137,13 @@ 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
}
@@ -139,25 +155,26 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
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))
@@ -177,7 +194,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 +222,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 +233,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 +242,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
}
@@ -242,14 +260,14 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
_ = 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())
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))
@@ -257,7 +275,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
return artist, nil
}
-func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
+func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error) {
artist, err := e.getArtist(ctx, id)
if err != nil {
return nil, err
@@ -265,19 +283,19 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode
e.callGetSimilar(ctx, e.ag, &artist, 15, false)
if utils.IsCtxDone(ctx) {
- log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
+ log.Warn(ctx, "ArtistRadio call canceled", ctx.Err())
return nil, ctx.Err()
}
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
if utils.IsCtxDone(ctx) {
- log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
+ log.Warn(ctx, "ArtistRadio call canceled", ctx.Err())
return ctx.Err()
}
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
@@ -340,29 +358,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 +416,170 @@ 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, err
+ return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, 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
- }
+ idMatches, err := e.loadTracksByID(ctx, songs)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
}
+ mbidMatches, err := e.loadTracksByMBID(ctx, songs)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
+ }
+ titleMatches, err := e.loadTracksByTitle(ctx, songs, artist, idMatches, mbidMatches)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load tracks by title: %w", err)
+ }
+
+ log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numIDMatches", len(idMatches), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
+ mfs := e.selectTopSongs(songs, idMatches, mbidMatches, titleMatches, count)
+
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
+func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
+ var mbids []string
+ for _, s := range songs {
+ if s.MBID != "" {
+ mbids = append(mbids, s.MBID)
}
- return e.findMatchingTrack(ctx, "", artistID, title)
}
- mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ matches := map[string]model.MediaFile{}
+ if len(mbids) == 0 {
+ return matches, nil
+ }
+ res, err := e.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
+}
+
+func (e *provider) 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 := e.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
+}
+
+func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, idMatches, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
+ titleMap := map[string]string{}
+ for _, s := range songs {
+ // Skip if already matched by ID or MBID
+ if s.ID != "" && idMatches[s.ID].ID != "" {
+ continue
+ }
+ if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
+ continue
+ }
+ sanitized := str.SanitizeFieldForSorting(s.Name)
+ titleMap[sanitized] = s.Name
+ }
+ matches := map[string]model.MediaFile{}
+ if len(titleMap) == 0 {
+ return matches, nil
+ }
+ titleFilters := squirrel.Or{}
+ for sanitized := range titleMap {
+ titleFilters = append(titleFilters, squirrel.Like{"order_title": sanitized})
+ }
+
+ res, 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.Eq{"artist_id": artist.ID},
+ squirrel.Eq{"album_artist_id": artist.ID},
},
- squirrel.Like{"order_title": str.SanitizeFieldForSorting(title)},
+ titleFilters,
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
+ if err != nil {
+ return matches, err
}
- return &mfs[0], nil
+ for _, mf := range res {
+ sanitized := str.SanitizeFieldForSorting(mf.Title)
+ if _, ok := matches[sanitized]; !ok {
+ matches[sanitized] = mf
+ }
+ }
+ return matches, nil
+}
+
+func (e *provider) selectTopSongs(songs []agents.Song, byID, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles {
+ var mfs model.MediaFiles
+ for _, t := range songs {
+ if len(mfs) == count {
+ break
+ }
+ // Try ID match first
+ if t.ID != "" {
+ if mf, ok := byID[t.ID]; ok {
+ mfs = append(mfs, mf)
+ continue
+ }
+ }
+ // Try MBID match second
+ if t.MBID != "" {
+ if mf, ok := byMBID[t.MBID]; ok {
+ mfs = append(mfs, mf)
+ continue
+ }
+ }
+ // Fall back to title match
+ if mf, ok := byTitle[str.SanitizeFieldForSorting(t.Name)]; ok {
+ mfs = append(mfs, mf)
+ }
+ }
+ return mfs
}
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 +587,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 +597,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
}
@@ -492,63 +616,180 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet
func (e *provider) callGetSimilar(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 +801,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 +817,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..8a81b4f4d 100644
--- a/core/external/provider_albumimage_test.go
+++ b/core/external/provider_albumimage_test.go
@@ -23,7 +23,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,10 +42,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockAlbumAgent = newMockAlbumInfoAgent()
- agentsCombined = &mockAgents{
- albumInfoAgent: mockAlbumAgent,
- }
-
+ agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent}
provider = NewProvider(ds, agentsCombined)
// Default mocks
@@ -66,13 +62,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 +76,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 +93,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 +103,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 +112,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 +121,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 +129,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 +138,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 +147,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 +157,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 +168,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 +180,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 +198,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 +206,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 +218,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 +226,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 +236,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 +258,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 +351,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..11290bb66 100644
--- a/core/external/provider_artistimage_test.go
+++ b/core/external/provider_artistimage_test.go
@@ -265,6 +265,67 @@ var _ = Describe("Provider - ArtistImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
})
+
+ 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_artistradio_test.go
similarity index 80%
rename from core/external/provider_similarsongs_test.go
rename to core/external/provider_artistradio_test.go
index fd622746a..18afede6b 100644
--- a/core/external/provider_similarsongs_test.go
+++ b/core/external/provider_artistradio_test.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
+ "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/model"
@@ -13,7 +14,7 @@ import (
"github.com/stretchr/testify/mock"
)
-var _ = Describe("Provider - SimilarSongs", func() {
+var _ = Describe("Provider - ArtistRadio", func() {
var ds model.DataStore
var provider Provider
var mockAgent *mockSimilarArtistAgent
@@ -50,9 +51,9 @@ var _ = Describe("Provider - SimilarSongs", func() {
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()
@@ -67,8 +68,16 @@ var _ = Describe("Provider - SimilarSongs", func() {
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,11 +91,10 @@ 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)
+ songs, err := provider.ArtistRadio(ctx, "artist-1", 3)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(3))
@@ -103,7 +111,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{}, nil).Maybe()
- songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5)
+ songs, err := provider.ArtistRadio(ctx, "artist-unknown-artist", 5)
Expect(err).To(Equal(model.ErrNotFound))
Expect(songs).To(BeNil())
@@ -111,7 +119,7 @@ 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 {
@@ -130,9 +138,9 @@ 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)
+ songs, err := provider.ArtistRadio(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
@@ -157,7 +165,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, errors.New("error getting top songs")).Once()
- songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
+ songs, err := provider.ArtistRadio(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
@@ -165,8 +173,8 @@ 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 {
@@ -186,10 +194,9 @@ 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)
+ songs, err := provider.ArtistRadio(ctx, "artist-1", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go
index 4ce7911de..114a35f63 100644
--- a/core/external/provider_topsongs_test.go
+++ b/core/external/provider_topsongs_test.go
@@ -4,10 +4,10 @@ import (
"context"
"errors"
+ _ "github.com/navidrome/navidrome/adapters/lastfm"
+ _ "github.com/navidrome/navidrome/adapters/listenbrainz"
+ _ "github.com/navidrome/navidrome/adapters/spotify"
"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/model"
"github.com/navidrome/navidrome/tests"
@@ -42,10 +42,6 @@ var _ = Describe("Provider - TopSongs", func() {
p = NewProvider(ds, ag)
})
- BeforeEach(func() {
- // Setup expectations in individual tests
- })
-
It("returns top songs for a known artist", func() {
// Mock finding the artist
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
@@ -58,11 +54,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 +150,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 +184,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..5f5d41a87 100644
--- a/core/external/provider_updatealbuminfo_test.go
+++ b/core/external/provider_updatealbuminfo_test.go
@@ -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))
diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go
index 9b1e8d866..0c489eadd 100644
--- a/core/external/provider_updateartistinfo_test.go
+++ b/core/external/provider_updateartistinfo_test.go
@@ -226,4 +226,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..d134077ce 100644
--- a/core/ffmpeg/ffmpeg.go
+++ b/core/ffmpeg/ffmpeg.go
@@ -112,7 +112,7 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args}
j.PipeReader, j.out = io.Pipe()
- err := j.start()
+ err := j.start(ctx)
if err != nil {
return nil, err
}
@@ -127,8 +127,8 @@ type ffCmd struct {
cmd *exec.Cmd
}
-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 log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = os.Stderr
diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go
index 7e67a2a6a..debe0b51e 100644
--- a/core/ffmpeg/ffmpeg_test.go
+++ b/core/ffmpeg/ffmpeg_test.go
@@ -1,7 +1,11 @@
package ffmpeg
import (
+ "context"
+ "runtime"
+ sync "sync"
"testing"
+ "time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
@@ -65,4 +69,98 @@ var _ = Describe("ffmpeg", func() {
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
})
})
+
+ 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, command, "tests/fixtures/test.mp3", 128, 0)
+ 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()
+
+ // Next read should fail due to cancelled context
+ _, err = stream.Read(buf)
+ Expect(err).To(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, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
+ Expect(err).To(MatchError(context.Canceled))
+ })
+ })
+
+ 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, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
+ 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())
+ })
+ })
+ })
})
diff --git a/core/library.go b/core/library.go
new file mode 100644
index 000000000..bb81a0a81
--- /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 interface{}) (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 interface{}, _ ...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/sources.go b/core/lyrics/sources.go
index 6d4a4cc6f..857dc2eef 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
diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go
index e92564c00..b3d502101 100644
--- a/core/lyrics/sources_test.go
+++ b/core/lyrics/sources_test.go
@@ -108,5 +108,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(gg.P(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(gg.P(int64(18800))))
+ Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love"))
+ Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(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..750fd3a9e
--- /dev/null
+++ b/core/maintenance.go
@@ -0,0 +1,226 @@
+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.Add(1)
+ go func() {
+ defer s.wg.Done()
+ 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/media_streamer.go b/core/media_streamer.go
index b3593c4eb..c741ed476 100644
--- a/core/media_streamer.go
+++ b/core/media_streamer.go
@@ -204,7 +204,20 @@ func NewTranscodingCache() TranscodingCache {
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)
+
+ // Choose the appropriate context based on EnableTranscodingCancellation configuration.
+ // This is where we decide whether transcoding processes should be cancellable or not.
+ var transcodingCtx context.Context
+ if conf.Server.EnableTranscodingCancellation {
+ // Use the request context directly, allowing cancellation when client disconnects
+ transcodingCtx = ctx
+ } else {
+ // Use background context with request values preserved.
+ // This prevents cancellation but maintains request metadata (user, client, etc.)
+ transcodingCtx = request.AddValues(context.Background(), ctx)
+ }
+
+ out, err := job.ms.transcoder.Transcode(transcodingCtx, 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
diff --git a/core/metrics/insights.go b/core/metrics/insights.go
index 6076be0a5..df3ca9a42 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
@@ -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, ".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
@@ -176,13 +194,15 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
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.EnableSpotify = conf.Server.Spotify.ID != "" && conf.Server.Spotify.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
@@ -198,6 +218,9 @@ var staticData = sync.OnceValue(func() insights.Data {
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 != "" || conf.Server.PID.Album != ""
+ data.Config.HasCustomTags = len(conf.Server.Tags) > 0
return data
})
@@ -232,12 +255,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 +307,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..19007fd06 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,8 +37,10 @@ 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"`
@@ -55,11 +58,13 @@ 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"`
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
+ EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
SearchFullString bool `json:"searchFullString,omitempty"`
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
@@ -68,7 +73,17 @@ type Data struct {
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_test.go b/core/playback/mpv/mpv_test.go
index 08432bef3..20c02501b 100644
--- a/core/playback/mpv/mpv_test.go
+++ b/core/playback/mpv/mpv_test.go
@@ -372,7 +372,7 @@ goto loop
`
} else {
scriptExt = ".sh"
- scriptContent = `#!/bin/bash
+ scriptContent = `#!/bin/sh
echo "$0"
for arg in "$@"; do
echo "$arg"
diff --git a/core/playlists.go b/core/playlists.go
index 4cdab0d38..1f98bf508 100644
--- a/core/playlists.go
+++ b/core/playlists.go
@@ -1,6 +1,7 @@
package core
import (
+ "cmp"
"context"
"encoding/json"
"errors"
@@ -9,7 +10,7 @@ import (
"net/url"
"os"
"path/filepath"
- "regexp"
+ "slices"
"strings"
"time"
@@ -20,7 +21,9 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/utils/ioutils"
"github.com/navidrome/navidrome/utils/slice"
+ "golang.org/x/text/unicode/norm"
)
type Playlists interface {
@@ -96,12 +99,13 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
}
defer file.Close()
+ reader := ioutils.UTF8Reader(file)
extension := strings.ToLower(filepath.Ext(playlistFile))
switch extension {
case ".nsp":
- err = s.parseNSP(ctx, pls, file)
+ err = s.parseNSP(ctx, pls, reader)
default:
- err = s.parseM3U(ctx, pls, folder, file)
+ err = s.parseM3U(ctx, pls, folder, reader)
}
return pls, err
}
@@ -164,6 +168,11 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R
if nsp.Comment != "" {
pls.Comment = nsp.Comment
}
+ if nsp.Public != nil {
+ pls.Public = *nsp.Public
+ } else {
+ pls.Public = conf.Server.DefaultPlaylistPublicVisibility
+ }
return nil
}
@@ -191,22 +200,35 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
}
filteredLines = append(filteredLines, line)
}
- paths, err := s.normalizePaths(ctx, pls, folder, filteredLines)
+ resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
if err != nil {
- log.Warn(ctx, "Error normalizing paths in playlist", "playlist", pls.Name, err)
+ log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
continue
}
- found, err := mediaFileRepository.FindByPaths(paths)
+
+ // Normalize to NFD for filesystem compatibility (macOS). Database stores paths in NFD.
+ // See https://github.com/navidrome/navidrome/issues/4663
+ resolvedPaths = slice.Map(resolvedPaths, func(path string) string {
+ return strings.ToLower(norm.NFD.String(path))
+ })
+
+ found, err := mediaFileRepository.FindByPaths(resolvedPaths)
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
existing := make(map[string]int, len(found))
for idx := range found {
- existing[strings.ToLower(found[idx].Path)] = idx
+ // Normalize to lowercase for case-insensitive comparison
+ // Key format: "libraryID:path"
+ key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(found[idx].Path))
+ existing[key] = idx
}
- for _, path := range paths {
- idx, ok := existing[strings.ToLower(path)]
+
+ // Find media files in the order of the resolved paths, to keep playlist order
+ for _, path := range resolvedPaths {
+ idx, ok := existing[path]
if ok {
mfs = append(mfs, found[idx])
} else {
@@ -223,62 +245,150 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
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
+// 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
}
-func (s *playlists) compileLibraryPaths(ctx context.Context) (*regexp.Regexp, error) {
- libs, err := s.ds.Library(ctx).GetAll()
+// 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) {
+ // 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].ID, cleanLibPath
+ }
+ }
+ }
+ return 0, ""
+}
+
+// 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 (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
+ resolver, err := newPathResolver(ctx, s.ds)
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)
+ results := make([]string, 0, len(lines))
+ for idx, line := range lines {
+ resolution := resolver.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)
}
- // 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
+
+ return results, nil
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
@@ -304,7 +414,10 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
} 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
+ // 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)
}
@@ -326,7 +439,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
if needsTrackRefresh {
pls, err = repo.GetWithTracks(playlistID, true, false)
pls.RemoveTracks(idxToRemove)
- pls.AddTracks(idsToAdd)
+ pls.AddMediaFilesByID(idsToAdd)
} else {
if len(idsToAdd) > 0 {
_, err = tracks.Add(idsToAdd)
@@ -368,6 +481,7 @@ type nspFile struct {
criteria.Criteria
Name string `json:"name"`
Comment string `json:"comment"`
+ Public *bool `json:"public"`
}
func (i *nspFile) UnmarshalJSON(data []byte) error {
@@ -378,5 +492,8 @@ func (i *nspFile) UnmarshalJSON(data []byte) error {
}
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_internal_test.go b/core/playlists_internal_test.go
new file mode 100644
index 000000000..88e36cc3a
--- /dev/null
+++ b/core/playlists_internal_test.go
@@ -0,0 +1,406 @@
+package core
+
+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() {
+ 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() {
+ 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() {
+ 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())
+ })
+ })
+
+ Describe("resolvePath", func() {
+ Context("With absolute paths", 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_test.go b/core/playlists_test.go
index 3a3c9aafc..f3347ae77 100644
--- a/core/playlists_test.go
+++ b/core/playlists_test.go
@@ -1,4 +1,4 @@
-package core
+package core_test
import (
"context"
@@ -9,17 +9,19 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core"
"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", func() {
var ds *tests.MockDataStore
- var ps Playlists
+ var ps core.Playlists
var mockPlsRepo mockedPlaylistRepo
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
@@ -32,16 +34,16 @@ var _ = Describe("Playlists", func() {
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)
+ ps = core.NewPlaylists(ds)
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,
@@ -73,6 +75,24 @@ var _ = Describe("Playlists", func() {
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
+
+ It("parses playlists with UTF-8 BOM marker", func() {
+ pls, err := ps.ImportFile(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.ImportFile(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"))
+ })
})
Describe("NSP", func() {
@@ -92,6 +112,245 @@ var _ = Describe("Playlists", func() {
_, err := ps.ImportFile(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.ImportFile(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.ImportFile(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() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.DefaultPlaylistPublicVisibility = true
+
+ pls, err := ps.ImportFile(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
+ })
+ })
+
+ 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 = core.NewPlaylists(ds)
+ })
+
+ 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.ImportFile(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.ImportFile(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.ImportFile(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.ImportFile(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 = core.NewPlaylists(ds)
+
+ // 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.ImportFile(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"))
+ })
})
})
@@ -100,7 +359,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
- ps = NewPlaylists(ds)
+ ps = core.NewPlaylists(ds)
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
@@ -186,6 +445,24 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
})
+
+ It("handles Unicode normalization when comparing paths (NFD vs NFC)", func() {
+ // Simulate macOS filesystem: stores paths in NFD (decomposed) form
+ // "è" (U+00E8) in NFC becomes "e" + "◌̀" (U+0065 + U+0300) in NFD
+ nfdPath := "artist/Mich" + string([]rune{'e', '\u0300'}) + "le/song.mp3" // NFD: e + combining grave
+ repo.data = []string{nfdPath}
+
+ // Simulate Apple Music M3U: uses NFC (composed) form
+ nfcPath := "/music/artist/Mich\u00E8le/song.mp3" // NFC: single è character
+ m3u := nfcPath + "\n"
+ f := strings.NewReader(m3u)
+ pls, err := ps.ImportM3U(ctx, f)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(1))
+ // Should match despite different Unicode normalization forms
+ Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
+ })
+
})
Describe("InPlaylistsPath", func() {
@@ -202,27 +479,27 @@ var _ = Describe("Playlists", func() {
It("returns true if PlaylistsPath is empty", func() {
conf.Server.PlaylistsPath = ""
- Expect(InPlaylistsPath(folder)).To(BeTrue())
+ Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns true if PlaylistsPath is any (**/**)", func() {
conf.Server.PlaylistsPath = "**/**"
- Expect(InPlaylistsPath(folder)).To(BeTrue())
+ Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns true if folder is in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other/**:playlists/**"
- Expect(InPlaylistsPath(folder)).To(BeTrue())
+ Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns false if folder is not in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other"
- Expect(InPlaylistsPath(folder)).To(BeFalse())
+ Expect(core.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())
+ Expect(core.InPlaylistsPath(folder)).To(BeFalse())
folder2 := model.Folder{
LibraryPath: "/music",
@@ -230,22 +507,47 @@ var _ = Describe("Playlists", func() {
Name: ".",
}
- Expect(InPlaylistsPath(folder2)).To(BeTrue())
+ Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
})
})
})
-// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
+// 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: path,
+ ID: strconv.Itoa(idx),
+ Path: actualPath,
+ LibraryID: libraryID,
})
}
return mfs, nil
@@ -257,13 +559,38 @@ type mockedMediaFileFromListRepo struct {
data []string
}
-func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
+func (r *mockedMediaFileFromListRepo) FindByPaths(paths []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,
- })
+
+ for idx, dataPath := range r.data {
+ // Normalize the data path to NFD (simulates macOS filesystem storage)
+ normalizedDataPath := norm.NFD.String(dataPath)
+
+ 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]
+ }
+ }
+
+ // The request path should already be normalized to NFD by production code
+ // before calling FindByPaths (to match DB storage)
+ normalizedRequestPath := norm.NFD.String(actualPath)
+
+ // Case-insensitive comparison (like SQL's "collate nocase")
+ if strings.EqualFold(normalizedRequestPath, normalizedDataPath) {
+ mfs = append(mfs, model.MediaFile{
+ ID: strconv.Itoa(idx),
+ Path: dataPath, // Return original path from DB
+ LibraryID: libraryID,
+ })
+ break
+ }
+ }
}
return mfs, nil
}
diff --git a/core/publicurl/publicurl.go b/core/publicurl/publicurl.go
new file mode 100644
index 000000000..ff6f4221e
--- /dev/null
+++ b/core/publicurl/publicurl.go
@@ -0,0 +1,81 @@
+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(map[string]any{"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 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..18f8f8129
--- /dev/null
+++ b/core/publicurl/publicurl_test.go
@@ -0,0 +1,174 @@
+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 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..be36e1f24 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 {
@@ -92,8 +131,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..f8567e91b 100644
--- a/core/scrobbler/interfaces.go
+++ b/core/scrobbler/interfaces.go
@@ -21,7 +21,7 @@ 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
}
diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go
index 53f397647..a40808007 100644
--- a/core/scrobbler/play_tracker.go
+++ b/core/scrobbler/play_tracker.go
@@ -2,9 +2,12 @@ package scrobbler
import (
"context"
+ "maps"
"sort"
+ "sync"
"time"
+ "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -17,6 +20,7 @@ import (
type NowPlayingInfo struct {
MediaFile model.MediaFile
Start time.Time
+ Position int
Username string
PlayerId string
PlayerName string
@@ -27,31 +31,69 @@ type Submission struct {
Timestamp time.Time
}
+type nowPlayingEntry struct {
+ ctx context.Context
+ userId string
+ track *model.MediaFile
+ position int
+}
+
type PlayTracker interface {
- NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error
+ NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
Submit(ctx context.Context, submissions []Submission) error
}
-type playTracker struct {
- ds model.DataStore
- broker events.Broker
- playMap cache.SimpleCache[string, NowPlayingInfo]
- scrobblers map[string]Scrobbler
+// 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)
}
-func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
+type playTracker struct {
+ ds model.DataStore
+ broker events.Broker
+ playMap cache.SimpleCache[string, NowPlayingInfo]
+ 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{}
+}
+
+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 {
+func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker {
m := cache.NewSimpleCache[string, NowPlayingInfo]()
- p := &playTracker{ds: ds, playMap: m, broker: broker}
- p.scrobblers = make(map[string]Scrobbler)
+ 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{}),
+ }
+ if conf.Server.EnableNowPlaying {
+ m.OnExpiration(func(_ string, _ NowPlayingInfo) {
+ broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()})
+ })
+ }
+
var enabled []string
for name, constructor := range constructors {
s := constructor(ds)
@@ -61,13 +103,97 @@ 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()
return p
}
-func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error {
+// stopNowPlayingWorker stops the background worker. This is primarily for testing.
+func (p *playTracker) stopNowPlayingWorker() {
+ close(p.shutdown)
+ <-p.workerDone // Wait for 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
+ }
+
+ // 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
+ }
+
+ // 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 (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId)
if err != nil {
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
@@ -78,31 +204,92 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
info := NowPlayingInfo{
MediaFile: *mf,
Start: time.Now(),
+ Position: position,
Username: user.UserName,
PlayerId: playerId,
PlayerName: playerName,
}
- ttl := time.Duration(int(mf.Duration)+5) * time.Second
+ // Calculate TTL based on remaining track duration. If position exceeds track duration,
+ // remaining is set to 0 to avoid negative TTL.
+ remaining := int(mf.Duration) - position
+ if remaining < 0 {
+ remaining = 0
+ }
+ // Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration.
+ ttl := time.Duration(remaining+5) * time.Second
_ = p.playMap.AddWithTTL(playerId, info, ttl)
+ if conf.Server.EnableNowPlaying {
+ p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
+ }
player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled {
- p.dispatchNowPlaying(ctx, user.ID, mf)
+ p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position)
}
return nil
}
-func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile) {
+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
}
- for name, s := range p.scrobblers {
+ 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)
- err := s.NowPlaying(ctx, userId, t)
+ 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 NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
continue
@@ -164,8 +351,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 +367,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..f7edecdfd 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,61 @@ import (
. "github.com/onsi/gomega"
)
+// mockPluginLoader is a test implementation of PluginLoader for plugin scrobbler tests
+// Moved to top-level scope to avoid linter issues
+
+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 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.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests
track = model.MediaFile{
ID: "123",
@@ -61,43 +97,89 @@ var _ = Describe("PlayTracker", func() {
_ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album)
})
+ AfterEach(func() {
+ // Stop the worker goroutine to prevent data races between tests
+ tracker.(*playTracker).stopNowPlayingWorker()
+ })
+
It("does not register disabled scrobblers", func() {
- Expect(tracker.(*playTracker).scrobblers).To(HaveKey("fake"))
- Expect(tracker.(*playTracker).scrobblers).ToNot(HaveKey("disabled"))
+ Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
+ Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
})
Describe("NowPlaying", func() {
It("sends track to agent", func() {
- err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
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))
+ Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
+ Expect(fake.GetUserID()).To(Equal("u-1"))
+ Expect(fake.GetTrack().ID).To(Equal("123"))
+ Expect(fake.GetTrack().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")
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeFalse())
+ Expect(fake.GetNowPlayingCalled()).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")
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeFalse())
+ Expect(fake.GetNowPlayingCalled()).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")
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeFalse())
+ Expect(fake.GetNowPlayingCalled()).To(BeFalse())
+ })
+
+ It("stores position when greater than zero", func() {
+ pos := 42
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos)
+ Expect(err).ToNot(HaveOccurred())
+
+ Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos))
+
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].Position).To(Equal(pos))
+ })
+
+ It("sends event with count", func() {
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ Expect(err).ToNot(HaveOccurred())
+ eventList := eventBroker.getEvents()
+ Expect(eventList).ToNot(BeEmpty())
+ evt, ok := eventList[0].(*events.NowPlayingCount)
+ Expect(ok).To(BeTrue())
+ Expect(evt.Count).To(Equal(1))
+ })
+
+ It("does not send event when disabled", func() {
+ conf.Server.EnableNowPlaying = false
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(eventBroker.getEvents()).To(BeEmpty())
+ })
+
+ It("passes user to scrobbler via context (fix for issue #4787)", func() {
+ ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"})
+ ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
+
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ Expect(err).ToNot(HaveOccurred())
+ Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
+ // Verify the username was passed through async dispatch via context
+ Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser"))
})
})
@@ -106,10 +188,10 @@ 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")
+ ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
+ _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
+ _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
playing, err := tracker.GetNowPlaying(ctx)
@@ -127,6 +209,26 @@ var _ = Describe("PlayTracker", func() {
})
})
+ Describe("Expiration events", func() {
+ It("sends event when entry expires", func() {
+ info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
+ _ = tracker.(*playTracker).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 := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
+ _ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
+ Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0))
+ })
+ })
+
Describe("Submit", func() {
It("sends track to agent", func() {
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
@@ -135,10 +237,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 +266,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 +275,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 +284,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 +293,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 +302,324 @@ 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("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.(*playTracker).builtinScrobblers["fake"] = fake
+ tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake
+ })
+
+ It("registers and uses plugin scrobbler for NowPlaying", func() {
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ Expect(err).ToNot(HaveOccurred())
+ Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
+ })
+
+ It("removes plugin scrobbler if not present anymore", func() {
+ // First call: plugin present
+ _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
+ pluginFake.nowPlayingCalled.Store(false)
+ // Remove plugin
+ pluginLoader.SetNames([]string{})
+ _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ // Should not be called since plugin was removed
+ 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.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ 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.stopNowPlayingWorker()
+ })
+
+ 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())
+ })
+ })
})
type fakeScrobbler struct {
Authorized bool
- NowPlayingCalled bool
- ScrobbleCalled bool
- UserID string
- Track *model.MediaFile
- LastScrobble Scrobble
+ nowPlayingCalled atomic.Bool
+ ScrobbleCalled atomic.Bool
+ userID atomic.Pointer[string]
+ username atomic.Pointer[string]
+ track atomic.Pointer[model.MediaFile]
+ position atomic.Int32
+ LastScrobble atomic.Pointer[Scrobble]
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) GetPosition() int {
+ return int(f.position.Load())
+}
+
+func (f *fakeScrobbler) GetUsername() string {
+ if p := f.username.Load(); p != nil {
+ return *p
+ }
+ return ""
+}
+
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
}
@@ -243,3 +630,51 @@ 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)
+}
diff --git a/core/share.go b/core/share.go
index add88322d..eb5e6679b 100644
--- a/core/share.go
+++ b/core/share.go
@@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
+ "github.com/navidrome/navidrome/utils/str"
)
type Share interface {
@@ -119,9 +120,8 @@ 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
@@ -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/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..3ed01bbc4
--- /dev/null
+++ b/core/storage/local/local_test.go
@@ -0,0 +1,428 @@
+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/core/storage"
+ "github.com/navidrome/navidrome/model/metadata"
+ . "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() {
+ 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("with invalid extractor", func() {
+ It("should handle extractor validation correctly", func() {
+ // Note: The actual implementation uses log.Fatal which exits the process,
+ // so we test the normal path where extractors exist
+
+ u, err := url.Parse("file://" + tempDir)
+ Expect(err).ToNot(HaveOccurred())
+
+ storage := newLocalStorage(*u)
+ Expect(storage).ToNot(BeNil())
+ })
+ })
+ })
+
+ Describe("localStorage.FS", func() {
+ 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() {
+ // 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() {
+ // 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/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..60496e611 100644
--- a/core/storage/storage_test.go
+++ b/core/storage/storage_test.go
@@ -65,6 +65,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/user.go b/core/user.go
new file mode 100644
index 000000000..a0a5f5377
--- /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 interface{}) (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 interface{}, 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..a8b1fde03 100644
--- a/core/wire_providers.go
+++ b/core/wire_providers.go
@@ -17,6 +17,9 @@ var Set = wire.NewSet(
NewPlayers,
NewShare,
NewPlaylists,
+ NewLibrary,
+ NewUser,
+ NewMaintenance,
agents.GetAgents,
external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)),
diff --git a/db/db.go b/db/db.go
index cb1ebd9e3..71bc082b2 100644
--- a/db/db.go
+++ b/db/db.go
@@ -45,10 +45,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 +101,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 +116,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")
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/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 513ebb4a4..4c7a95a23 100644
--- a/go.mod
+++ b/go.mod
@@ -1,15 +1,20 @@
module github.com/navidrome/navidrome
-go 1.24.2
+go 1.25
-// 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
+replace (
+ // Fork to fix https://github.com/navidrome/navidrome/issues/3254
+ github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
+
+ // Fork to implement raw tags support
+ go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798
+)
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/bmatcuk/doublestar/v4 v4.9.2
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
@@ -21,109 +26,131 @@ require (
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/go-chi/chi/v5 v5.2.4
+ 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-viper/encoding/ini v0.1.1
- github.com/gohugoio/hashstructure v0.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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.6
+ github.com/maruel/natural v1.3.0
github.com/matoous/go-nanoid/v2 v2.1.0
- github.com/mattn/go-sqlite3 v1.14.28
+ github.com/mattn/go-sqlite3 v1.14.33
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/onsi/ginkgo/v2 v2.27.5
+ github.com/onsi/gomega v1.39.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/pressly/goose/v3 v3.26.0
+ 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.0
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.35.0
+ golang.org/x/net v0.49.0
+ golang.org/x/sync v0.19.0
+ golang.org/x/sys v0.40.0
+ golang.org/x/term v0.39.0
+ golang.org/x/text v0.33.0
+ golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
)
require (
+ dario.cat/mergo v1.0.2 // indirect
+ github.com/Masterminds/semver/v3 v3.4.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/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
- github.com/go-logr/logr v1.4.2 // 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/go-viper/mapstructure/v2 v2.5.0 // indirect
+ github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.5 // 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-20260115054156-294ebfa9ad83 // 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-20251118225945-96ee0021ea0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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/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/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/pkg/errors v0.9.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/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.67.5 // indirect
+ github.com/prometheus/procfs v0.19.2 // 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/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/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
- go.uber.org/automaxprocs v1.6.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.9.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.47.0 // indirect
+ golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
+ golang.org/x/mod v0.32.0 // indirect
+ golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
+ golang.org/x/tools v0.41.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+ gopkg.in/ini.v1 v1.67.1 // 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..ef3f8389d 100644
--- a/go.sum
+++ b/go.sum
@@ -1,17 +1,23 @@
+dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
+dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+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/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/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI=
+github.com/bmatcuk/doublestar/v4 v4.9.2/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/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
@@ -20,14 +26,18 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
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/deluan/go-taglib v0.0.0-20260119020817-8753c7531798 h1:q4fvcIK/LxElpyQILCejG6WPYjVb2F/4P93+k017ANk=
+github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
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=
@@ -46,8 +56,14 @@ 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/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=
@@ -55,67 +71,82 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
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/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.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
+github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
+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-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/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.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/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-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
+github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/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-20251118225945-96ee0021ea0f h1:Fnl4pzx8SR7k7JuzyW8lEtSFH6EQ8xgcypgIn8pcGIE=
+github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f/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/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,8 +161,8 @@ 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/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=
@@ -142,48 +173,55 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf
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/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
+github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
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/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
+github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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/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/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/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/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/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/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
+github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
+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.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
+github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
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=
@@ -196,81 +234,105 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
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.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
+github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
+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/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=
+go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
+go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
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/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
+golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
+golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
+golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
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/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
+golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
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.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.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 +341,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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
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 +353,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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
+golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
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.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
+golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
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 +395,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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
+golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
+golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
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.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
+gopkg.in/ini.v1 v1.67.1/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.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
+modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
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.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
+modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
diff --git a/log/log.go b/log/log.go
index 08a487fcd..3e8597bdd 100644
--- a/log/log.go
+++ b/log/log.go
@@ -11,6 +11,7 @@ import (
"runtime"
"sort"
"strings"
+ "sync"
"time"
"github.com/sirupsen/logrus"
@@ -28,8 +29,8 @@ var redacted = &Hook{
"(Secret:\")[\\w]*",
"(Spotify.*ID:\")[\\w]*",
"(PasswordEncryptionKey:[\\s]*\")[^\"]*",
- "(ReverseProxyUserHeader:[\\s]*\")[^\"]*",
- "(ReverseProxyWhitelist:[\\s]*\")[^\"]*",
+ "(UserHeader:[\\s]*\")[^\"]*",
+ "(TrustedSources:[\\s]*\")[^\"]*",
"(MetricsPath:[\\s]*\")[^\"]*",
"(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*",
"(DevAutoLoginUsername:[\\s]*\")[^\"]*",
@@ -70,6 +71,7 @@ type levelPath struct {
var (
currentLevel Level
+ loggerMu sync.RWMutex
defaultLogger = logrus.New()
logSourceLine = false
rootPath string
@@ -78,17 +80,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 +114,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 +131,8 @@ func SetLogSourceLine(enabled bool) {
func SetRedacting(enabled bool) {
if enabled {
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
defaultLogger.AddHook(redacted)
}
}
@@ -133,6 +141,8 @@ func SetOutput(w io.Writer) {
if runtime.GOOS == "windows" {
w = CRLFWriter(w)
}
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
defaultLogger.SetOutput(w)
}
@@ -158,10 +168,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
}
@@ -171,31 +185,31 @@ func IsGreaterOrEqualTo(level Level) bool {
}
func Fatal(args ...interface{}) {
- log(LevelFatal, args...)
+ Log(LevelFatal, args...)
os.Exit(1)
}
func Error(args ...interface{}) {
- log(LevelError, args...)
+ Log(LevelError, args...)
}
func Warn(args ...interface{}) {
- log(LevelWarn, args...)
+ Log(LevelWarn, args...)
}
func Info(args ...interface{}) {
- log(LevelInfo, args...)
+ Log(LevelInfo, args...)
}
func Debug(args ...interface{}) {
- log(LevelDebug, args...)
+ Log(LevelDebug, args...)
}
func Trace(args ...interface{}) {
- log(LevelTrace, args...)
+ Log(LevelTrace, args...)
}
-func log(level Level, args ...interface{}) {
+func Log(level Level, args ...interface{}) {
if !shouldLog(level, 3) {
return
}
@@ -203,11 +217,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 +242,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
}
@@ -310,6 +335,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/model/album.go b/model/album.go
index c9dc022cb..a8dcfe682 100644
--- a/model/album.go
+++ b/model/album.go
@@ -14,6 +14,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
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..309ee800f 100644
--- a/model/artist.go
+++ b/model/artist.go
@@ -78,11 +78,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/criteria/criteria.go b/model/criteria/criteria.go
index 493e53173..54ac59697 100644
--- a/model/criteria/criteria.go
+++ b/model/criteria/criteria.go
@@ -25,17 +25,48 @@ func (c Criteria) OrderBy() string {
if c.Sort == "" {
c.Sort = "title"
}
- 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 {
+
+ 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(c.Sort, ",")
+ fields := make([]string, 0, len(parts))
+
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+
+ dir := "asc"
+ if strings.HasPrefix(p, "+") || strings.HasPrefix(p, "-") {
+ if strings.HasPrefix(p, "-") {
+ dir = "desc"
+ }
+ p = strings.TrimSpace(p[1:])
+ }
+
+ sortField := strings.ToLower(p)
+ f := fieldMap[sortField]
+ if f == nil {
+ log.Error("Invalid field in 'sort' field", "sort", sortField)
+ continue
+ }
+
+ var mapped string
+
if f.order != "" {
mapped = f.order
} else if f.isTag {
- mapped = "COALESCE(json_extract(media_file.tags, '$." + sortField + "[0].value'), '')"
+ // Use the actual field name (handles aliases like albumtype -> releasetype)
+ tagName := sortField
+ if f.field != "" {
+ tagName = f.field
+ }
+ mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')"
} else if f.isRole {
mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')"
} else {
@@ -44,15 +75,20 @@ func (c Criteria) OrderBy() string {
if f.numeric {
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
}
- }
- 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)
+ // If the global 'order' field is set to 'desc', reverse the default or field-specific sort direction.
+ // This ensures that the global order applies consistently across all fields.
+ if order == "desc" {
+ if dir == "asc" {
+ dir = "desc"
+ } else {
+ dir = "asc"
+ }
}
+
+ fields = append(fields, mapped+" "+dir)
}
- return mapped
+
+ return strings.Join(fields, ", ")
}
func (c Criteria) ToSql() (sql string, args []any, err error) {
diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go
index 7afb6ec0d..032ead5c8 100644
--- a/model/criteria/criteria_test.go
+++ b/model/criteria/criteria_test.go
@@ -118,11 +118,43 @@ var _ = Describe("Criteria", func() {
)
})
+ It("sorts by albumtype alias (resolves to releasetype)", func() {
+ AddTagNames([]string{"releasetype"})
+ goObj.Sort = "albumtype"
+ gomega.Expect(goObj.OrderBy()).To(
+ gomega.Equal(
+ "COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc",
+ ),
+ )
+ })
+
It("sorts by random", func() {
newObj := goObj
newObj.Sort = "random"
gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc"))
})
+
+ It("sorts by multiple fields", func() {
+ goObj.Sort = "title,-rating"
+ gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
+ "media_file.title asc, COALESCE(annotation.rating, 0) desc",
+ ))
+ })
+
+ It("reverts order when order is desc", func() {
+ goObj.Sort = "-date,artist"
+ goObj.Order = "desc"
+ gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
+ "media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc",
+ ))
+ })
+
+ It("ignores invalid sort fields", func() {
+ goObj.Sort = "bogus,title"
+ gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
+ "media_file.title asc",
+ ))
+ })
})
})
diff --git a/model/criteria/fields.go b/model/criteria/fields.go
index b7178e540..5381ae597 100644
--- a/model/criteria/fields.go
+++ b/model/criteria/fields.go
@@ -10,43 +10,53 @@ import (
)
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)"},
+ "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"},
+ "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"},
+ "daterated": {field: "annotation.rated_at"},
+ "playcount": {field: "COALESCE(annotation.play_count, 0)"},
+ "rating": {field: "COALESCE(annotation.rating, 0)"},
+ "mbz_album_id": {field: "media_file.mbz_album_id"},
+ "mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"},
+ "mbz_artist_id": {field: "media_file.mbz_artist_id"},
+ "mbz_recording_id": {field: "media_file.mbz_recording_id"},
+ "mbz_release_track_id": {field: "media_file.mbz_release_track_id"},
+ "mbz_release_group_id": {field: "media_file.mbz_release_group_id"},
+ "library_id": {field: "media_file.library_id", numeric: true},
+
+ // Backward compatibility: albumtype is an alias for releasetype tag
+ "albumtype": {field: "releasetype", isTag: true},
// special fields
"random": {field: "", order: "random()"}, // pseudo-field for random sorting
@@ -147,13 +157,19 @@ type tagCond struct {
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)")
+ // Resolve the actual tag name (handles aliases like albumtype -> releasetype)
+ tagName := e.tag
+ if fm, ok := fieldMap[e.tag]; ok {
+ if fm.field != "" {
+ tagName = fm.field
+ }
+ if 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)
+ tagName, cond)
if e.not {
cond = "not " + cond
}
diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go
index 95f9fc5f4..4c1db1303 100644
--- a/model/criteria/operators_test.go
+++ b/model/criteria/operators_test.go
@@ -29,7 +29,11 @@ var _ = Describe("Operators", func() {
},
Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
+ Entry("is [numeric]", Is{"library_id": 1}, "media_file.library_id = ?", 1),
+ Entry("is [numeric list]", Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2),
Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
+ Entry("isNot [numeric]", IsNot{"library_id": 1}, "media_file.library_id <> ?", 1),
+ Entry("isNot [numeric list]", IsNot{"library_id": []int{1, 2}}, "media_file.library_id NOT IN (?,?)", 1, 2),
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%"),
@@ -101,6 +105,40 @@ var _ = Describe("Operators", func() {
gomega.Expect(sql).To(gomega.BeEmpty())
gomega.Expect(args).To(gomega.BeEmpty())
})
+ It("supports releasetype as multi-valued tag", func() {
+ AddTagNames([]string{"releasetype"})
+ op := Contains{"releasetype": "soundtrack"}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%"))
+ })
+ It("supports albumtype as alias for releasetype", func() {
+ AddTagNames([]string{"releasetype"})
+ op := Contains{"albumtype": "live"}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("%live%"))
+ })
+ It("supports albumtype alias with Is operator", func() {
+ AddTagNames([]string{"releasetype"})
+ op := Is{"albumtype": "album"}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ // Should query $.releasetype, not $.albumtype
+ gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("album"))
+ })
+ It("supports albumtype alias with IsNot operator", func() {
+ AddTagNames([]string{"releasetype"})
+ op := IsNot{"albumtype": "compilation"}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ // Should query $.releasetype, not $.albumtype
+ gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("compilation"))
+ })
})
Describe("Custom Roles", func() {
diff --git a/model/datastore.go b/model/datastore.go
index 4290e2134..a187c4953 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
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/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/mediafile.go b/model/mediafile.go
index 5068e5d04..1ae63e759 100644
--- a/model/mediafile.go
+++ b/model/mediafile.go
@@ -26,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"`
@@ -36,53 +37,53 @@ 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"`
+ 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"`
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
@@ -352,6 +353,7 @@ 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
Get(id string) (*MediaFile, error)
@@ -367,6 +369,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/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..c64e8c724 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
@@ -77,7 +77,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 +104,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..e3adf3fae 100644
--- a/model/metadata/map_mediafile_test.go
+++ b/model/metadata/map_mediafile_test.go
@@ -75,6 +75,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() {
diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go
index 471c2434c..1372d0034 100644
--- a/model/metadata/metadata.go
+++ b/model/metadata/metadata.go
@@ -103,9 +103,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 +121,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 +245,14 @@ 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)
+ id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
}
- return parseID3Pairs(name, lowered)
+ return id3Base
}
func parseID3Pairs(name model.TagName, lowered model.Tags) []string {
diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go
index d7473afa7..82afd8657 100644
--- a/model/metadata/metadata_test.go
+++ b/model/metadata/metadata_test.go
@@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/utils"
+ "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -257,38 +258,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", gg.P(0.0)),
+ Entry("1.2dB", "1.2dB", gg.P(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", gg.P(0.0)),
+ Entry("1.0", "1.0", gg.P(1.0)),
+ Entry("0.5", "0.5", gg.P(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", gg.P(5.0)),
+ Entry("-3776", "-3776", gg.P(-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..d4222441c 100644
--- a/model/metadata/persistent_ids.go
+++ b/model/metadata/persistent_ids.go
@@ -2,11 +2,13 @@ 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"
@@ -16,17 +18,24 @@ import (
type hashFunc = func(...string) string
-// getPID returns the persistent ID for a given spec, getting the referenced values from the metadata
+// createGetPID returns a function that calculates 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 {
+type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string
+
+func createGetPID(hash hashFunc) getPIDFunc {
+ var getPID getPIDFunc
+ getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string) string {
+ attr = strings.TrimSpace(strings.ToLower(attr))
switch attr {
case "albumid":
- return getPID(mf, md, conf.Server.PID.Album)
+ if spec == conf.Server.PID.Album {
+ log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
+ return ""
+ }
+ return getPID(mf, md, conf.Server.PID.Album, prependLibId)
case "folder":
return filepath.Dir(mf.Path)
case "albumartistid":
@@ -38,14 +47,14 @@ func createGetPID(hash hashFunc) func(mf model.MediaFile, md Metadata, spec stri
}
return md.String(model.TagName(attr))
}
- getPID = func(mf model.MediaFile, md Metadata, spec string) string {
+ getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) 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)
+ v := getAttr(mf, md, attr, prependLibId, spec)
if v != "" {
hasValue = true
}
@@ -56,32 +65,35 @@ func createGetPID(hash hashFunc) func(mf model.MediaFile, md Metadata, spec stri
break
}
}
+ if prependLibId {
+ pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid)
+ }
return hash(pid)
}
- return func(mf model.MediaFile, md Metadata, spec string) string {
+ return func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
switch spec {
case "track_legacy":
- return legacyTrackID(mf)
+ return legacyTrackID(mf, prependLibId)
case "album_legacy":
- return legacyAlbumID(md)
+ return legacyAlbumID(mf, md, prependLibId)
}
- return getPID(mf, md, spec)
+ return getPID(mf, md, spec, prependLibId)
}
}
func (md Metadata) trackPID(mf model.MediaFile) string {
- return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track)
+ return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track, true)
}
-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 createGetPID(id.NewHash)(mf, md, pidConf, true)
}
// BFR Must be configurable?
func (md Metadata) artistID(name string) string {
mf := model.MediaFile{AlbumArtist: name}
- return createGetPID(id.NewHash)(mf, md, "albumartistid")
+ return createGetPID(id.NewHash)(mf, md, "albumartistid", false)
}
func (md Metadata) mapTrackTitle() string {
diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go
index 6903abc05..ad81eaa53 100644
--- a/model/metadata/persistent_ids_test.go
+++ b/model/metadata/persistent_ids_test.go
@@ -15,7 +15,7 @@ var _ = Describe("getPID", func() {
md Metadata
mf model.MediaFile
sum hashFunc
- getPID func(mf model.MediaFile, md Metadata, spec string) string
+ getPID getPIDFunc
)
BeforeEach(func() {
@@ -28,7 +28,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 +40,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 +48,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,10 +57,11 @@ 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())
@@ -72,7 +73,7 @@ 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() {
@@ -80,7 +81,7 @@ var _ = Describe("getPID", func() {
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() {
@@ -93,7 +94,7 @@ var _ = Describe("getPID", func() {
"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\\version\\2021-01-01))"))
})
})
When("field is albumartistid", func() {
@@ -103,14 +104,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 6380cfb4d..1d59674da 100644
--- a/model/playlist.go
+++ b/model/playlist.go
@@ -42,6 +42,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,6 +66,7 @@ func (pls *Playlist) RemoveTracks(idxToRemove []int) {
newTracks = append(newTracks, t)
}
pls.Tracks = newTracks
+ pls.refreshStats()
}
// ToM3U8 exports the playlist to the Extended M3U8 format
@@ -58,7 +74,7 @@ func (pls *Playlist) ToM3U8() 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++
@@ -70,6 +86,7 @@ func (pls *Playlist) AddTracks(mediaFileIds []string) {
}
pls.Tracks = append(pls.Tracks, t)
}
+ pls.refreshStats()
}
func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
@@ -84,6 +101,7 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
}
pls.Tracks = append(pls.Tracks, t)
}
+ pls.refreshStats()
}
func (pls Playlist) CoverArtID() ArtworkID {
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..d23103995
--- /dev/null
+++ b/model/plugin.go
@@ -0,0 +1,30 @@
+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"`
+ 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
+ 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/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..389c77f87
--- /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
+ colonIdx := strings.Index(part, ":")
+ if colonIdx == -1 {
+ return nil, fmt.Errorf("invalid target format: %q (expected libraryID:folderPath)", part)
+ }
+
+ libIDStr := part[:colonIdx]
+ folderPath := part[colonIdx+1:]
+
+ 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..631a11726 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, offset, size int, options ...QueryOptions) (T, error)
}
diff --git a/model/tag.go b/model/tag.go
index a1f4e28da..674f688ca 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
@@ -153,7 +153,7 @@ func (t Tags) Add(name TagName, v string) {
}
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..bfe098f77 100644
--- a/model/tag_mappings.go
+++ b/model/tag_mappings.go
@@ -139,7 +139,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 +149,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/user.go b/model/user.go
index 7c41ac041..2127b635c 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,6 +15,9 @@ 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.
@@ -22,11 +27,26 @@ type User struct {
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..adca058c2 100644
--- a/persistence/album_repository.go
+++ b/persistence/album_repository.go
@@ -12,6 +12,7 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
+ "github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -105,6 +106,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 +114,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() {
@@ -146,10 +149,6 @@ func recentlyPlayedFilter(string, interface{}) Sqlizer {
return Gt{"play_count": 0}
}
-func hasRatingFilter(string, interface{}) Sqlizer {
- return Gt{"rating": 0}
-}
-
func yearFilter(_ string, value interface{}) Sqlizer {
return Or{
And{
@@ -183,9 +182,10 @@ func allRolesFilter(_ string, value interface{}) Sqlizer {
}
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) {
@@ -215,8 +215,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) {
@@ -290,7 +292,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"),
@@ -333,8 +334,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,13 +350,20 @@ func (r *albumRepository) purgeEmpty() error {
return nil
}
-func (r *albumRepository) Search(q string, offset int, size int, includeMissing bool) (model.Albums, error) {
+func (r *albumRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) {
var res dbAlbums
- err := r.doSearch(r.selectAlbum(), q, offset, size, includeMissing, &res, "name")
- if err != nil {
- return nil, err
+ if uuid.Validate(q) == nil {
+ err := r.searchByMBID(r.selectAlbum(options...), q, []string{"mbz_album_id", "mbz_release_group_id"}, &res)
+ if err != nil {
+ return nil, fmt.Errorf("searching album by MBID %q: %w", q, err)
+ }
+ } else {
+ err := r.doSearch(r.selectAlbum(options...), q, offset, size, &res, "album.rowid", "name")
+ if err != nil {
+ return nil, fmt.Errorf("searching album by query %q: %w", q, err)
+ }
}
- return res.toModels(), err
+ return res.toModels(), nil
}
func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) {
diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go
index 529458c26..2705653ab 100644
--- a/persistence/album_repository_test.go
+++ b/persistence/album_repository_test.go
@@ -1,13 +1,13 @@
package persistence
import (
- "context"
"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 +16,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{}
}
@@ -42,7 +42,7 @@ var _ = Describe("AlbumRepository", func() {
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,6 +56,7 @@ var _ = Describe("AlbumRepository", func() {
It("returns all records sorted", func() {
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
albumAbbeyRoad,
+ albumMultiDisc,
albumRadioactivity,
albumSgtPeppers,
}))
@@ -65,6 +66,7 @@ var _ = Describe("AlbumRepository", func() {
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
albumSgtPeppers,
albumRadioactivity,
+ albumMultiDisc,
albumAbbeyRoad,
}))
})
@@ -76,6 +78,82 @@ var _ = Describe("AlbumRepository", func() {
})
})
+ 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 +161,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())
+ Expect(albumRepo.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.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 +184,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())
+ Expect(albumRepo.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.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 +203,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
@@ -283,6 +444,299 @@ 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}))
+ })
+ })
})
func _p(id, name string, sortName ...string) model.Participant {
diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go
index c656950ce..5c34ace5d 100644
--- a/persistence/artist_repository.go
+++ b/persistence/artist_repository.go
@@ -11,6 +11,7 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
+ "github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -26,9 +27,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 +38,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
@@ -112,18 +131,25 @@ 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"),
+ "role": roleFilter,
+ "missing": booleanFilter,
+ "library_id": artistLibraryIdFilter,
})
r.setSortMappings(map[string]string{
"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
}
@@ -131,26 +157,60 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
func roleFilter(_ string, role any) Sqlizer {
if role, ok := role.(string); ok {
if _, ok := model.AllRoles[role]; ok {
- return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil}
+ return Expr("JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL")
}
}
return Eq{"1": 2}
}
-func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
- query := r.newSelect(options...).Columns("artist.*")
- query = r.withAnnotation(query, "artist.id")
+// artistLibraryIdFilter filters artists based on library access through the library_artist table
+func artistLibraryIdFilter(_ string, value interface{}) 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 {
+ // 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 {
@@ -207,8 +267,15 @@ 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 {
@@ -223,10 +290,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})
@@ -292,75 +368,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
@@ -379,21 +477,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
@@ -406,17 +499,35 @@ 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) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) {
+ var res dbArtists
+ if uuid.Validate(q) == nil {
+ err := r.searchByMBID(r.selectArtist(options...), q, []string{"mbz_artist_id"}, &res)
+ if err != nil {
+ return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err)
+ }
+ } else {
+ // Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist
+ err := r.doSearch(r.selectArtist(options...), q, offset, size, &res, "artist.id",
+ "sum(json_extract(stats, '$.total.m')) desc", "name")
+ if err != nil {
+ return nil, fmt.Errorf("searching artist by query %q: %w", q, err)
+ }
}
- return dba.toModels(), nil
+ return res.toModels(), nil
}
func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {
@@ -434,9 +545,9 @@ func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, e
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...))
}
diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go
index c85ef95cc..18883378d 100644
--- a/persistence/artist_repository_test.go
+++ b/persistence/artist_repository_test.go
@@ -5,9 +5,9 @@ import (
"encoding/json"
"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/request"
"github.com/navidrome/navidrome/utils"
@@ -15,393 +15,807 @@ 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())
+ Context("Admin User Operations", func() {
+ var repo model.ArtistRepository
- 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))
+ BeforeEach(func() {
+ ctx := GinkgoT().Context()
+ ctx = request.WithUser(ctx, adminUser)
+ repo = NewArtistRepository(ctx, GetDBXBuilder())
+ })
- // Restore the original value
- artistBeatles.SortArtistName = ""
- er = repo.Put(&artistBeatles)
- Expect(er).To(BeNil())
+ Describe("Basic Operations", func() {
+ Describe("Count", func() {
+ It("returns the number of artists in the DB", func() {
+ Expect(repo.CountAll()).To(Equal(int64(2)))
+ })
})
- // 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))
+ 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))
+ })
})
})
- 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())
+ 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("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))
+ idx, err := repo.GetIndex(false, []int{1})
+ 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())
+ // 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(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))
+ })
})
- 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))
+ 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(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, []int{1})
+ 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("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(2))
+
+ // 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
+ })
})
})
- When("filtering by role", func() {
- var raw *artistRepository
+ Describe("Filters", func() {
+ var artistWithoutAnnotation model.Artist
BeforeEach(func() {
- raw = repo.(*artistRepository)
- // Add stats to artists using direct SQL since Put doesn't populate stats
- composerStats := `{"composer": {"s": 1000, "m": 5, "a": 2}}`
- producerStats := `{"producer": {"s": 500, "m": 3, "a": 1}}`
-
- // Set Beatles as composer
- _, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", composerStats).Where(squirrel.Eq{"id": artistBeatles.ID}))
- Expect(err).ToNot(HaveOccurred())
-
- // Set Kraftwerk as producer
- _, err = raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", producerStats).Where(squirrel.Eq{"id": artistKraftwerk.ID}))
+ // 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() {
- // Clean up stats
- _, _ = raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", "{}").Where(squirrel.Eq{"id": artistBeatles.ID}))
- _, _ = raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", "{}").Where(squirrel.Eq{"id": artistKraftwerk.ID}))
+ if raw, ok := repo.(*artistRepository); ok {
+ _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithoutAnnotation.ID}))
+ }
})
- It("returns only artists with the specified role", func() {
- idx, err := repo.GetIndex(false, model.RoleComposer)
+ 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())
- 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, model.RoleComposer, model.RoleProducer)
+ 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", 0, 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", 0, 10)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+
+ // But admin should find it
+ results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 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", 0, 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", 0, 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(2)))
+
+ artists, err := repo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(artists).To(HaveLen(2))
+
+ 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
+
+ BeforeEach(func() {
+ raw = repo.(*artistRepository)
+ 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())
+
+ _, 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(3)) // Including the missing artist
+
+ // Search never returns missing artists (hardcoded behavior)
+ results, err := repo.Search("Missing Artist", 0, 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", 0, 10)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+
+ results, err = restrictedRepo.Search("Kraftwerk", 0, 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(2))) // Beatles and Kraftwerk
+ })
+
+ It("GetAll returns artists after gaining access", func() {
+ artists, err := restrictedRepo.GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(artists).To(HaveLen(2))
+
+ var names []string
+ for _, artist := range artists {
+ names = append(names, artist.Name)
+ }
+ Expect(names).To(ContainElements("The Beatles", "Kraftwerk"))
+ })
+
+ It("Exists returns true for accessible artists", func() {
+ exists, err := restrictedRepo.Exists(artistBeatles.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(exists).To(BeTrue())
+
+ 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(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, model.RoleDirector)
+ // 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("dbArtist mapping", func() {
- var (
- artist *model.Artist
- dba *dbArtist
- )
-
- BeforeEach(func() {
- artist = &model.Artist{ID: "1", Name: "Eddie Van Halen", SortArtistName: "Van Halen, Eddie"}
- dba = &dbArtist{Artist: artist}
- })
-
- 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"}]`
-
- 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("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("Missing artist visibility", func() {
- var raw *artistRepository
- var missing model.Artist
-
- insertMissing := func() {
- missing = model.Artist{ID: "m1", Name: "Missing", OrderArtistName: "missing"}
- Expect(repo.Put(&missing)).To(Succeed())
- raw = repo.(*artistRepository)
- _, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missing.ID}))
- Expect(err).ToNot(HaveOccurred())
- }
-
- removeMissing := func() {
- if raw != nil {
- _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missing.ID}))
- }
- }
-
- 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))
- })
- })
-
- 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()
- })
-
- 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))
- })
- })
- })
- })
-
- Describe("roleFilter", func() {
- It("filters out roles not present in the participants model", func() {
- Expect(roleFilter("", "artist")).To(Equal(squirrel.NotEq{"stats ->> '$.artist'": nil}))
- Expect(roleFilter("", "albumartist")).To(Equal(squirrel.NotEq{"stats ->> '$.albumartist'": nil}))
- Expect(roleFilter("", "composer")).To(Equal(squirrel.NotEq{"stats ->> '$.composer'": nil}))
- Expect(roleFilter("", "conductor")).To(Equal(squirrel.NotEq{"stats ->> '$.conductor'": nil}))
- Expect(roleFilter("", "lyricist")).To(Equal(squirrel.NotEq{"stats ->> '$.lyricist'": nil}))
- Expect(roleFilter("", "arranger")).To(Equal(squirrel.NotEq{"stats ->> '$.arranger'": nil}))
- Expect(roleFilter("", "producer")).To(Equal(squirrel.NotEq{"stats ->> '$.producer'": nil}))
- Expect(roleFilter("", "director")).To(Equal(squirrel.NotEq{"stats ->> '$.director'": nil}))
- Expect(roleFilter("", "engineer")).To(Equal(squirrel.NotEq{"stats ->> '$.engineer'": nil}))
- Expect(roleFilter("", "mixer")).To(Equal(squirrel.NotEq{"stats ->> '$.mixer'": nil}))
- Expect(roleFilter("", "remixer")).To(Equal(squirrel.NotEq{"stats ->> '$.remixer'": nil}))
- Expect(roleFilter("", "djmixer")).To(Equal(squirrel.NotEq{"stats ->> '$.djmixer'": nil}))
- Expect(roleFilter("", "performer")).To(Equal(squirrel.NotEq{"stats ->> '$.performer'": nil}))
-
- Expect(roleFilter("", "wizard")).To(Equal(squirrel.Eq{"1": 2}))
- Expect(roleFilter("", "songanddanceman")).To(Equal(squirrel.Eq{"1": 2}))
- Expect(roleFilter("", "artist') SELECT LIKE(CHAR(65,66,67,68,69,70,71),UPPER(HEX(RANDOMBLOB(500000000/2))))--")).To(Equal(squirrel.Eq{"1": 2}))
- })
- })
})
+
+// 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/folder_repository.go b/persistence/folder_repository.go
index a8b7884b7..f80cbde65 100644
--- a/persistence/folder_repository.go
+++ b/persistence/folder_repository.go
@@ -4,7 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
+ "os"
+ "path/filepath"
"slices"
+ "strings"
"time"
. "github.com/Masterminds/squirrel"
@@ -61,8 +64,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 +89,101 @@ 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
+ }
+ for id, info := range batchResult {
+ result[id] = info
+ }
+ }
+
+ 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
}
@@ -146,7 +228,7 @@ func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error)
}, 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 +236,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..6c24741c9
--- /dev/null
+++ b/persistence/folder_repository_test.go
@@ -0,0 +1,213 @@
+package persistence
+
+import (
+ "context"
+ "fmt"
+
+ "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("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() {
+ // 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() {
+ // 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() {
+ // 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() {
+ // 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())
+ })
+ })
+ })
+})
diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go
index e92e1491a..5857350a6 100644
--- a/persistence/genre_repository.go
+++ b/persistence/genre_repository.go
@@ -10,31 +10,18 @@ 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
+ genreFilter := model.TagGenre
+ return &genreRepository{
+ baseTagRepository: newBaseTagRepository(ctx, db, &genreFilter),
+ }
}
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,12 +31,10 @@ 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})
+ sel := r.selectGenre().Where(Eq{"tag.id": id})
var res model.Genre
err := r.queryOne(sel, &res)
return &res, err
@@ -59,10 +44,6 @@ func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, er
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
-func (r *genreRepository) EntityName() string {
- return r.tableName
-}
-
func (r *genreRepository) NewInstance() interface{} {
return &model.Genre{}
}
diff --git a/persistence/genre_repository_test.go b/persistence/genre_repository_test.go
new file mode 100644
index 000000000..67e84ce51
--- /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]interface{}{"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]interface{}{"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/library_repository.go b/persistence/library_repository.go
index 5ec54b964..f9ea65001 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) (interface{}, 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) (interface{}, error) {
+ return r.GetAll(r.parseRestOptions(r.ctx, options...))
+}
+
+func (r *libraryRepository) EntityName() string {
+ return "library"
+}
+
+func (r *libraryRepository) NewInstance() interface{} {
+ return &model.Library{}
+}
+
+func (r *libraryRepository) Save(entity interface{}) (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 interface{}, 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..3e3972bdb
--- /dev/null
+++ b/persistence/library_repository_test.go
@@ -0,0 +1,203 @@
+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"
+ "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
+
+ // 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..9c682369a 100644
--- a/persistence/mediafile_repository.go
+++ b/persistence/mediafile_repository.go
@@ -4,11 +4,15 @@ import (
"context"
"fmt"
"slices"
+ "strconv"
+ "strings"
"sync"
"time"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
+ "github.com/google/uuid"
+ "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 {
@@ -74,13 +78,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 +94,12 @@ 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"),
"genre_id": tagIDFilter,
"missing": booleanFilter,
"artists_id": artistFilter,
+ "library_id": libraryIdFilter,
}
// Add all album tags as filters
for tag := range model.TagMappings() {
@@ -103,12 +110,39 @@ 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})
}
@@ -124,10 +158,11 @@ func (r *mediaFileRepository) Put(m *model.MediaFile) error {
}
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) {
@@ -179,12 +214,43 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me
}, 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 +329,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{
@@ -284,13 +350,68 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC
}, 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
+}
+
+func (r *mediaFileRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) {
+ var res dbMediaFiles
+ if uuid.Validate(q) == nil {
+ err := r.searchByMBID(r.selectMediaFile(options...), q, []string{"mbz_recording_id", "mbz_release_track_id"}, &res)
+ if err != nil {
+ return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err)
+ }
+ } else {
+ err := r.doSearch(r.selectMediaFile(options...), q, offset, size, &res, "media_file.rowid", "title")
+ if err != nil {
+ return nil, fmt.Errorf("searching media_file by query %q: %w", q, err)
+ }
+ }
+ return res.toModels(), nil
}
func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) {
diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go
index c17bc595b..9f62a6a7c 100644
--- a/persistence/mediafile_repository_test.go
+++ b/persistence/mediafile_repository_test.go
@@ -5,12 +5,16 @@ import (
"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 +39,69 @@ 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(10)))
+ })
+
+ 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"))
+ }
})
It("checks existence of mediafiles in the DB", func() {
@@ -92,6 +158,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 +265,300 @@ 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]interface{}{
+ "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]interface{}{
+ "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]interface{}{
+ "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("Search", func() {
+ Context("text search", func() {
+ It("finds media files by title", func() {
+ results, err := mr.Search("Antenna", 0, 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", 0, 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", 0, 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", 0, 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", 0, 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", 0, 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", 0, 10)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+
+ // Clean up
+ _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID}))
+ })
+ })
+ })
})
diff --git a/persistence/persistence.go b/persistence/persistence.go
index 2536b9c35..afc7537e6 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,6 +89,14 @@ func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepos
return NewScrobbleBufferRepository(ctx, s.getDBXBuilder())
}
+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 interface{}) model.ResourceRepository {
switch m.(type) {
case model.User:
@@ -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..559ca3d4c 100644
--- a/persistence/persistence_suite_test.go
+++ b/persistence/persistence_suite_test.go
@@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
+ "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
@@ -33,16 +34,22 @@ 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{}
@@ -62,10 +69,12 @@ 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})
+ 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})
testAlbums = model.Albums{
albumSgtPeppers,
albumAbbeyRoad,
albumRadioactivity,
+ albumMultiDisc,
}
)
@@ -76,13 +85,33 @@ var (
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,
+ RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(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"})
+ testSongs = model.MediaFiles{
songDayInALife,
songComeTogether,
songRadioactivity,
songAntenna,
+ songAntennaWithLyrics,
+ songAntenna2,
+ songDisc2Track11,
+ songDisc1Track01,
+ songDisc2Track01,
+ songDisc1Track02,
}
)
@@ -101,7 +130,8 @@ 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 {
@@ -123,14 +153,13 @@ 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 {
@@ -150,6 +179,15 @@ var _ = BeforeSuite(func() {
}
}
+ // 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)
+ }
+ }
+
mr := NewMediaFileRepository(ctx, conn)
for i := range testSongs {
err := mr.Put(&testSongs[i])
@@ -175,9 +213,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 +230,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 +248,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/playlist_repository.go b/persistence/playlist_repository.go
index a279ef2ee..859e06010 100644
--- a/persistence/playlist_repository.go
+++ b/persistence/playlist_repository.go
@@ -167,7 +167,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
}
@@ -270,7 +270,12 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
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) + "')")
+ " AND annotation.user_id = '" + usr.ID + "')")
+
+ // Only include media files from libraries the user has access to
+ sq = r.applyLibraryFilter(sq, "media_file")
+
+ // Apply the criteria rules
sq = r.addCriteria(sq, rules)
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
_, err = r.executeSQL(insSql)
@@ -386,6 +391,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",
@@ -393,14 +400,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})
diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go
index aac643cc4..5a33456f7 100644
--- a/persistence/playlist_repository_test.go
+++ b/persistence/playlist_repository_test.go
@@ -1,7 +1,6 @@
package persistence
import (
- "context"
"time"
sq "github.com/Masterminds/squirrel"
@@ -12,13 +11,14 @@ import (
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
)
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())
})
@@ -80,13 +80,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))
@@ -259,4 +259,283 @@ var _ = Describe("PlaylistRepository", func() {
})
})
})
+
+ 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 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: "/music/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: uniqueLibPath + "/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/playlist_track_repository.go b/persistence/playlist_track_repository.go
index 80925aa88..666f227e2 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",
@@ -84,17 +85,19 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er
}
func (r *playlistTrackRepository) Read(id string) (interface{}, 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.*",
).
diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go
index fe42dd7fc..74c80ee92 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,21 @@ 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
+ q := r.toModel(&res)
+ return &q, err
}
func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue {
@@ -100,7 +127,6 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue {
q.Items = append(q.Items, model.MediaFile{ID: t})
}
}
- q.Items = r.loadTracks(q.Items)
return q
}
@@ -145,4 +171,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..4a98f148b
--- /dev/null
+++ b/persistence/plugin_repository.go
@@ -0,0 +1,161 @@
+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) 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, enabled, last_error, sha256, created_at, updated_at)
+ VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {: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,
+ 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,
+ "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..ee158a31c
--- /dev/null
+++ b/persistence/plugin_repository_test.go
@@ -0,0 +1,227 @@
+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("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/scrobble_buffer_repository_test.go b/persistence/scrobble_buffer_repository_test.go
index 6962ea7c6..62423ff45 100644
--- a/persistence/scrobble_buffer_repository_test.go
+++ b/persistence/scrobble_buffer_repository_test.go
@@ -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..dda98b763
--- /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]interface{}{
+ "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..d943943e0 100644
--- a/persistence/share_repository.go
+++ b/persistence/share_repository.go
@@ -95,7 +95,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
}
diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go
new file mode 100644
index 000000000..252115175
--- /dev/null
+++ b/persistence/share_repository_test.go
@@ -0,0 +1,133 @@
+package persistence
+
+import (
+ "context"
+ "time"
+
+ "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(context.TODO()), 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(context.Background(), 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]interface{}{
+ "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(context.Background(), 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]interface{}{
+ "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(context.Background(), 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]interface{}{
+ "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())
+ })
+ })
+})
diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go
index daf621ffe..cf95d39a2 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,12 +39,28 @@ 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},
}
@@ -56,8 +73,9 @@ func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...strin
}
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)
@@ -76,7 +94,23 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error {
}
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]interface{}{"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) {
+ userID := loggedUser(r.ctx).ID
values := map[string]interface{}{}
- values["user_id"] = userId(r.ctx)
+ 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..1848bbc8b
--- /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 []interface{}) {
+ 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", []interface{}(nil)),
+ Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []interface{}(nil)),
+ Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []interface{}(nil)),
+ Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []interface{}(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..ce026a3c3 100644
--- a/persistence/sql_base_repository.go
+++ b/persistence/sql_base_repository.go
@@ -49,27 +49,14 @@ 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
-}
-
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
if r.tableName == "" {
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
@@ -86,6 +73,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 +186,45 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti
return sq
}
+func (r *sqlRepository) withTableName(filter filterFunc) filterFunc {
+ return func(field string, value any) Sqlizer {
+ if r.tableName != "" {
+ field = r.tableName + "." + field
+ }
+ return filter(field, value)
+ }
+}
+
+// libraryIdFilter is a filter function to be added to resources that have a library_id column.
+func libraryIdFilter(_ string, value interface{}) 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)
}
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..9164aed9d 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},
}
@@ -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..ff0d06a8b 100644
--- a/persistence/sql_restful.go
+++ b/persistence/sql_restful.go
@@ -1,6 +1,7 @@
package persistence
import (
+ "cmp"
"context"
"fmt"
"reflect"
@@ -105,8 +106,15 @@ 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))
+ cond := cmp.Or(
+ mbidExpr(tableName, v, mbidFields...),
+ fullTextExpr(tableName, v),
+ )
+ return cond
+ }
}
func substringFilter(field string, value any) Sqlizer {
diff --git a/persistence/sql_restful_test.go b/persistence/sql_restful_test.go
index 20cc31a36..fd95fbb31 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"
)
@@ -66,4 +69,167 @@ var _ = Describe("sqlRestful", func() {
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())
+ 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 fullTextExpr
+ expected := squirrel.And{
+ squirrel.Like{"test_table.full_text": "% 550e8400-e29b-41d4-a716-446655440000%"},
+ }
+ Expect(result).To(Equal(expected))
+ })
+ })
+
+ 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 fullTextExpr result is returned directly
+ expected := squirrel.And{
+ squirrel.Like{"test_table.full_text": "% beatles%"},
+ }
+ Expect(result).To(Equal(expected))
+ })
+
+ It("handles multi-word search terms", func() {
+ result := filter("search", "the beatles abbey road")
+
+ // Should return And condition directly
+ andCondition, ok := result.(squirrel.And)
+ Expect(ok).To(BeTrue())
+ Expect(andCondition).To(HaveLen(4))
+
+ // Check that all words are present (order may vary)
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% the%"}))
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% beatles%"}))
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% abbey%"}))
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% road%"}))
+ })
+ })
+
+ Context("when SearchFullString config changes behavior", func() {
+ It("uses different separator with SearchFullString=false", func() {
+ conf.Server.SearchFullString = false
+ result := filter("search", "test query")
+
+ andCondition, ok := result.(squirrel.And)
+ Expect(ok).To(BeTrue())
+ Expect(andCondition).To(HaveLen(2))
+
+ // Check that all words are present with leading space (order may vary)
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% test%"}))
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% query%"}))
+ })
+
+ It("uses no separator with SearchFullString=true", func() {
+ conf.Server.SearchFullString = true
+ result := filter("search", "test query")
+
+ andCondition, ok := result.(squirrel.And)
+ Expect(ok).To(BeTrue())
+ Expect(andCondition).To(HaveLen(2))
+
+ // Check that all words are present without leading space (order may vary)
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%test%"}))
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%query%"}))
+ })
+ })
+
+ 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")
+
+ expected := squirrel.And{
+ squirrel.Like{"test_table.full_text": "% dont%"}, // str.SanitizeStrings removes quotes
+ }
+ Expect(result).To(Equal(expected))
+ })
+
+ 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
+ expected := squirrel.And{
+ squirrel.Like{"test_table.full_text": "% 550e8400-invalid-uuid%"},
+ }
+ Expect(result).To(Equal(expected))
+ })
+
+ It("handles empty mbid fields array", func() {
+ emptyMbidFilter := fullTextFilter(tableName, []string{}...)
+ result := emptyMbidFilter("search", "test")
+
+ // mbidExpr with empty fields returns nil, so cmp.Or falls back to fullTextExpr
+ expected := squirrel.And{
+ squirrel.Like{"test_table.full_text": "% test%"},
+ }
+ Expect(result).To(Equal(expected))
+ })
+
+ It("converts value to lowercase before processing", func() {
+ result := filter("search", "TEST")
+
+ // The function converts to lowercase internally
+ expected := squirrel.And{
+ squirrel.Like{"test_table.full_text": "% test%"},
+ }
+ Expect(result).To(Equal(expected))
+ })
+ })
+ })
+
})
diff --git a/persistence/sql_search.go b/persistence/sql_search.go
index 9ac171263..0d3bfb743 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,32 +15,50 @@ 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 {
+// doSearch performs a full-text search with the specified parameters.
+// The naturalOrder is used to sort results when no full-text filter is applied. It is useful for cases like
+// OpenSubsonic, where an empty search query should return all results in a natural order. Normally the parameter
+// should be `tableName + ".rowid"`, but some repositories (ex: artist) may use a different natural order.
+func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, results any, naturalOrder string, orderBys ...string) error {
q = strings.TrimSpace(q)
q = strings.TrimSuffix(q, "*")
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})
+ // If the filter is empty, we sort by the specified natural order.
+ sq = sq.OrderBy(naturalOrder)
}
+ 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 (r sqlRepository) searchByMBID(sq SelectBuilder, mbid string, mbidFields []string, results any) error {
+ sq = sq.Where(mbidExpr(r.tableName, mbid, mbidFields...))
+ sq = sq.Where(Eq{r.tableName + ".missing": false})
+
+ return r.queryAll(sq, results)
+}
+
+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)
+}
+
func fullTextExpr(tableName string, s string) Sqlizer {
q := str.SanitizeStrings(s)
if q == "" {
diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go
index d7b48f23e..8c3c1e89d 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 interface{}) 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) (interface{}, 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) (interface{}, 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() interface{} {
+ 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..77b91847a
--- /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]interface{}{"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]interface{}{"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]interface{}{"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]interface{}{"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]interface{}{"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]interface{}{"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..c3947a9f7
--- /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]interface{}{"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]interface{}{"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]interface{}{"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]interface{}{"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..125f57541 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)
@@ -72,7 +72,7 @@ func (r *transcodingRepository) NewInstance() interface{} {
}
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
- if !isAdmin(r.ctx) {
+ if !loggedUser(r.ctx).IsAdmin {
return "", rest.ErrPermissionDenied
}
t := entity.(*model.Transcoding)
@@ -84,7 +84,7 @@ func (r *transcodingRepository) Save(entity interface{}) (string, error) {
}
func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error {
- if !isAdmin(r.ctx) {
+ if !loggedUser(r.ctx).IsAdmin {
return rest.ErrPermissionDenied
}
t := entity.(*model.Transcoding)
@@ -97,7 +97,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/user_repository.go b/persistence/user_repository.go
index 073e32963..dc149e8ba 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": r.withTableName(startsWithFilter),
})
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..8abbf76a9 100644
--- a/persistence/user_repository_test.go
+++ b/persistence/user_repository_test.go
@@ -3,7 +3,9 @@ package persistence
import (
"context"
"errors"
+ "slices"
+ "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
@@ -18,7 +20,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 +81,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 +93,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 +120,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 +158,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 +202,11 @@ var _ = Describe("UserRepository", func() {
user.CurrentPassword = "abc123"
user.NewPassword = "new"
err := validatePasswordChange(&user, loggedUser)
- Expect(err).To(BeNil())
+ Expect(err).ToNot(HaveOccurred())
})
})
})
+
Describe("validateUsernameUnique", func() {
var repo *tests.MockedUserRepo
var existingUser *model.User
@@ -235,4 +238,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..c11dd2db0
--- /dev/null
+++ b/plugins/README.md
@@ -0,0 +1,1078 @@
+# Navidrome Plugin System
+
+Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, and other integrations through host services like scheduling, caching, WebSockets, and Subsonic API access.
+
+The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. This means 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)
+ - [Lifecycle](#lifecycle)
+- [Host Services](#host-services)
+ - [HTTP Requests](#http-requests)
+ - [Scheduler](#scheduler)
+ - [Cache](#cache)
+ - [KVStore](#kvstore)
+ - [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 Package Structure
+
+```
+my-plugin.ndp (zip archive)
+├── manifest.json # Required: Plugin metadata
+└── plugin.wasm # Required: Compiled WebAssembly module
+```
+
+### 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",
+ "permissions": {
+ "http": {
+ "reason": "Fetch metadata from external API",
+ "requiredHosts": ["api.example.com", "*.musicbrainz.org"]
+ }
+ }
+}
+```
+
+**Required fields:** `name`, `author`, `version`
+
+#### 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
+{
+ "name": "Threaded Plugin",
+ "author": "Author Name",
+ "version": "1.0.0",
+ "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.
+
+### MetadataAgent
+
+Provides artist and album metadata. Export one or more of these functions:
+
+| 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 |
+
+**Example:**
+
+```go
+type ArtistInput struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ MBID string `json:"mbid,omitempty"`
+}
+
+type BiographyOutput struct {
+ Biography string `json:"biography"`
+}
+
+//go:wasmexport nd_get_artist_biography
+func ndGetArtistBiography() int32 {
+ var input ArtistInput
+ if err := pdk.InputJSON(&input); err != nil {
+ pdk.SetError(err)
+ return 1
+ }
+
+ // Fetch biography from your data source...
+ output := BiographyOutput{Biography: "Artist biography..."}
+ pdk.OutputJSON(output)
+ return 0
+}
+```
+
+To use the plugin as a metadata agent, add it to your config:
+
+```toml
+Agents = "lastfm,spotify,my-plugin"
+```
+
+### Scrobbler
+
+Integrates with external scrobbling services. Export one or more of these functions:
+
+| 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. The `nd_scrobbler_is_authorized` function is called after the server-side user check passes.
+
+**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 error using predefined constants
+return scrobbler.ScrobblerErrorNotAuthorized
+return scrobbler.ScrobblerErrorRetryLater
+return scrobbler.ScrobblerErrorUnrecoverable
+```
+
+### Lifecycle
+
+Optional initialization callback. Export this function to run code when your plugin loads:
+
+| Function | Input | Output | Description |
+|--------------|-------|------------|--------------------------------|
+| `nd_on_init` | `{}` | `{error?}` | Called once after plugin loads |
+
+Useful for initializing connections, scheduling recurring tasks, etc.
+
+---
+
+## Host Services
+
+Host services let your plugin call back into Navidrome for advanced functionality. Each service requires declaring the permission in your manifest.
+
+### HTTP Requests
+
+Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for more details on making requests.
+
+**Manifest permission:**
+
+```json
+{
+ "permissions": {
+ "http": {
+ "reason": "Fetch metadata from external API",
+ "requiredHosts": ["api.example.com", "*.musicbrainz.org"]
+ }
+ }
+}
+```
+
+**Usage:**
+
+```go
+req := pdk.NewHTTPRequest(pdk.MethodGet, "https://api.example.com/data")
+req.SetHeader("Authorization", "Bearer " + apiKey)
+resp := req.Send()
+
+if resp.Status() == 200 {
+ data := resp.Body()
+ // Process response...
+}
+```
+
+### Scheduler
+
+Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_callback` 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 |
+
+**Callback function:**
+
+```go
+type SchedulerCallbackInput struct {
+ ScheduleID string `json:"scheduleId"`
+ Payload string `json:"payload"`
+ IsRecurring bool `json:"isRecurring"`
+}
+
+//go:wasmexport nd_scheduler_callback
+func ndSchedulerCallback() int32 {
+ var input SchedulerCallbackInput
+ pdk.InputJSON(&input)
+
+ // Handle the scheduled task based on payload
+ pdk.Log(pdk.LogInfo, "Task fired: " + input.ScheduleID)
+ return 0
+}
+```
+
+**Scheduling tasks (using generated SDK):**
+
+Add the generated SDK 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 and use:
+
+```go
+import "github.com/navidrome/navidrome/plugins/pdk/go/host"
+
+// 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
+
+Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own isolated namespace.
+
+**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 (with generated SDK):**
+
+Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup):
+
+```go
+import "github.com/navidrome/navidrome/plugins/pdk/go/host"
+
+// Cache a value for 1 hour
+host.CacheSetString("api-response", responseData, 3600)
+
+// Retrieve (check Exists before using Value)
+result, err := host.CacheGetString("api-response")
+if result.Exists {
+ data := result.Value
+}
+```
+
+> **Note:** Cache is in-memory only and cleared on server restart.
+
+### KVStore
+
+Persistent key-value storage that survives server restarts. Each plugin has its own isolated SQLite database.
+
+**Manifest permission:**
+
+```json
+{
+ "permissions": {
+ "kvstore": {
+ "reason": "Store OAuth tokens and plugin state",
+ "maxSize": "1MB"
+ }
+ }
+}
+```
+
+**Permission options:**
+- `maxSize`: Maximum storage size (e.g., `"1MB"`, `"500KB"`). Default: 1MB
+
+**Host functions:**
+
+| Function | Parameters | Description |
+|--------------------------|--------------|-----------------------------------|
+| `kvstore_set` | `key, value` | Store a byte value |
+| `kvstore_get` | `key` | Retrieve a byte value |
+| `kvstore_delete` | `key` | Delete a value |
+| `kvstore_has` | `key` | Check if key exists |
+| `kvstore_list` | `prefix` | List keys matching prefix |
+| `kvstore_getstorageused` | - | Get current storage usage (bytes) |
+
+**Key constraints:**
+- Maximum key length: 256 bytes
+- Keys must be valid UTF-8 strings
+
+**Usage (with generated SDK):**
+
+Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup):
+
+```go
+import "github.com/navidrome/navidrome/plugins/pdk/go/host"
+
+// Store a value (as raw bytes)
+token := []byte(`{"access_token": "xyz", "refresh_token": "abc"}`)
+_, err := host.KVStoreSet("oauth:spotify", token)
+
+// Retrieve a value
+result, err := host.KVStoreGet("oauth:spotify")
+if result.Exists {
+ var tokenData map[string]string
+ json.Unmarshal(result.Value, &tokenData)
+}
+
+// List all keys with prefix
+keysResult, err := host.KVStoreList("user:")
+for _, key := range keysResult.Keys {
+ // Process each key
+}
+
+// Check storage usage
+usageResult, err := host.KVStoreGetStorageUsed()
+fmt.Printf("Using %d bytes\n", usageResult.Bytes)
+
+// Delete a value
+host.KVStoreDelete("oauth:spotify")
+```
+
+> **Note:** Unlike Cache, KVStore data persists across server restarts. Storage is located at `${DataFolder}/plugins/${pluginID}/kvstore.db`.
+
+### WebSocket
+
+Establish persistent WebSocket connections to external services.
+
+**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_close` | `connectionId, code?, reason?` | Close connection |
+
+**Callback functions (export these to receive events):**
+
+| 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 |
+
+### 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"
+
+// Read a file from library 1
+content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3")
+
+// List directory contents
+entries, err := os.ReadDir("/libraries/1/Artist")
+```
+
+> **Security:** Filesystem access is read-only and restricted to configured library paths only. Plugins cannot access other parts of the host filesystem.
+
+**Usage (with generated SDK):**
+
+Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup). The `Library` struct is provided by the SDK:
+
+```go
+import "github.com/navidrome/navidrome/plugins/pdk/go/host"
+
+// Get a specific library
+resp, err := host.LibraryGetLibrary(1)
+if err != nil {
+ // Handle error
+}
+library := resp.Result
+
+// Get all libraries
+resp, err := host.LibraryGetAllLibraries()
+for _, lib := range resp.Result {
+ // lib is of type host.Library
+ 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 |
+
+### 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. User access is controlled through the plugin's database configuration, not the manifest. Configure which users can use the plugin through the Navidrome UI or API.
+
+**Host function:**
+
+| Function | Parameters | Returns |
+|--------------------|------------|---------------|
+| `subsonicapi_call` | `uri` | JSON response |
+
+**Usage:**
+
+```go
+// The URI must include the 'u' parameter with the username
+response, err := SubsonicAPICall("getAlbumList2?type=random&size=10&u=username")
+```
+
+### Config
+
+Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys—useful for discovering dynamic configuration (e.g., user-to-token mappings).
+
+> **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 (with generated SDK):**
+
+```go
+import "github.com/navidrome/navidrome/plugins/pdk/go/host"
+
+// Get a string configuration value
+value, exists := host.ConfigGet("api_key")
+if exists {
+ // Use the value
+}
+
+// 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:")
+for _, key := range keys {
+ // key might be "user:john", "user:jane", etc.
+}
+
+// List all configuration keys
+allKeys := host.ConfigKeys("")
+```
+
+### Users
+
+Access user information for the users that the plugin has been granted access to. This is useful for plugins that need to associate data with specific users or display user information.
+
+**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. This can be done in two ways:
+
+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 |
+
+**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 (with generated SDK):**
+
+```go
+import "github.com/navidrome/navidrome/plugins/pdk/go/host"
+
+// Get all users the plugin has access to
+users, err := host.UsersGetUsers()
+if err != nil {
+ pdk.Log(pdk.LogError, "Failed to get users: " + err.Error())
+ return
+}
+
+for _, user := range users {
+ pdk.Log(pdk.LogInfo, "User: " + user.UserName + " (" + user.Name + ")")
+ if user.IsAdmin {
+ pdk.Log(pdk.LogInfo, " - Administrator")
+ }
+}
+```
+
+**Rust example:**
+
+```rust
+use nd_pdk_host::users::get_users;
+
+let users = get_users()?;
+for user in users {
+ println!("User: {} ({})", user.user_name, user.name);
+}
+```
+
+**Python example:**
+
+```python
+from host.nd_host_users import users_get_users
+
+users = users_get_users()
+for user in users:
+ print(f"User: {user['userName']} ({user['name']})")
+```
+
+---
+
+## 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
+}
+```
+
+---
+
+## Building Plugins
+
+### Supported Languages
+
+Plugins can be written in any language that Extism supports. Each language has its own PDK (Plugin Development Kit) that provides the APIs for I/O, logging, configuration, and HTTP requests. See the [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for details.
+
+We recommend:
+
+- **Go** – Best experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk)
+- **Rust** – Excellent performance with the [Rust PDK](https://github.com/extism/rust-pdk)
+- **Python** – Experimental support via [extism-py](https://github.com/extism/python-pdk)
+- **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 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 |
+| `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 (HTTP, cache, etc.) |
+
+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
+
+The Rust PDK provides generated type-safe wrappers for both capabilities and host services:
+
+```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 a value for 1 hour
+cache::set_string("my_key", "my_value", 3600)?;
+
+// Schedule a recurring task
+scheduler::schedule_recurring("@every 5m", "payload", "task_id")?;
+
+// Access library metadata
+let libs = library::get_all_libraries()?;
+```
+
+See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation and examples.
+
+### 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
+```
+
+### 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.
+
+### Using Host Service SDKs
+
+Generated SDKs for calling host services are in `plugins/pdk/go/`, `plugins/pdk/python/` and `plugins/pdk/rust`.
+
+**For Go plugins:** Import the SDK as a Go module:
+
+```go
+import "github.com/navidrome/navidrome/plugins/pdk/go/host"
+```
+
+Add 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
+```
+
+See [pdk/go/README.md](pdk/go/README.md) for detailed documentation.
+
+**For Python plugins:** Copy functions from `nd_host_*.py` into your `__init__.py` (see comments in those files for extism-py limitations).
+
+**Recommendations:**
+
+- **Go:** Best overall experience with excellent stdlib support and familiar syntax for most developers. Recommended if you're already in the Go ecosystem.
+- **Rust:** Best for performance-critical plugins or when leveraging Rust's ecosystem. Produces smallest binaries with excellent type safety.
+- **Python:** Best for rapid prototyping or simple plugins. Note that extism-py has limitations compared to compiled languages.
+
+---
+
+## 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 |
+| [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](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](examples/discord-rich-presence/) | Go | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration |
+| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) |
+
+---
+
+
+## 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** – Plugins can only access library directories when explicitly granted the `library.filesystem` permission, and access is read-only
+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. The `users` permission is required for these features.
+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 the `AutoReload` setting 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
diff --git a/plugins/capabilities.go b/plugins/capabilities.go
new file mode 100644
index 000000000..d52b27b07
--- /dev/null
+++ b/plugins/capabilities.go
@@ -0,0 +1,47 @@
+package plugins
+
+// 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 {
+ for _, fn := range functions {
+ if plugin.FunctionExists(fn) {
+ capabilities = append(capabilities, cap)
+ break // 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 {
+ for _, c := range capabilities {
+ if c == cap {
+ return true
+ }
+ }
+ return false
+}
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/metadata_agent.go b/plugins/capabilities/metadata_agent.go
new file mode 100644
index 000000000..fbe89a2be
--- /dev/null
+++ b/plugins/capabilities/metadata_agent.go
@@ -0,0 +1,167 @@
+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)
+}
+
+// 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 name and optional MBID.
+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"`
+}
+
+// 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"`
+}
diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml
new file mode 100644
index 000000000..ebc4a2ba0
--- /dev/null
+++ b/plugins/capabilities/metadata_agent.yaml
@@ -0,0 +1,275 @@
+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
+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
+ SongRef:
+ description: SongRef is a reference to a song with name and optional MBID.
+ 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.
+ 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..300652cb3
--- /dev/null
+++ b/plugins/capabilities/scrobbler.go
@@ -0,0 +1,106 @@
+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 three functions: IsAuthorized, NowPlaying, and Scrobble.
+//
+//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
+}
+
+// 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 for scrobbling.
+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"`
+}
+
+// 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"`
+}
+
+// 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..d8f47c951
--- /dev/null
+++ b/plugins/capabilities/scrobbler.yaml
@@ -0,0 +1,141 @@
+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
+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
+ 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 for scrobbling.
+ 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.
+ required:
+ - id
+ - title
+ - album
+ - artist
+ - albumArtist
+ - artists
+ - albumArtists
+ - duration
+ - trackNumber
+ - discNumber
diff --git a/plugins/capabilities/websocket_callback.go b/plugins/capabilities/websocket_callback.go
new file mode 100644
index 000000000..07db029f0
--- /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 string `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..401c77bb4
--- /dev/null
+++ b/plugins/capabilities/websocket_callback.yaml
@@ -0,0 +1,79 @@
+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
+ 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..26df6fc17
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/generator.go
@@ -0,0 +1,859 @@
+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)
+ }
+
+ 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"
+ 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)
+ }
+
+ 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..0fcd0da98
--- /dev/null
+++ b/plugins/cmd/ndpgen/internal/generator_test.go
@@ -0,0 +1,1527 @@
+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)`))
+ })
+ })
+
+ 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("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