diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 4fc7a5b73..6cf7a5e4e 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -9,12 +9,19 @@ 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
# [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 451ffb2bd..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, Deezer)
-
-## 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
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 9ee7546fd..3352cfa4b 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -14,7 +14,7 @@ concurrency:
cancel-in-progress: true
env:
- CROSS_TAGLIB_VERSION: "2.1.0-1"
+ CROSS_TAGLIB_VERSION: "2.1.1-1"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
jobs:
@@ -25,7 +25,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 +63,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,7 +71,7 @@ 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
@@ -93,7 +93,7 @@ jobs:
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 +106,7 @@ 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
js:
name: Test JS code
@@ -114,10 +114,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 +145,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 +157,8 @@ jobs:
exit 1
fi
done
+ - run: ./.github/workflows/validate-translations.sh -v
+
check-push-enabled:
name: Check Docker configuration
@@ -189,7 +191,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 +217,7 @@ jobs:
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
@@ -246,7 +248,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
@@ -262,10 +264,10 @@ jobs:
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@v6
with:
path: /tmp/digests
pattern: digests-*
@@ -316,9 +318,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@v6
with:
path: ./binaries
pattern: navidrome-windows*
@@ -337,7 +339,7 @@ jobs:
du -h binaries/msi/*.msi
- name: Upload MSI files
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
@@ -350,12 +352,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@v6
with:
path: ./binaries
pattern: navidrome-*
@@ -381,7 +383,7 @@ jobs:
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
with:
name: packages
path: dist/navidrome_0*
@@ -404,13 +406,13 @@ jobs:
item: ${{ fromJson(needs.release.outputs.package_list) }}
steps:
- name: Download all-packages artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v6
with:
name: packages
path: ./dist
- name: Upload all-packages artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}
diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml
index 70a9de3d8..cc120cb8d 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:
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/.nvmrc b/.nvmrc
index 9a2a0e219..54c65116f 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v20
+v24
diff --git a/Dockerfile b/Dockerfile
index 2606d2153..6568ce9d2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcross
########################################################################################################################
-### Build xx (orignal image: tonistiigi/xx)
+### Build xx (original image: tonistiigi/xx)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS xx-build
# v1.5.0
@@ -28,10 +28,12 @@ COPY --from=xx-build /out/ /usr/bin/
### Get TagLib
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS taglib-build
ARG TARGETPLATFORM
-ARG CROSS_TAGLIB_VERSION=2.1.0-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
@@ -153,6 +184,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/taglib/end_to_end_test.go b/adapters/taglib/end_to_end_test.go
index 0b5126542..e4d94bb24 100644
--- a/adapters/taglib/end_to_end_test.go
+++ b/adapters/taglib/end_to_end_test.go
@@ -79,22 +79,29 @@ 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) {
- path := "tests/fixtures/" + file
- 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/" + file)
Expect(mf.RGTrackGain).To(Equal(trackGain))
Expect(mf.RGTrackPeak).To(Equal(trackPeak))
@@ -106,18 +113,82 @@ var _ = Describe("Extractor", func() {
)
})
+ 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")
+
+ // Why is the order inconsistent between runs? Nobody knows
+ Expect(lyrics).To(Or(
+ Equal(model.LyricList{engSylt, engUslt, unsSylt, unsUslt}),
+ Equal(model.LyricList{unsSylt, unsUslt, engSylt, engUslt}),
+ ))
+ })
+
+ 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
@@ -168,11 +239,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 62a949d85..d32adf4ed 100644
--- a/adapters/taglib/taglib.go
+++ b/adapters/taglib/taglib.go
@@ -43,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) {
@@ -107,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".
diff --git a/adapters/taglib/taglib_test.go b/adapters/taglib/taglib_test.go
index 37b012763..f24c0e839 100644
--- a/adapters/taglib/taglib_test.go
+++ b/adapters/taglib/taglib_test.go
@@ -179,7 +179,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/root.go b/cmd/root.go
index df39f50a6..5e91ecd5f 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -110,7 +110,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 {
@@ -346,7 +346,7 @@ func startPluginManager(ctx context.Context) func() error {
// 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")`)
@@ -374,6 +374,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")
@@ -397,6 +398,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..e587b8931 100644
--- a/cmd/scan.go
+++ b/cmd/scan.go
@@ -8,6 +8,7 @@ import (
"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 +18,13 @@ import (
var (
fullScan bool
subprocess bool
+ targets []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\")")
rootCmd.AddCommand(scanCmd)
}
@@ -68,7 +71,18 @@ func runScanner(ctx context.Context) {
ds := persistence.New(sqlDB)
pls := core.NewPlaylists(ds)
- progress, err := scanner.CallScan(ctx, ds, pls, fullScan)
+ // Parse targets if provided
+ var scanTargets []model.ScanTarget
+ if len(targets) > 0 {
+ var err error
+ 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)
}
diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index 59cf91e89..d7b6a3ad2 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -47,18 +47,33 @@ func CreateServer() *server.Server {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
- insights := metrics.GetInstance(dataStore)
+ metricsMetrics := metrics.GetPrometheusInstance(dataStore)
+ manager := plugins.GetManager(dataStore, metricsMetrics)
+ insights := metrics.GetInstance(dataStore, manager)
serverServer := server.New(dataStore, broker, insights)
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)
+ metricsMetrics := metrics.GetPrometheusInstance(dataStore)
+ manager := plugins.GetManager(dataStore, metricsMetrics)
+ insights := metrics.GetInstance(dataStore, manager)
+ fileCache := artwork.GetImageCache()
+ fFmpeg := ffmpeg.New()
+ 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()
+ modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
+ watcher := scanner.GetWatcher(dataStore, modelScanner)
+ library := core.NewLibrary(dataStore, modelScanner, watcher, broker)
+ maintenance := core.NewMaintenance(dataStore)
+ router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance)
return router
}
@@ -80,10 +95,10 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore)
- scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
+ 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, metricsMetrics)
+ router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
return router
}
@@ -122,7 +137,9 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
func CreateInsights() metrics.Insights {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
- insights := metrics.GetInstance(dataStore)
+ metricsMetrics := metrics.GetPrometheusInstance(dataStore)
+ manager := plugins.GetManager(dataStore, metricsMetrics)
+ insights := metrics.GetInstance(dataStore, manager)
return insights
}
@@ -133,7 +150,7 @@ func CreatePrometheus() metrics.Metrics {
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()
@@ -146,8 +163,8 @@ func CreateScanner(ctx context.Context) scanner.Scanner {
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
playlists := core.NewPlaylists(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 {
@@ -163,8 +180,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
playlists := core.NewPlaylists(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
}
@@ -175,7 +192,7 @@ func GetPlaybackServer() playback.PlaybackServer {
return playbackServer
}
-func getPluginManager() *plugins.Manager {
+func getPluginManager() plugins.Manager {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
@@ -185,9 +202,9 @@ func getPluginManager() *plugins.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, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)))
+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, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
-func GetPluginManager(ctx context.Context) *plugins.Manager {
+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 9530e9bcf..595d406b9 100644
--- a/cmd/wire_injectors.go
+++ b/cmd/wire_injectors.go
@@ -38,12 +38,14 @@ var allProviders = wire.NewSet(
listenbrainz.NewRouter,
events.GetBroker,
scanner.New,
- scanner.NewWatcher,
+ scanner.GetWatcher,
plugins.GetManager,
metrics.GetPrometheusInstance,
db.Db,
- wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
- wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
+ wire.Bind(new(agents.PluginLoader), new(plugins.Manager)),
+ wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)),
+ wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)),
+ wire.Bind(new(core.Watcher), new(scanner.Watcher)),
)
func CreateDataStore() model.DataStore {
@@ -58,7 +60,7 @@ func CreateServer() *server.Server {
))
}
-func CreateNativeAPIRouter() *nativeapi.Router {
+func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
panic(wire.Build(
allProviders,
))
@@ -100,7 +102,7 @@ func CreatePrometheus() metrics.Metrics {
))
}
-func CreateScanner(ctx context.Context) scanner.Scanner {
+func CreateScanner(ctx context.Context) model.Scanner {
panic(wire.Build(
allProviders,
))
@@ -118,13 +120,13 @@ func GetPlaybackServer() playback.PlaybackServer {
))
}
-func getPluginManager() *plugins.Manager {
+func getPluginManager() plugins.Manager {
panic(wire.Build(
allProviders,
))
}
-func GetPluginManager(ctx context.Context) *plugins.Manager {
+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 5e95bfbbe..9d7b1a3d0 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -41,6 +41,7 @@ type configOptions struct {
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
+ EnableTranscodingCancellation bool
EnableDownloads bool
EnableExternalServices bool
EnableInsightsCollector bool
@@ -86,8 +87,7 @@ type configOptions struct {
AuthRequestLimit int
AuthWindowLength time.Duration
PasswordEncryptionKey string
- ReverseProxyUserHeader string
- ReverseProxyWhitelist string
+ ExtAuth extAuthOptions
Plugins pluginsOptions
PluginConfig map[string]map[string]string
HTTPSecurityHeaders secureOptions `json:",omitzero"`
@@ -125,10 +125,13 @@ type configOptions struct {
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
DevScannerThreads uint
+ DevSelectiveWatcher bool
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
+ DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
+ DevOptimizeDB bool
DevPreserveUnicodeInExternalCalls bool
}
@@ -175,7 +178,8 @@ type spotifyOptions struct {
}
type deezerOptions struct {
- Enabled bool
+ Enabled bool
+ Language string
}
type listenBrainzOptions struct {
@@ -226,6 +230,11 @@ type pluginsOptions struct {
CacheSize string
}
+type extAuthOptions struct {
+ TrustedSources string
+ UserHeader string
+}
+
var (
Server = &configOptions{}
hooks []func()
@@ -244,6 +253,10 @@ 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")
+
err := viper.Unmarshal(&Server)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
@@ -265,13 +278,15 @@ func Load(noConfigDump bool) {
os.Exit(1)
}
- 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)
+ 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()
@@ -345,6 +360,7 @@ func Load(noConfigDump bool) {
logDeprecatedOptions("Scanner.GenreSeparators")
logDeprecatedOptions("Scanner.GroupAlbumReleases")
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
+ logDeprecatedOptions("ReverseProxyWhitelist", "ReverseProxyUserHeader")
// Call init hooks
for _, hook := range hooks {
@@ -364,6 +380,14 @@ func logDeprecatedOptions(options ...string) {
}
}
+// 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
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
// section into the root level.
@@ -423,7 +447,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
@@ -487,6 +511,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)
@@ -531,8 +556,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", "")
@@ -563,6 +588,7 @@ func setViperDefaults() {
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")
@@ -598,10 +624,13 @@ 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)
}
@@ -609,9 +638,14 @@ 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)
@@ -625,10 +659,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/core/agents/agents.go b/core/agents/agents.go
index efa9f383d..cb10d2c4c 100644
--- a/core/agents/agents.go
+++ b/core/agents/agents.go
@@ -4,7 +4,6 @@ import (
"context"
"slices"
"strings"
- "sync"
"time"
"github.com/navidrome/navidrome/conf"
@@ -18,59 +17,14 @@ import (
// PluginLoader defines an interface for loading plugins
type PluginLoader interface {
// PluginNames returns the names of all plugins that implement a particular service
- PluginNames(serviceName string) []string
+ PluginNames(capability string) []string
// LoadMediaAgent loads and returns a media agent plugin
LoadMediaAgent(name string) (Interface, bool)
}
-type cachedAgent struct {
- agent Interface
- expiration time.Time
-}
-
-// Encapsulates agent caching logic
-// agentCache is a simple TTL cache for agents
-// Not exported, only used by Agents
-
-type agentCache struct {
- mu sync.Mutex
- items map[string]cachedAgent
- ttl time.Duration
-}
-
-// TTL for cached agents
-const agentCacheTTL = 5 * time.Minute
-
-func newAgentCache(ttl time.Duration) *agentCache {
- return &agentCache{
- items: make(map[string]cachedAgent),
- ttl: ttl,
- }
-}
-
-func (c *agentCache) Get(name string) Interface {
- c.mu.Lock()
- defer c.mu.Unlock()
- cached, ok := c.items[name]
- if ok && cached.expiration.After(time.Now()) {
- return cached.agent
- }
- return nil
-}
-
-func (c *agentCache) Set(name string, agent Interface) {
- c.mu.Lock()
- defer c.mu.Unlock()
- c.items[name] = cachedAgent{
- agent: agent,
- expiration: time.Now().Add(c.ttl),
- }
-}
-
type Agents struct {
ds model.DataStore
pluginLoader PluginLoader
- cache *agentCache
}
// GetAgents returns the singleton instance of Agents
@@ -85,18 +39,24 @@ func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
return &Agents{
ds: ds,
pluginLoader: pluginLoader,
- cache: newAgentCache(agentCacheTTL),
}
}
-// getEnabledAgentNames returns the current list of enabled agent names, including:
+// 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
-func (a *Agents) getEnabledAgentNames() []string {
+// 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 []string{LocalAgentName}
+ return []enabledAgent{{name: LocalAgentName, isPlugin: false}}
}
// Get all available plugin names
@@ -108,19 +68,13 @@ func (a *Agents) getEnabledAgentNames() []string {
configuredAgents := strings.Split(conf.Server.Agents, ",")
// Always add LocalAgentName if not already included
- hasLocalAgent := false
- for _, name := range configuredAgents {
- if name == LocalAgentName {
- hasLocalAgent = true
- break
- }
- }
+ hasLocalAgent := slices.Contains(configuredAgents, LocalAgentName)
if !hasLocalAgent {
configuredAgents = append(configuredAgents, LocalAgentName)
}
// Filter to only include valid agents (built-in or plugins)
- var validNames []string
+ var validAgents []enabledAgent
for _, name := range configuredAgents {
// Check if it's a built-in agent
isBuiltIn := Map[name] != nil
@@ -128,39 +82,35 @@ func (a *Agents) getEnabledAgentNames() []string {
// Check if it's a plugin
isPlugin := slices.Contains(availablePlugins, name)
- if isBuiltIn || isPlugin {
- validNames = append(validNames, name)
+ if isBuiltIn {
+ validAgents = append(validAgents, enabledAgent{name: name, isPlugin: false})
+ } else if isPlugin {
+ validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
} else {
- log.Warn("Unknown agent ignored", "name", name)
+ log.Debug("Unknown agent ignored", "name", name)
}
}
- return validNames
+ return validAgents
}
-func (a *Agents) getAgent(name string) Interface {
- // Check cache first
- agent := a.cache.Get(name)
- if agent != nil {
- return agent
- }
-
- // Try to get built-in agent
- constructor, ok := Map[name]
- if ok {
- agent := constructor(a.ds)
- if agent != nil {
- a.cache.Set(name, agent)
- return agent
+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
+ }
}
- log.Debug("Built-in agent not available. Missing configuration?", "name", name)
- }
-
- // Try to load WASM plugin agent (if plugin loader is available)
- if a.pluginLoader != nil {
- agent, ok := a.pluginLoader.LoadMediaAgent(name)
- if ok && agent != nil {
- a.cache.Set(name, agent)
- 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)
}
}
@@ -179,8 +129,8 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
return "", nil
}
start := time.Now()
- for _, agentName := range a.getEnabledAgentNames() {
- ag := a.getAgent(agentName)
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
@@ -208,8 +158,8 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
return "", nil
}
start := time.Now()
- for _, agentName := range a.getEnabledAgentNames() {
- ag := a.getAgent(agentName)
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
@@ -237,8 +187,8 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
return "", nil
}
start := time.Now()
- for _, agentName := range a.getEnabledAgentNames() {
- ag := a.getAgent(agentName)
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
@@ -271,8 +221,8 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier)
start := time.Now()
- for _, agentName := range a.getEnabledAgentNames() {
- ag := a.getAgent(agentName)
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
@@ -304,8 +254,8 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
return nil, nil
}
start := time.Now()
- for _, agentName := range a.getEnabledAgentNames() {
- ag := a.getAgent(agentName)
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
@@ -338,8 +288,8 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
start := time.Now()
- for _, agentName := range a.getEnabledAgentNames() {
- ag := a.getAgent(agentName)
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
@@ -364,8 +314,8 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
return nil, ErrNotFound
}
start := time.Now()
- for _, agentName := range a.getEnabledAgentNames() {
- ag := a.getAgent(agentName)
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
@@ -391,8 +341,8 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string)
return nil, ErrNotFound
}
start := time.Now()
- for _, agentName := range a.getEnabledAgentNames() {
- ag := a.getAgent(agentName)
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
diff --git a/core/agents/agents_plugin_test.go b/core/agents/agents_plugin_test.go
index 575fcbebe..b2791c00e 100644
--- a/core/agents/agents_plugin_test.go
+++ b/core/agents/agents_plugin_test.go
@@ -5,6 +5,7 @@ import (
"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"
)
@@ -73,8 +74,10 @@ var _ = Describe("Agents with Plugin Loading", func() {
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent", "another_plugin")
// Should only include the local agent
- agentNames := agents.getEnabledAgentNames()
- Expect(agentNames).To(HaveExactElements(LocalAgentName))
+ 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() {
@@ -85,9 +88,10 @@ var _ = Describe("Agents with Plugin Loading", func() {
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
// Should only include the local agent
- agentNames := agents.getEnabledAgentNames()
- Expect(agentNames).To(HaveExactElements(LocalAgentName))
- Expect(agentNames).NotTo(ContainElement("plugin_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() {
@@ -96,14 +100,24 @@ var _ = Describe("Agents with Plugin Loading", func() {
// With no config, should not include plugin
conf.Server.Agents = ""
- agentNames := agents.getEnabledAgentNames()
- Expect(agentNames).To(HaveExactElements(LocalAgentName))
- Expect(agentNames).NotTo(ContainElement("plugin_agent"))
+ 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"
- agentNames = agents.getEnabledAgentNames()
+ 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() {
@@ -114,9 +128,19 @@ var _ = Describe("Agents with Plugin Loading", func() {
conf.Server.Agents = "plugin_one"
// Verify only the configured one is included
- agentNames := agents.getEnabledAgentNames()
- Expect(agentNames).To(ContainElement("plugin_one"))
+ 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() {
@@ -140,31 +164,6 @@ var _ = Describe("Agents with Plugin Loading", func() {
Expect(mockLoader.pluginCallCount["plugin_agent"]).To(Equal(1))
})
- It("should cache plugin agents", 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",
- }
-
- // Call multiple times
- _, err := agents.GetArtistMBID(ctx, "123", "Artist")
- Expect(err).ToNot(HaveOccurred())
- _, err = agents.GetArtistMBID(ctx, "123", "Artist")
- Expect(err).ToNot(HaveOccurred())
- _, err = agents.GetArtistMBID(ctx, "123", "Artist")
- Expect(err).ToNot(HaveOccurred())
-
- // Should only load once
- 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 {
@@ -188,8 +187,23 @@ var _ = Describe("Agents with Plugin Loading", func() {
}
// Verify that both are in the enabled list
- agentNames := agents.getEnabledAgentNames()
- Expect(agentNames).To(ContainElements("built_in", "plugin_agent"))
+ 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() {
@@ -212,10 +226,56 @@ var _ = Describe("Agents with Plugin Loading", func() {
conf.Server.Agents = "plugin_y,agent_b,plugin_x,agent_a"
// Get the agent names
- agentNames := agents.getEnabledAgentNames()
+ 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 0732d43ef..0b7eec282 100644
--- a/core/agents/agents_test.go
+++ b/core/agents/agents_test.go
@@ -56,8 +56,8 @@ var _ = Describe("Agents", func() {
It("does not register disabled agents", func() {
var ags []string
- for _, name := range ag.getEnabledAgentNames() {
- agent := ag.getAgent(name)
+ for _, enabledAgent := range ag.getEnabledAgentNames() {
+ agent := ag.getAgent(enabledAgent)
if agent != nil {
ags = append(ags, agent.AgentName())
}
diff --git a/core/agents/deezer/client.go b/core/agents/deezer/client.go
index e75526d80..32d93bad6 100644
--- a/core/agents/deezer/client.go
+++ b/core/agents/deezer/client.go
@@ -1,6 +1,7 @@
package deezer
import (
+ bytes "bytes"
"context"
"encoding/json"
"errors"
@@ -9,11 +10,14 @@ import (
"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")
@@ -25,10 +29,15 @@ type httpDoer interface {
type client struct {
httpDoer httpDoer
+ language string
+ jwt jwtToken
}
-func newClient(hc httpDoer) *client {
- return &client{hc}
+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) {
@@ -53,7 +62,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A
return results.Data, nil
}
-func (c *client) makeRequest(req *http.Request, response interface{}) error {
+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 {
@@ -81,3 +90,129 @@ func (c *client) parseError(data []byte) error {
}
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/core/agents/deezer/client_auth.go b/core/agents/deezer/client_auth.go
new file mode 100644
index 000000000..c88c2bcb6
--- /dev/null
+++ b/core/agents/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/core/agents/deezer/client_auth_test.go b/core/agents/deezer/client_auth_test.go
new file mode 100644
index 000000000..b0c2d195d
--- /dev/null
+++ b/core/agents/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/core/agents/deezer/client_test.go b/core/agents/deezer/client_test.go
index 5e47460d4..7e4f7a49f 100644
--- a/core/agents/deezer/client_test.go
+++ b/core/agents/deezer/client_test.go
@@ -2,10 +2,11 @@ package deezer
import (
"bytes"
- "context"
+ "fmt"
"io"
"net/http"
"os"
+ "time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -17,7 +18,7 @@ var _ = Describe("client", func() {
BeforeEach(func() {
httpClient = &fakeHttpClient{}
- client = newClient(httpClient)
+ client = newClient(httpClient, "en")
})
Describe("ArtistImages", func() {
@@ -26,7 +27,7 @@ var _ = Describe("client", func() {
Expect(err).To(BeNil())
httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})
- artists, err := client.searchArtists(context.TODO(), "Michael Jackson", 20)
+ 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"))
@@ -39,10 +40,136 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
})
- _, err := client.searchArtists(context.TODO(), "Michael Jackson", 20)
+ _, 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 {
diff --git a/core/agents/deezer/deezer.go b/core/agents/deezer/deezer.go
index 8cabfbcfb..8f3e505ec 100644
--- a/core/agents/deezer/deezer.go
+++ b/core/agents/deezer/deezer.go
@@ -12,6 +12,7 @@ import (
"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"
@@ -32,7 +33,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface {
Timeout: consts.DefaultHttpClientTimeOut,
}
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
- agent.client = newClient(cachedHttpClient)
+ agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language)
return agent
}
@@ -88,6 +89,56 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
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 {
diff --git a/core/agents/deezer/responses.go b/core/agents/deezer/responses.go
index 112fe28ec..266c44c62 100644
--- a/core/agents/deezer/responses.go
+++ b/core/agents/deezer/responses.go
@@ -29,3 +29,38 @@ type Error struct {
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/core/agents/deezer/responses_test.go b/core/agents/deezer/responses_test.go
index 95a7f43f4..a9de5c5fb 100644
--- a/core/agents/deezer/responses_test.go
+++ b/core/agents/deezer/responses_test.go
@@ -35,4 +35,35 @@ var _ = Describe("Responses", func() {
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/core/agents/lastfm/agent.go b/core/agents/lastfm/agent.go
index d01b496ec..fafa6afec 100644
--- a/core/agents/lastfm/agent.go
+++ b/core/agents/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
}
@@ -190,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)
@@ -205,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)
}
@@ -222,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
diff --git a/core/agents/lastfm/agent_test.go b/core/agents/lastfm/agent_test.go
index 4476d592f..18e7facf2 100644
--- a/core/agents/lastfm/agent_test.go
+++ b/core/agents/lastfm/agent_test.go
@@ -393,4 +393,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/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..4250a373b 100644
--- a/core/artwork/sources.go
+++ b/core/artwork/sources.go
@@ -188,7 +188,7 @@ func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, 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/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..f4f55ec5a
--- /dev/null
+++ b/core/library.go
@@ -0,0 +1,407 @@
+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
+}
+
+// NewLibrary creates a new Library service
+func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker) Library {
+ return &libraryService{
+ ds: ds,
+ scanner: scanner,
+ watcher: watcher,
+ broker: broker,
+ }
+}
+
+// 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,
+ }
+ return wrapper
+}
+
+type libraryRepositoryWrapper struct {
+ rest.Repository
+ model.LibraryRepository
+ ctx context.Context
+ ds model.DataStore
+ scanner model.Scanner
+ watcher Watcher
+ broker events.Broker
+}
+
+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)
+ }
+
+ 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..bf73a62b7
--- /dev/null
+++ b/core/library_test.go
@@ -0,0 +1,958 @@
+package core_test
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "github.com/deluan/rest"
+ _ "github.com/navidrome/navidrome/adapters/taglib" // 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
+
+ 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{}
+ service = core.NewLibrary(ds, scanner, watcherManager, broker)
+ 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))
+ })
+ })
+})
+
+// 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 29284a908..411bc9ac1 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,8 @@ 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/schema"
"github.com/navidrome/navidrome/utils/singleton"
)
@@ -34,12 +37,18 @@ var (
)
type insightsCollector struct {
- ds model.DataStore
- lastRun atomic.Int64
- lastStatus atomic.Bool
+ ds model.DataStore
+ pluginLoader PluginLoader
+ lastRun atomic.Int64
+ lastStatus atomic.Bool
}
-func GetInstance(ds model.DataStore) Insights {
+// PluginLoader defines an interface for loading plugins
+type PluginLoader interface {
+ PluginList() map[string]schema.PluginManifest
+}
+
+func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
return singleton.GetInstance(func() *insightsCollector {
id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
if err != nil {
@@ -51,14 +60,21 @@ func GetInstance(ds model.DataStore) Insights {
}
}
insightsID = id
- return &insightsCollector{ds: ds}
+ return &insightsCollector{ds: ds, pluginLoader: pluginLoader}
})
}
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 +169,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
@@ -180,10 +203,11 @@ var staticData = sync.OnceValue(func() insights.Data {
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
@@ -199,6 +223,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
})
@@ -233,12 +260,29 @@ 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)
}
+
+ // 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)},
@@ -264,3 +308,23 @@ 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 {
+ plugins := make(map[string]insights.PluginInfo)
+ for id, manifest := range c.pluginLoader.PluginList() {
+ plugins[id] = insights.PluginInfo{
+ Name: manifest.Name,
+ Version: manifest.Version,
+ }
+ }
+ return plugins
+}
diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go
index 85c1ad18b..c46eb8743 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,6 +37,7 @@ 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"`
} `json:"library"`
@@ -55,6 +57,7 @@ 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"`
@@ -69,7 +72,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..f972140c4 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,15 @@ var fsTypeMap = map[int64]string{
0x01021997: "v9fs",
0x786f4256: "vboxsf",
0x4d44: "vfat",
+ 0xca451a4e: "virtiofs",
0x58465342: "xfs",
0x2FC12FC1: "zfs",
+
+ // 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 0b89f85ed..412483156 100644
--- a/core/metrics/prometheus.go
+++ b/core/metrics/prometheus.go
@@ -20,7 +20,7 @@ import (
type Metrics interface {
WriteInitialMetrics(ctx context.Context)
WriteAfterScanMetrics(ctx context.Context, success bool)
- RecordRequest(ctx context.Context, endpoint, method, client string, status int, elapsed int64)
+ 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
}
@@ -56,7 +56,7 @@ 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 int, elapsed int64) {
+func (m *metrics) RecordRequest(_ context.Context, endpoint, method, client string, status int32, elapsed int64) {
httpLabel := prometheus.Labels{
"endpoint": endpoint,
"method": method,
@@ -233,7 +233,7 @@ func (n noopMetrics) WriteInitialMetrics(context.Context) {}
func (n noopMetrics) WriteAfterScanMetrics(context.Context, bool) {}
-func (n noopMetrics) RecordRequest(context.Context, string, string, string, int, int64) {}
+func (n noopMetrics) RecordRequest(context.Context, string, string, string, int32, int64) {}
func (n noopMetrics) RecordPluginRequest(context.Context, string, string, bool, int64) {}
diff --git a/core/mock_library_service.go b/core/mock_library_service.go
new file mode 100644
index 000000000..56f2abd4c
--- /dev/null
+++ b/core/mock_library_service.go
@@ -0,0 +1,46 @@
+package core
+
+import (
+ "context"
+
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+)
+
+// MockLibraryWrapper provides a simple wrapper around MockLibraryRepo
+// that implements the core.Library interface for testing
+type MockLibraryWrapper struct {
+ *tests.MockLibraryRepo
+}
+
+// MockLibraryRestAdapter adapts MockLibraryRepo to rest.Repository interface
+type MockLibraryRestAdapter struct {
+ *tests.MockLibraryRepo
+}
+
+// NewMockLibraryService creates a new mock library service for testing
+func NewMockLibraryService() Library {
+ repo := &tests.MockLibraryRepo{
+ Data: make(map[int]model.Library),
+ }
+ // Set up default test data
+ repo.SetData(model.Libraries{
+ {ID: 1, Name: "Test Library 1", Path: "/music/library1"},
+ {ID: 2, Name: "Test Library 2", Path: "/music/library2"},
+ })
+ return &MockLibraryWrapper{MockLibraryRepo: repo}
+}
+
+func (m *MockLibraryWrapper) NewRepository(ctx context.Context) rest.Repository {
+ return &MockLibraryRestAdapter{MockLibraryRepo: m.MockLibraryRepo}
+}
+
+// rest.Repository interface implementation
+
+func (a *MockLibraryRestAdapter) Delete(id string) error {
+ return a.DeleteByStringID(id)
+}
+
+var _ Library = (*MockLibraryWrapper)(nil)
+var _ rest.Repository = (*MockLibraryRestAdapter)(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..f98179f88 100644
--- a/core/playlists.go
+++ b/core/playlists.go
@@ -20,7 +20,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 +98,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
}
@@ -203,10 +206,10 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
}
existing := make(map[string]int, len(found))
for idx := range found {
- existing[strings.ToLower(found[idx].Path)] = idx
+ existing[normalizePathForComparison(found[idx].Path)] = idx
}
for _, path := range paths {
- idx, ok := existing[strings.ToLower(path)]
+ idx, ok := existing[normalizePathForComparison(path)]
if ok {
mfs = append(mfs, found[idx])
} else {
@@ -223,6 +226,13 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
return nil
}
+// normalizePathForComparison normalizes a file path to NFC form and converts to lowercase
+// for consistent comparison. This fixes Unicode normalization issues on macOS where
+// Apple Music creates playlists with NFC-encoded paths but the filesystem uses NFD.
+func normalizePathForComparison(path string) string {
+ return strings.ToLower(norm.NFC.String(path))
+}
+
// 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)
@@ -326,7 +336,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)
diff --git a/core/playlists_test.go b/core/playlists_test.go
index 3a3c9aafc..fb42f9c9f 100644
--- a/core/playlists_test.go
+++ b/core/playlists_test.go
@@ -15,6 +15,7 @@ import (
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
+ "golang.org/x/text/unicode/norm"
)
var _ = Describe("Playlists", func() {
@@ -73,6 +74,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() {
@@ -186,6 +205,54 @@ 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", func() {
+ // Test case for Apple Music playlists that use NFC encoding vs macOS filesystem NFD
+ // The character "è" can be represented as NFC (single codepoint) or NFD (e + combining accent)
+
+ const pathWithAccents = "artist/Michèle Desrosiers/album/Noël.m4a"
+
+ // Simulate a database entry with NFD encoding (as stored by macOS filesystem)
+ nfdPath := norm.NFD.String(pathWithAccents)
+ repo.data = []string{nfdPath}
+
+ // Simulate an Apple Music M3U playlist entry with NFC encoding
+ nfcPath := norm.NFC.String("/music/" + pathWithAccents)
+ m3u := strings.Join([]string{
+ nfcPath,
+ }, "\n")
+ f := strings.NewReader(m3u)
+
+ pls, err := ps.ImportM3U(ctx, f)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(1), "Should find the track despite Unicode normalization differences")
+ Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
+ })
+ })
+
+ Describe("normalizePathForComparison", func() {
+ It("normalizes Unicode characters to NFC form and converts to lowercase", func() {
+ // Test with NFD (decomposed) input - as would come from macOS filesystem
+ nfdPath := norm.NFD.String("Michèle") // Explicitly convert to NFD form
+ normalized := normalizePathForComparison(nfdPath)
+ Expect(normalized).To(Equal("michèle"))
+
+ // Test with NFC (composed) input - as would come from Apple Music M3U
+ nfcPath := "Michèle" // This might be in NFC form
+ normalizedNfc := normalizePathForComparison(nfcPath)
+
+ // Ensure the two paths are not equal in their original forms
+ Expect(nfdPath).ToNot(Equal(nfcPath))
+
+ // Both should normalize to the same result
+ Expect(normalized).To(Equal(normalizedNfc))
+ })
+
+ It("handles paths with mixed case and Unicode characters", func() {
+ path := "Artist/Noël Coward/Album/Song.mp3"
+ normalized := normalizePathForComparison(path)
+ Expect(normalized).To(Equal("artist/noël coward/album/song.mp3"))
+ })
})
Describe("InPlaylistsPath", func() {
diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go
index c1440046d..9fbca6f71 100644
--- a/core/scrobbler/buffered_scrobbler_test.go
+++ b/core/scrobbler/buffered_scrobbler_test.go
@@ -38,9 +38,9 @@ var _ = Describe("BufferedScrobbler", func() {
It("forwards NowPlaying calls", func() {
track := &model.MediaFile{ID: "123", Title: "Test Track"}
Expect(bs.NowPlaying(ctx, "user1", track, 0)).To(Succeed())
- Expect(scr.NowPlayingCalled).To(BeTrue())
- Expect(scr.UserID).To(Equal("user1"))
- Expect(scr.Track).To(Equal(track))
+ Expect(scr.GetNowPlayingCalled()).To(BeTrue())
+ Expect(scr.GetUserID()).To(Equal("user1"))
+ Expect(scr.GetTrack()).To(Equal(track))
})
It("enqueues scrobbles to buffer", func() {
@@ -51,9 +51,10 @@ var _ = Describe("BufferedScrobbler", func() {
Expect(scr.ScrobbleCalled.Load()).To(BeFalse())
Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed())
- Expect(buffer.Length()).To(Equal(int64(1)))
- // Wait for the scrobble to be sent
+ // 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()
diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go
index 7ce9522b9..d7ab0e6cf 100644
--- a/core/scrobbler/play_tracker.go
+++ b/core/scrobbler/play_tracker.go
@@ -31,6 +31,12 @@ type Submission struct {
Timestamp time.Time
}
+type nowPlayingEntry struct {
+ userId string
+ track *model.MediaFile
+ position int
+}
+
type PlayTracker interface {
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
@@ -40,7 +46,7 @@ type PlayTracker interface {
// PluginLoader is a minimal interface for plugin manager usage in PlayTracker
// (avoids import cycles)
type PluginLoader interface {
- PluginNames(service string) []string
+ PluginNames(capability string) []string
LoadScrobbler(name string) (Scrobbler, bool)
}
@@ -52,6 +58,11 @@ type playTracker struct {
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 {
@@ -71,11 +82,14 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
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) {
- ctx := events.BroadcastToAll(context.Background())
- broker.SendMessage(ctx, &events.NowPlayingCount{Count: m.Len()})
+ broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()})
})
}
@@ -91,9 +105,16 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
p.builtinScrobblers[name] = s
}
log.Debug("List of builtin scrobblers enabled", "names", enabled)
+ go p.nowPlayingWorker()
return p
}
+// 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) {
@@ -138,23 +159,18 @@ func (p *playTracker) refreshPluginScrobblers() {
}
}
+ type stoppableScrobbler interface {
+ Scrobbler
+ Stop()
+ }
+
// Process removals - remove plugins that no longer exist
for name, scrobbler := range p.pluginScrobblers {
if _, exists := current[name]; !exists {
- // Type assertion to access the Stop method
- // We need to ensure this works even with interface objects
- if bs, ok := scrobbler.(*bufferedScrobbler); ok {
- log.Debug("Stopping buffered scrobbler goroutine", "name", name)
- bs.Stop()
- } else {
- // For tests - try to see if this is a mock with a Stop method
- type stoppable interface {
- Stop()
- }
- if s, ok := scrobbler.(stoppable); ok {
- log.Debug("Stopping mock scrobbler", "name", name)
- s.Stop()
- }
+ // 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)
}
@@ -200,16 +216,62 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
ttl := time.Duration(remaining+5) * time.Second
_ = p.playMap.AddWithTTL(playerId, info, ttl)
if conf.Server.EnableNowPlaying {
- ctx = events.BroadcastToAll(ctx)
- p.broker.SendMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
+ p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
}
player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled {
- p.dispatchNowPlaying(ctx, user.ID, mf, position)
+ p.enqueueNowPlaying(playerId, user.ID, mf, position)
}
return nil
}
+func (p *playTracker) enqueueNowPlaying(playerId string, userId string, track *model.MediaFile, position int) {
+ p.npMu.Lock()
+ defer p.npMu.Unlock()
+ p.npQueue[playerId] = nowPlayingEntry{
+ 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(context.Background(), 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)
diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go
index 0447aa142..f300f7796 100644
--- a/core/scrobbler/play_tracker_test.go
+++ b/core/scrobbler/play_tracker_test.go
@@ -24,15 +24,26 @@ import (
// 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
}
@@ -46,7 +57,7 @@ var _ = Describe("PlayTracker", func() {
var album model.Album
var artist1 model.Artist
var artist2 model.Artist
- var fake fakeScrobbler
+ var fake *fakeScrobbler
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
@@ -54,16 +65,16 @@ var _ = Describe("PlayTracker", func() {
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
})
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
- tracker.(*playTracker).builtinScrobblers["fake"] = &fake // Bypass buffering for tests
+ tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests
track = model.MediaFile{
ID: "123",
@@ -86,6 +97,11 @@ 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).builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
@@ -95,10 +111,10 @@ var _ = Describe("PlayTracker", func() {
It("sends track to agent", func() {
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
@@ -106,7 +122,7 @@ var _ = Describe("PlayTracker", func() {
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})
@@ -114,7 +130,7 @@ var _ = Describe("PlayTracker", func() {
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
@@ -122,7 +138,7 @@ var _ = Describe("PlayTracker", func() {
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() {
@@ -130,11 +146,12 @@ var _ = Describe("PlayTracker", func() {
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))
- Expect(fake.Position).To(Equal(pos))
})
It("sends event with count", func() {
@@ -210,7 +227,7 @@ var _ = Describe("PlayTracker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
- Expect(fake.UserID).To(Equal("u-1"))
+ 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"))
@@ -278,45 +295,46 @@ var _ = Describe("PlayTracker", func() {
Describe("Plugin scrobbler logic", func() {
var pluginLoader *mockPluginLoader
- var pluginFake fakeScrobbler
+ var pluginFake *fakeScrobbler
BeforeEach(func() {
- pluginFake = fakeScrobbler{Authorized: true}
+ pluginFake = &fakeScrobbler{Authorized: true}
pluginLoader = &mockPluginLoader{
names: []string{"plugin1"},
- scrobblers: map[string]Scrobbler{"plugin1": &pluginFake},
+ 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
+ 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())
- Expect(pluginFake.NowPlayingCalled).To(BeTrue())
+ 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)
- Expect(pluginFake.NowPlayingCalled).To(BeTrue())
- pluginFake.NowPlayingCalled = false
+ Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
+ pluginFake.nowPlayingCalled.Store(false)
// Remove plugin
- pluginLoader.names = []string{}
+ pluginLoader.SetNames([]string{})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
- Expect(pluginFake.NowPlayingCalled).To(BeFalse())
+ // 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 = false
- pluginFake.NowPlayingCalled = false
+ fake.nowPlayingCalled.Store(false)
+ pluginFake.nowPlayingCalled.Store(false)
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeTrue())
- Expect(pluginFake.NowPlayingCalled).To(BeTrue())
+ Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
+ Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("calls plugin scrobbler for Submit", func() {
@@ -359,7 +377,7 @@ var _ = Describe("PlayTracker", func() {
It("calls Stop on scrobblers when removing them", func() {
// Change the plugin names to simulate a plugin being removed
- mockPlugin.names = []string{}
+ mockPlugin.SetNames([]string{})
// Call refreshPluginScrobblers which should detect the removed plugin
pTracker.refreshPluginScrobblers()
@@ -375,32 +393,51 @@ var _ = Describe("PlayTracker", func() {
type fakeScrobbler struct {
Authorized bool
- NowPlayingCalled bool
+ nowPlayingCalled atomic.Bool
ScrobbleCalled atomic.Bool
- UserID string
- Track *model.MediaFile
- Position int
+ userID 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) 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, position int) error {
- f.NowPlayingCalled = true
+ f.nowPlayingCalled.Store(true)
if f.Error != nil {
return f.Error
}
- f.UserID = userId
- f.Track = track
- f.Position = position
+ f.userID.Store(&userId)
+ f.track.Store(track)
+ f.position.Store(int32(position))
return nil
}
func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
- f.UserID = userId
+ f.userID.Store(&userId)
f.LastScrobble.Store(&s)
f.ScrobbleCalled.Store(true)
if f.Error != nil {
@@ -429,6 +466,12 @@ func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
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()
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/wire_providers.go b/core/wire_providers.go
index 482cfbefe..16335645c 100644
--- a/core/wire_providers.go
+++ b/core/wire_providers.go
@@ -17,6 +17,8 @@ var Set = wire.NewSet(
NewPlayers,
NewShare,
NewPlaylists,
+ NewLibrary,
+ 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/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/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/go.mod b/go.mod
index e8951570e..f680bda51 100644
--- a/go.mod
+++ b/go.mod
@@ -1,15 +1,15 @@
module github.com/navidrome/navidrome
-go 1.24.4
+go 1.25
-// Fork to fix https://github.com/navidrome/navidrome/pull/3254
+// Fork to fix https://github.com/navidrome/navidrome/issues/3254
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
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.1
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
@@ -22,57 +22,59 @@ require (
github.com/djherbis/times v1.6.0
github.com/dustin/go-humanize v1.0.1
github.com/fatih/structs v1.1.0
- github.com/go-chi/chi/v5 v5.2.2
- github.com/go-chi/cors v1.2.1
+ github.com/go-chi/chi/v5 v5.2.3
+ 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.4.0
- github.com/kardianos/service v1.2.2
+ github.com/kardianos/service v1.2.4
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/knqyf263/go-plugin v0.9.0
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.6
+ github.com/maruel/natural v1.2.1
github.com/matoous/go-nanoid/v2 v2.1.0
- github.com/mattn/go-sqlite3 v1.14.28
+ github.com/mattn/go-sqlite3 v1.14.32
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.2
+ github.com/onsi/gomega v1.38.2
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/tetratelabs/wazero v1.9.0
+ github.com/spf13/cobra v1.10.1
+ github.com/spf13/viper v1.21.0
+ github.com/stretchr/testify v1.11.1
+ github.com/tetratelabs/wazero v1.10.1
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.uber.org/goleak v1.3.0
- golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
- golang.org/x/image v0.28.0
- golang.org/x/net v0.41.0
- golang.org/x/sync v0.15.0
- golang.org/x/sys v0.33.0
- golang.org/x/text v0.26.0
- golang.org/x/time v0.12.0
- google.golang.org/protobuf v1.36.6
+ golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
+ golang.org/x/image v0.33.0
+ golang.org/x/net v0.47.0
+ golang.org/x/sync v0.18.0
+ golang.org/x/sys v0.38.0
+ golang.org/x/text v0.31.0
+ golang.org/x/time v0.14.0
+ google.golang.org/protobuf v1.36.10
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
@@ -84,16 +86,16 @@ require (
github.com/fsnotify/fsnotify v1.9.0 // 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.3.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
- github.com/goccy/go-yaml v1.17.1 // indirect
+ github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
- github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a // indirect
+ github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // 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/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/klauspost/cpuid/v2 v2.2.11 // 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
@@ -108,27 +110,28 @@ require (
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/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
- github.com/sagikazarmark/locafero v0.9.0 // indirect
+ github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sanity-io/litter v1.5.8 // indirect
- github.com/segmentio/asm v1.2.0 // indirect
+ github.com/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sosodev/duration v1.3.1 // indirect
- github.com/sourcegraph/conc v0.3.0 // indirect
- github.com/spf13/afero v1.14.0 // indirect
- github.com/spf13/cast v1.9.2 // indirect
- github.com/spf13/pflag v1.0.6 // 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.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
- go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
- golang.org/x/crypto v0.39.0 // indirect
- golang.org/x/mod v0.25.0 // indirect
- golang.org/x/tools v0.34.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/crypto v0.45.0 // indirect
+ golang.org/x/mod v0.30.0 // indirect
+ golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect
+ golang.org/x/tools v0.39.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
)
diff --git a/go.sum b/go.sum
index c6f96bec3..77c0cbb40 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ 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=
@@ -14,8 +16,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
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.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
+github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
@@ -60,10 +62,16 @@ 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.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
-github.com/go-chi/chi/v5 v5.2.2/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.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
+github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
+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=
@@ -71,35 +79,34 @@ github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/
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.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
-github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
-github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
-github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
-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.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/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-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18=
-github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
+github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
+github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
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=
@@ -115,16 +122,18 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
-github.com/klauspost/cpuid/v2 v2.2.11/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/knqyf263/go-plugin v0.9.0 h1:CQs2+lOPIlkZVtcb835ZYDEoyyWJWLbSTWeCs0EwTwI=
github.com/knqyf263/go-plugin v0.9.0/go.mod h1:2z5lCO1/pez6qGo8CvCxSlBFSEat4MEp1DrnA+f7w8Q=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -153,14 +162,18 @@ 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.2.1 h1:G/y4pwtTA07lbQsMefvsmEO0VN0NfqpxprxXDM4R/4o=
+github.com/maruel/natural v1.2.1/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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/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=
@@ -173,10 +186,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
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.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
+github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
+github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
+github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
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=
@@ -188,16 +201,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
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/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.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -212,12 +223,12 @@ 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/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/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
-github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+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=
@@ -229,18 +240,17 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
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/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.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
-github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
-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/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.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
+github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
+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=
@@ -251,49 +261,57 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
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.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/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
-github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
+github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
+github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
+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.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.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+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.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
-golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
-golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
-golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
+golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
-golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
+golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
+golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
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.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
-golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
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=
@@ -302,12 +320,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.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
-golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
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=
@@ -315,13 +332,12 @@ 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.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
-golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.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=
@@ -331,19 +347,19 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.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-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo=
+golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
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=
@@ -357,24 +373,23 @@ 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.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
-golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
-golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
-golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+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.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
-golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+google.golang.org/protobuf v1.36.10/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=
@@ -386,11 +401,11 @@ 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 20119ab46..24f3dff6e 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,8 +80,10 @@ 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))
}
@@ -110,6 +114,8 @@ 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)})
@@ -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
}
@@ -204,14 +218,21 @@ func log(level Level, args ...interface{}) {
}
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
}
@@ -221,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
}
@@ -314,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/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..fbff5f178 100644
--- a/model/annotation.go
+++ b/model/annotation.go
@@ -6,6 +6,7 @@ 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" `
+ 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"`
}
diff --git a/model/artist.go b/model/artist.go
index 7f68f9787..309ee800f 100644
--- a/model/artist.go
+++ b/model/artist.go
@@ -78,7 +78,7 @@ 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)
diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go
index fa92c5aca..54ac59697 100644
--- a/model/criteria/criteria.go
+++ b/model/criteria/criteria.go
@@ -61,7 +61,12 @@ func (c Criteria) OrderBy() 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 {
diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go
index 3792264a5..032ead5c8 100644
--- a/model/criteria/criteria_test.go
+++ b/model/criteria/criteria_test.go
@@ -118,6 +118,16 @@ 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"
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..536a37274 100644
--- a/model/datastore.go
+++ b/model/datastore.go
@@ -43,5 +43,5 @@ type DataStore interface {
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 12e0d711e..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"`
@@ -85,7 +85,7 @@ type FolderRepository interface {
GetByPath(lib Library, path string) (*Folder, error)
GetAll(...QueryOptions) ([]Folder, error)
CountAll(...QueryOptions) (int64, error)
- GetLastUpdates(lib Library) (map[string]FolderUpdateInfo, 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 fda22f19f..bcb2864c8 100644
--- a/model/library.go
+++ b/model/library.go
@@ -2,40 +2,57 @@ 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
-
- TotalSongs int
- TotalAlbums int
- TotalArtists int
- TotalFolders int
- TotalFiles int
- TotalMissingFiles int
- TotalSize int64
+ 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
diff --git a/model/mediafile.go b/model/mediafile.go
index d29a2a509..0ef26d746 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"`
@@ -367,6 +368,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 591b618a3..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"
)
@@ -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,8 +104,7 @@ 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 {
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 aea4238a4..1372d0034 100644
--- a/model/metadata/metadata.go
+++ b/model/metadata/metadata.go
@@ -245,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/persistent_ids.go b/model/metadata/persistent_ids.go
index 0a1451cfb..b45882946 100644
--- a/model/metadata/persistent_ids.go
+++ b/model/metadata/persistent_ids.go
@@ -2,6 +2,7 @@ package metadata
import (
"cmp"
+ "fmt"
"path/filepath"
"strings"
@@ -16,18 +17,20 @@ 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) string {
attr = strings.TrimSpace(strings.ToLower(attr))
switch attr {
case "albumid":
- return getPID(mf, md, conf.Server.PID.Album)
+ return getPID(mf, md, conf.Server.PID.Album, prependLibId)
case "folder":
return filepath.Dir(mf.Path)
case "albumartistid":
@@ -39,14 +42,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)
if v != "" {
hasValue = true
}
@@ -57,32 +60,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 d07b36331..7ae0c91f7 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,7 +57,7 @@ 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\\)"))
})
})
})
@@ -73,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() {
@@ -81,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() {
@@ -94,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() {
@@ -104,14 +104,14 @@ 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)"))
})
})
})
@@ -123,7 +123,7 @@ var _ = Describe("getPID", func() {
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("the spec has spaces", func() {
@@ -133,7 +133,7 @@ var _ = Describe("getPID", func() {
"albumartist": {"Album Artist"},
"album": {"album name"},
}
- Expect(getPID(mf, md, spec)).To(Equal("(Album Artist\\album name)"))
+ Expect(getPID(mf, md, spec, false)).To(Equal("(Album Artist\\album name)"))
})
})
When("the spec has mixed case fields", func() {
@@ -143,7 +143,129 @@ var _ = Describe("getPID", func() {
"albumartist": {"Album Artist"},
"album": {"album name"},
}
- Expect(getPID(mf, md, spec)).To(Equal("(Album Artist\\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/playlist.go b/model/playlist.go
index 40b666ff9..a87019ed5 100644
--- a/model/playlist.go
+++ b/model/playlist.go
@@ -40,6 +40,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 {
@@ -49,6 +64,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
@@ -56,7 +72,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++
@@ -68,6 +84,7 @@ func (pls *Playlist) AddTracks(mediaFileIds []string) {
}
pls.Tracks = append(pls.Tracks, t)
}
+ pls.refreshStats()
}
func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
@@ -82,6 +99,7 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
}
pls.Tracks = append(pls.Tracks, t)
}
+ pls.refreshStats()
}
func (pls Playlist) CoverArtID() ArtworkID {
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/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..aabedc096 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,6 +27,18 @@ 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 {
@@ -35,4 +52,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..dab255784 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,7 +114,7 @@ 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,
@@ -122,6 +124,7 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
"missing": booleanFilter,
"genre_id": tagIDFilter,
"role_total_id": allRolesFilter,
+ "library_id": libraryIdFilter,
}
// Add all album tags as filters
for tag := range model.AlbumLevelTags() {
@@ -183,9 +186,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 +219,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 +296,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 +338,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 +354,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..a062b4398 100644
--- a/persistence/album_repository_test.go
+++ b/persistence/album_repository_test.go
@@ -1,13 +1,12 @@
package persistence
import (
- "context"
"fmt"
"time"
+ "github.com/Masterminds/squirrel"
"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 +15,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 +41,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 +55,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 +65,7 @@ var _ = Describe("AlbumRepository", func() {
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
albumSgtPeppers,
albumRadioactivity,
+ albumMultiDisc,
albumAbbeyRoad,
}))
})
@@ -83,12 +84,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 +107,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)))
},
@@ -283,6 +284,235 @@ 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}))
+ })
+ })
})
func _p(id, name string, sortName ...string) model.Participant {
diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go
index 977f0cb8b..c9e38a1ee 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,23 +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": booleanFilter,
+ "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": "stats->>'maincredit'->>'m'",
- "maincredit_album_count": "stats->>'maincredit'->>'a'",
- "maincredit_size": "stats->>'maincredit'->>'a'",
+ "maincredit_song_count": "sum(stats->>'maincredit'->>'m')",
+ "maincredit_album_count": "sum(stats->>'maincredit'->>'a')",
+ "maincredit_size": "sum(stats->>'maincredit'->>'s')",
})
return r
}
@@ -136,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 {
@@ -212,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 {
@@ -228,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})
@@ -298,6 +369,7 @@ 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.
// 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 allArtists {
@@ -326,27 +398,23 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
}
// 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,
@@ -354,40 +422,43 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
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
+ GROUP BY mfa.artist_id, mf.library_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
+ 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
+ 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
- UNION
- SELECT artist_id, role, album_count, count, size FROM artist_participant_counter
+ 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 (ROLE_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
@@ -428,17 +499,35 @@ func (r *artistRepository) RefreshStats(allArtists bool) (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) {
@@ -456,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..dfaf499ac 100644
--- a/persistence/artist_repository_test.go
+++ b/persistence/artist_repository_test.go
@@ -7,7 +7,6 @@ import (
"github.com/Masterminds/squirrel"
"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 +14,759 @@ 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("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() {
- 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 up headless repo (no user context)
+ headlessRepo = NewArtistRepository(context.Background(), GetDBXBuilder())
- // Set Beatles as composer
- _, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", composerStats).Where(squirrel.Eq{"id": artistBeatles.ID}))
+ // 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())
- // Set Kraftwerk as producer
- _, err = raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", producerStats).Where(squirrel.Eq{"id": artistKraftwerk.ID}))
+ // Create a user with access to only library 1
+ restrictedUser = createUserWithLibraries("search_user", []int{1})
+
+ // Create repository context for the restricted user
+ ctx := request.WithUser(GinkgoT().Context(), restrictedUser)
+ restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder())
+
+ // Ensure both test artists are associated with library 1
+ err = lr.AddArtist(1, artistBeatles.ID)
+ Expect(err).ToNot(HaveOccurred())
+ err = lr.AddArtist(1, artistKraftwerk.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Create the restricted user in the database
+ ur := NewUserRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
+ err = ur.Put(&restrictedUser)
+ Expect(err).ToNot(HaveOccurred())
+ err = ur.SetUserLibraries(restrictedUser.ID, []int{1})
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
- // Clean up 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}))
+ // Clean up library 2
+ lr := NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
+ _ = lr.(*libraryRepository).delete(squirrel.Eq{"id": lib2.ID})
})
- It("returns only artists with the specified role", func() {
- idx, err := repo.GetIndex(false, model.RoleComposer)
+ 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())
- 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))
+
+ // 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}))
+ }
})
- It("returns artists with any of the specified roles", func() {
- idx, err := repo.GetIndex(false, model.RoleComposer, model.RoleProducer)
+ 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/folder_repository.go b/persistence/folder_repository.go
index 02b272134..a586746a0 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,12 +89,52 @@ 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]model.FolderUpdateInfo, error) {
- sq := r.newSelect().Columns("id", "updated_at", "hash").Where(Eq{"library_id": lib.ID, "missing": false})
+func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) {
+ where := And{
+ Eq{"library_id": lib.ID},
+ Eq{"missing": false},
+ }
+
+ // If specific paths are requested, include those folders and all their descendants
+ if len(targetPaths) > 0 {
+ // 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 {
+ if targetPath == "" || targetPath == "." {
+ // Root path - include everything in this library
+ pathConditions = Or{}
+ folderIDs = nil
+ break
+ }
+ // 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)
+ }
+ }
+
+ sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where)
var res []struct {
ID string
UpdatedAt time.Time
@@ -147,7 +191,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},
@@ -155,6 +199,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 9c305e52b..9349f3c4c 100644
--- a/persistence/library_repository.go
+++ b/persistence/library_repository.go
@@ -2,10 +2,13 @@ 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"
@@ -68,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
}
@@ -137,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
}
@@ -150,6 +194,7 @@ func (r *libraryRepository) ScanInProgress() (bool, error) {
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 {
@@ -180,6 +225,9 @@ func (r *libraryRepository) RefreshStats(id int) error {
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
@@ -193,12 +241,34 @@ func (r *libraryRepository) RefreshStats(id int) error {
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)
+
+ return nil
+}
+
func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries, error) {
sq := r.newSelect(ops...).Columns("*")
res := model.Libraries{}
@@ -206,4 +276,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
index 280f254b5..3e3972bdb 100644
--- a/persistence/library_repository_test.go
+++ b/persistence/library_repository_test.go
@@ -22,6 +22,96 @@ var _ = Describe("LibraryRepository", func() {
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())
@@ -32,6 +122,7 @@ var _ = Describe("LibraryRepository", func() {
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())
@@ -40,6 +131,7 @@ var _ = Describe("LibraryRepository", func() {
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)))
@@ -48,5 +140,64 @@ var _ = Describe("LibraryRepository", func() {
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 eee6444c1..8f32accc6 100644
--- a/persistence/mediafile_repository.go
+++ b/persistence/mediafile_repository.go
@@ -9,6 +9,8 @@ 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"
"github.com/navidrome/navidrome/utils/slice"
@@ -74,13 +76,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 +92,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"),
+ "title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"),
"starred": booleanFilter,
"genre_id": tagIDFilter,
"missing": booleanFilter,
"artists_id": artistFilter,
+ "library_id": libraryIdFilter,
}
// Add all album tags as filters
for tag := range model.TagMappings() {
@@ -103,9 +108,17 @@ 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...)
}
@@ -124,10 +137,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) {
@@ -263,7 +277,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 +298,62 @@ 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
+func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
+ sel := r.selectMediaFile().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
+func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
+ sel := r.selectMediaFile().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 9dbb8080f..ab926c00d 100644
--- a/persistence/mediafile_repository_test.go
+++ b/persistence/mediafile_repository_test.go
@@ -5,12 +5,15 @@ import (
"time"
"github.com/Masterminds/squirrel"
+ "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 +38,7 @@ var _ = Describe("MediaRepository", func() {
})
It("counts the number of mediafiles in the DB", func() {
- Expect(mr.CountAll()).To(Equal(int64(6)))
+ Expect(mr.CountAll()).To(Equal(int64(10)))
})
It("returns songs ordered by lyrics with a specific title/artist", func() {
@@ -155,4 +158,256 @@ 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
+ })
+ })
+
+ })
+ })
+
+ 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 ac607f85f..1de0bae61 100644
--- a/persistence/persistence.go
+++ b/persistence/persistence.go
@@ -157,7 +157,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 +167,17 @@ func (s *SQLStore) GC(ctx context.Context) error {
}
}
+ // 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() }),
+ 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 7edfeee1f..f3cb4f3d0 100644
--- a/persistence/persistence_suite_test.go
+++ b/persistence/persistence_suite_test.go
@@ -34,6 +34,7 @@ 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}},
@@ -47,6 +48,8 @@ func mf(mf model.MediaFile) model.MediaFile {
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{}
@@ -66,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,
}
)
@@ -91,13 +96,22 @@ var (
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"})
- testSongs = model.MediaFiles{
+ // 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,
}
)
@@ -138,14 +152,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 {
@@ -165,6 +178,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])
@@ -190,9 +212,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)
@@ -207,7 +229,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
@@ -219,6 +247,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 bdaaeeddb..3fdd19af2 100644
--- a/persistence/playlist_repository.go
+++ b/persistence/playlist_repository.go
@@ -161,7 +161,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
}
@@ -263,7 +263,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)
@@ -379,6 +384,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",
@@ -386,14 +393,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 b799d4912..05a36352f 100644
--- a/persistence/playlist_repository_test.go
+++ b/persistence/playlist_repository_test.go
@@ -1,7 +1,6 @@
package persistence
import (
- "context"
"time"
"github.com/navidrome/navidrome/conf"
@@ -11,13 +10,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())
})
@@ -79,13 +79,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))
@@ -219,4 +219,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/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..108e9be94 100644
--- a/persistence/sql_annotations.go
+++ b/persistence/sql_annotations.go
@@ -15,20 +15,20 @@ import (
const annotationTable = "annotation"
func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder {
- if userId(r.ctx) == invalidUserId {
+ userID := loggedUser(r.ctx).ID
+ if userID == invalidUserId {
return query
}
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(
@@ -42,8 +42,9 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
}
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 +57,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 +78,8 @@ 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()
+ return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
}
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
@@ -86,8 +89,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 +121,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_base_repository.go b/persistence/sql_base_repository.go
index ea22389a2..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.")
@@ -199,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 7ba2a0021..b46e2066b 100644
--- a/persistence/sql_base_repository_test.go
+++ b/persistence/sql_base_repository_test.go
@@ -223,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..d88eca45e 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 {
@@ -53,22 +60,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..7baa8f6a8 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) {
@@ -365,6 +437,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/README.md b/plugins/README.md
index 31f967879..100230cbf 100644
--- a/plugins/README.md
+++ b/plugins/README.md
@@ -196,7 +196,7 @@ See the [cache.proto](host/cache/cache.proto) file for the full API definition.
#### SchedulerService
-The SchedulerService provides a unified interface for scheduling both one-time and recurring tasks. See the [scheduler.proto](host/scheduler/scheduler.proto) file for the full API.
+The SchedulerService provides a unified interface for scheduling both one-time and recurring tasks, as well as accessing current time information. See the [scheduler.proto](host/scheduler/scheduler.proto) file for the full API.
```protobuf
service SchedulerService {
@@ -208,11 +208,50 @@ service SchedulerService {
// Cancel any scheduled job
rpc CancelSchedule(CancelRequest) returns (CancelResponse);
+
+ // Get current time in multiple formats
+ rpc TimeNow(TimeNowRequest) returns (TimeNowResponse);
}
```
+**Key Features:**
+
- **One-time scheduling**: Schedule a callback to be executed once after a specified delay.
- **Recurring scheduling**: Schedule a callback to be executed repeatedly according to a cron expression.
+- **Current time access**: Get the current time in standardized formats for time-based operations.
+
+**TimeNow Function:**
+
+The `TimeNow` function returns the current time in three formats:
+
+```protobuf
+message TimeNowResponse {
+ string rfc3339_nano = 1; // RFC3339 format with nanosecond precision
+ int64 unix_milli = 2; // Unix timestamp in milliseconds
+ string local_time_zone = 3; // Local timezone name (e.g., "UTC", "America/New_York")
+}
+```
+
+This allows plugins to:
+
+- Get high-precision timestamps for logging and event correlation
+- Perform time-based calculations using Unix timestamps
+- Handle timezone-aware operations by knowing the server's local timezone
+
+Example usage:
+
+```go
+// Get current time information
+timeResp, err := scheduler.TimeNow(ctx, &scheduler.TimeNowRequest{})
+if err != nil {
+ return err
+}
+
+// Use the different time formats
+timestamp := timeResp.Rfc3339Nano // "2024-01-15T10:30:45.123456789Z"
+unixMs := timeResp.UnixMilli // 1705312245123
+timezone := timeResp.LocalTimeZone // "UTC"
+```
Plugins using this service must implement the `SchedulerCallback` interface:
@@ -433,7 +472,7 @@ If no permissions are needed, use an empty permissions object: `"permissions": {
The following permission keys correspond to host services:
| Permission | Host Service | Description | Required Fields |
-|---------------|--------------------|----------------------------------------------------|-------------------------------------------------------|
+| ------------- | ------------------ | -------------------------------------------------- | ----------------------------------------------------- |
| `http` | HttpService | Make HTTP requests (GET, POST, PUT, DELETE, etc..) | `reason`, `allowedUrls` |
| `websocket` | WebSocketService | Connect to and communicate via WebSockets | `reason`, `allowedUrls` |
| `cache` | CacheService | Store and retrieve cached data with TTL | `reason` |
diff --git a/plugins/adapter_media_agent.go b/plugins/adapter_media_agent.go
index 43fc0e030..eca891275 100644
--- a/plugins/adapter_media_agent.go
+++ b/plugins/adapter_media_agent.go
@@ -10,14 +10,14 @@ import (
)
// NewWasmMediaAgent creates a new adapter for a MetadataAgent plugin
-func newWasmMediaAgent(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
+func newWasmMediaAgent(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
loader, err := api.NewMetadataAgentPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
if err != nil {
log.Error("Error creating media metadata service plugin", "plugin", pluginID, "path", wasmPath, err)
return nil
}
return &wasmMediaAgent{
- wasmBasePlugin: newWasmBasePlugin[api.MetadataAgent, *api.MetadataAgentPlugin](
+ baseCapability: newBaseCapability[api.MetadataAgent, *api.MetadataAgentPlugin](
wasmPath,
pluginID,
CapabilityMetadataAgent,
@@ -32,7 +32,7 @@ func newWasmMediaAgent(wasmPath, pluginID string, m *Manager, runtime api.Wazero
// wasmMediaAgent adapts a MetadataAgent plugin to implement the agents.Interface
type wasmMediaAgent struct {
- *wasmBasePlugin[api.MetadataAgent, *api.MetadataAgentPlugin]
+ *baseCapability[api.MetadataAgent, *api.MetadataAgentPlugin]
}
func (w *wasmMediaAgent) AgentName() string {
@@ -49,108 +49,108 @@ func (w *wasmMediaAgent) mapError(err error) error {
// Album-related methods
func (w *wasmMediaAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
- return callMethod(ctx, w, "GetAlbumInfo", func(inst api.MetadataAgent) (*agents.AlbumInfo, error) {
- res, err := inst.GetAlbumInfo(ctx, &api.AlbumInfoRequest{Name: name, Artist: artist, Mbid: mbid})
- if err != nil {
- return nil, w.mapError(err)
- }
- if res == nil || res.Info == nil {
- return nil, agents.ErrNotFound
- }
- info := res.Info
- return &agents.AlbumInfo{
- Name: info.Name,
- MBID: info.Mbid,
- Description: info.Description,
- URL: info.Url,
- }, nil
+ res, err := callMethod(ctx, w, "GetAlbumInfo", func(inst api.MetadataAgent) (*api.AlbumInfoResponse, error) {
+ return inst.GetAlbumInfo(ctx, &api.AlbumInfoRequest{Name: name, Artist: artist, Mbid: mbid})
})
+ if err != nil {
+ return nil, w.mapError(err)
+ }
+ if res == nil || res.Info == nil {
+ return nil, agents.ErrNotFound
+ }
+ info := res.Info
+ return &agents.AlbumInfo{
+ Name: info.Name,
+ MBID: info.Mbid,
+ Description: info.Description,
+ URL: info.Url,
+ }, nil
}
func (w *wasmMediaAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
- return callMethod(ctx, w, "GetAlbumImages", func(inst api.MetadataAgent) ([]agents.ExternalImage, error) {
- res, err := inst.GetAlbumImages(ctx, &api.AlbumImagesRequest{Name: name, Artist: artist, Mbid: mbid})
- if err != nil {
- return nil, w.mapError(err)
- }
- return convertExternalImages(res.Images), nil
+ res, err := callMethod(ctx, w, "GetAlbumImages", func(inst api.MetadataAgent) (*api.AlbumImagesResponse, error) {
+ return inst.GetAlbumImages(ctx, &api.AlbumImagesRequest{Name: name, Artist: artist, Mbid: mbid})
})
+ if err != nil {
+ return nil, w.mapError(err)
+ }
+ return convertExternalImages(res.Images), nil
}
// Artist-related methods
func (w *wasmMediaAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
- return callMethod(ctx, w, "GetArtistMBID", func(inst api.MetadataAgent) (string, error) {
- res, err := inst.GetArtistMBID(ctx, &api.ArtistMBIDRequest{Id: id, Name: name})
- if err != nil {
- return "", w.mapError(err)
- }
- return res.GetMbid(), nil
+ res, err := callMethod(ctx, w, "GetArtistMBID", func(inst api.MetadataAgent) (*api.ArtistMBIDResponse, error) {
+ return inst.GetArtistMBID(ctx, &api.ArtistMBIDRequest{Id: id, Name: name})
})
+ if err != nil {
+ return "", w.mapError(err)
+ }
+ return res.GetMbid(), nil
}
func (w *wasmMediaAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
- return callMethod(ctx, w, "GetArtistURL", func(inst api.MetadataAgent) (string, error) {
- res, err := inst.GetArtistURL(ctx, &api.ArtistURLRequest{Id: id, Name: name, Mbid: mbid})
- if err != nil {
- return "", w.mapError(err)
- }
- return res.GetUrl(), nil
+ res, err := callMethod(ctx, w, "GetArtistURL", func(inst api.MetadataAgent) (*api.ArtistURLResponse, error) {
+ return inst.GetArtistURL(ctx, &api.ArtistURLRequest{Id: id, Name: name, Mbid: mbid})
})
+ if err != nil {
+ return "", w.mapError(err)
+ }
+ return res.GetUrl(), nil
}
func (w *wasmMediaAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
- return callMethod(ctx, w, "GetArtistBiography", func(inst api.MetadataAgent) (string, error) {
- res, err := inst.GetArtistBiography(ctx, &api.ArtistBiographyRequest{Id: id, Name: name, Mbid: mbid})
- if err != nil {
- return "", w.mapError(err)
- }
- return res.GetBiography(), nil
+ res, err := callMethod(ctx, w, "GetArtistBiography", func(inst api.MetadataAgent) (*api.ArtistBiographyResponse, error) {
+ return inst.GetArtistBiography(ctx, &api.ArtistBiographyRequest{Id: id, Name: name, Mbid: mbid})
})
+ if err != nil {
+ return "", w.mapError(err)
+ }
+ return res.GetBiography(), nil
}
func (w *wasmMediaAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
- return callMethod(ctx, w, "GetSimilarArtists", func(inst api.MetadataAgent) ([]agents.Artist, error) {
- resp, err := inst.GetSimilarArtists(ctx, &api.ArtistSimilarRequest{Id: id, Name: name, Mbid: mbid, Limit: int32(limit)})
- if err != nil {
- return nil, w.mapError(err)
- }
- artists := make([]agents.Artist, 0, len(resp.GetArtists()))
- for _, a := range resp.GetArtists() {
- artists = append(artists, agents.Artist{
- Name: a.GetName(),
- MBID: a.GetMbid(),
- })
- }
- return artists, nil
+ resp, err := callMethod(ctx, w, "GetSimilarArtists", func(inst api.MetadataAgent) (*api.ArtistSimilarResponse, error) {
+ return inst.GetSimilarArtists(ctx, &api.ArtistSimilarRequest{Id: id, Name: name, Mbid: mbid, Limit: int32(limit)})
})
+ if err != nil {
+ return nil, w.mapError(err)
+ }
+ artists := make([]agents.Artist, 0, len(resp.GetArtists()))
+ for _, a := range resp.GetArtists() {
+ artists = append(artists, agents.Artist{
+ Name: a.GetName(),
+ MBID: a.GetMbid(),
+ })
+ }
+ return artists, nil
}
func (w *wasmMediaAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
- return callMethod(ctx, w, "GetArtistImages", func(inst api.MetadataAgent) ([]agents.ExternalImage, error) {
- res, err := inst.GetArtistImages(ctx, &api.ArtistImageRequest{Id: id, Name: name, Mbid: mbid})
- if err != nil {
- return nil, w.mapError(err)
- }
- return convertExternalImages(res.Images), nil
+ resp, err := callMethod(ctx, w, "GetArtistImages", func(inst api.MetadataAgent) (*api.ArtistImageResponse, error) {
+ return inst.GetArtistImages(ctx, &api.ArtistImageRequest{Id: id, Name: name, Mbid: mbid})
})
+ if err != nil {
+ return nil, w.mapError(err)
+ }
+ return convertExternalImages(resp.Images), nil
}
func (w *wasmMediaAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
- return callMethod(ctx, w, "GetArtistTopSongs", func(inst api.MetadataAgent) ([]agents.Song, error) {
- resp, err := inst.GetArtistTopSongs(ctx, &api.ArtistTopSongsRequest{Id: id, ArtistName: artistName, Mbid: mbid, Count: int32(count)})
- if err != nil {
- return nil, w.mapError(err)
- }
- songs := make([]agents.Song, 0, len(resp.GetSongs()))
- for _, s := range resp.GetSongs() {
- songs = append(songs, agents.Song{
- Name: s.GetName(),
- MBID: s.GetMbid(),
- })
- }
- return songs, nil
+ resp, err := callMethod(ctx, w, "GetArtistTopSongs", func(inst api.MetadataAgent) (*api.ArtistTopSongsResponse, error) {
+ return inst.GetArtistTopSongs(ctx, &api.ArtistTopSongsRequest{Id: id, ArtistName: artistName, Mbid: mbid, Count: int32(count)})
})
+ if err != nil {
+ return nil, w.mapError(err)
+ }
+ songs := make([]agents.Song, 0, len(resp.GetSongs()))
+ for _, s := range resp.GetSongs() {
+ songs = append(songs, agents.Song{
+ Name: s.GetName(),
+ MBID: s.GetMbid(),
+ })
+ }
+ return songs, nil
}
// Helper function to convert ExternalImage objects from the API to the agents package
diff --git a/plugins/adapter_media_agent_test.go b/plugins/adapter_media_agent_test.go
index 709fd62cd..70b5d275a 100644
--- a/plugins/adapter_media_agent_test.go
+++ b/plugins/adapter_media_agent_test.go
@@ -7,6 +7,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
+ "github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/plugins/api"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -14,7 +15,7 @@ import (
var _ = Describe("Adapter Media Agent", func() {
var ctx context.Context
- var mgr *Manager
+ var mgr *managerImpl
BeforeEach(func() {
ctx = GinkgoT().Context()
@@ -23,8 +24,14 @@ var _ = Describe("Adapter Media Agent", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Folder = testDataDir
- mgr = createManager(nil, nil)
+ mgr = createManager(nil, metrics.NewNoopInstance())
mgr.ScanPlugins()
+
+ // Wait for all plugins to compile to avoid race conditions
+ err := mgr.EnsureCompiled("multi_plugin")
+ Expect(err).NotTo(HaveOccurred(), "multi_plugin should compile successfully")
+ err = mgr.EnsureCompiled("fake_album_agent")
+ Expect(err).NotTo(HaveOccurred(), "fake_album_agent should compile successfully")
})
Describe("AgentName and PluginName", func() {
diff --git a/plugins/adapter_scheduler_callback.go b/plugins/adapter_scheduler_callback.go
index 2fe94d613..64b7eefff 100644
--- a/plugins/adapter_scheduler_callback.go
+++ b/plugins/adapter_scheduler_callback.go
@@ -9,14 +9,14 @@ import (
)
// newWasmSchedulerCallback creates a new adapter for a SchedulerCallback plugin
-func newWasmSchedulerCallback(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
+func newWasmSchedulerCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
loader, err := api.NewSchedulerCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
if err != nil {
log.Error("Error creating scheduler callback plugin", "plugin", pluginID, "path", wasmPath, err)
return nil
}
return &wasmSchedulerCallback{
- wasmBasePlugin: newWasmBasePlugin[api.SchedulerCallback, *api.SchedulerCallbackPlugin](
+ baseCapability: newBaseCapability[api.SchedulerCallback, *api.SchedulerCallbackPlugin](
wasmPath,
pluginID,
CapabilitySchedulerCallback,
@@ -31,5 +31,16 @@ func newWasmSchedulerCallback(wasmPath, pluginID string, m *Manager, runtime api
// wasmSchedulerCallback adapts a SchedulerCallback plugin
type wasmSchedulerCallback struct {
- *wasmBasePlugin[api.SchedulerCallback, *api.SchedulerCallbackPlugin]
+ *baseCapability[api.SchedulerCallback, *api.SchedulerCallbackPlugin]
+}
+
+func (w *wasmSchedulerCallback) OnSchedulerCallback(ctx context.Context, scheduleID string, payload []byte, isRecurring bool) error {
+ _, err := callMethod(ctx, w, "OnSchedulerCallback", func(inst api.SchedulerCallback) (*api.SchedulerCallbackResponse, error) {
+ return inst.OnSchedulerCallback(ctx, &api.SchedulerCallbackRequest{
+ ScheduleId: scheduleID,
+ Payload: payload,
+ IsRecurring: isRecurring,
+ })
+ })
+ return err
}
diff --git a/plugins/adapter_scrobbler.go b/plugins/adapter_scrobbler.go
index b9c27901f..54c6af127 100644
--- a/plugins/adapter_scrobbler.go
+++ b/plugins/adapter_scrobbler.go
@@ -12,14 +12,14 @@ import (
"github.com/tetratelabs/wazero"
)
-func newWasmScrobblerPlugin(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
+func newWasmScrobblerPlugin(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
loader, err := api.NewScrobblerPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
if err != nil {
log.Error("Error creating scrobbler service plugin", "plugin", pluginID, "path", wasmPath, err)
return nil
}
return &wasmScrobblerPlugin{
- wasmBasePlugin: newWasmBasePlugin[api.Scrobbler, *api.ScrobblerPlugin](
+ baseCapability: newBaseCapability[api.Scrobbler, *api.ScrobblerPlugin](
wasmPath,
pluginID,
CapabilityScrobbler,
@@ -33,7 +33,7 @@ func newWasmScrobblerPlugin(wasmPath, pluginID string, m *Manager, runtime api.W
}
type wasmScrobblerPlugin struct {
- *wasmBasePlugin[api.Scrobbler, *api.ScrobblerPlugin]
+ *baseCapability[api.Scrobbler, *api.ScrobblerPlugin]
}
func (w *wasmScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
@@ -44,21 +44,16 @@ func (w *wasmScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) b
username = u.UserName
}
}
-
- result, err := callMethod(ctx, w, "IsAuthorized", func(inst api.Scrobbler) (bool, error) {
- resp, err := inst.IsAuthorized(ctx, &api.ScrobblerIsAuthorizedRequest{
+ resp, err := callMethod(ctx, w, "IsAuthorized", func(inst api.Scrobbler) (*api.ScrobblerIsAuthorizedResponse, error) {
+ return inst.IsAuthorized(ctx, &api.ScrobblerIsAuthorizedRequest{
UserId: userId,
Username: username,
})
- if err != nil {
- return false, err
- }
- if resp.Error != "" {
- return false, nil
- }
- return resp.Authorized, nil
})
- return err == nil && result
+ if err != nil {
+ log.Warn("Error calling IsAuthorized", "userId", userId, "pluginID", w.id, err)
+ }
+ return err == nil && resp.Authorized
}
func (w *wasmScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
@@ -70,25 +65,7 @@ func (w *wasmScrobblerPlugin) NowPlaying(ctx context.Context, userId string, tra
}
}
- artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist]))
- for _, a := range track.Participants[model.RoleArtist] {
- artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
- }
- albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist]))
- for _, a := range track.Participants[model.RoleAlbumArtist] {
- albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
- }
- trackInfo := &api.TrackInfo{
- Id: track.ID,
- Mbid: track.MbzRecordingID,
- Name: track.Title,
- Album: track.Album,
- AlbumMbid: track.MbzAlbumID,
- Artists: artists,
- AlbumArtists: albumArtists,
- Length: int32(track.Duration),
- Position: int32(position),
- }
+ trackInfo := w.toTrackInfo(track, position)
_, err := callMethod(ctx, w, "NowPlaying", func(inst api.Scrobbler) (struct{}, error) {
resp, err := inst.NowPlaying(ctx, &api.ScrobblerNowPlayingRequest{
UserId: userId,
@@ -115,26 +92,7 @@ func (w *wasmScrobblerPlugin) Scrobble(ctx context.Context, userId string, s scr
username = u.UserName
}
}
-
- track := &s.MediaFile
- artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist]))
- for _, a := range track.Participants[model.RoleArtist] {
- artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
- }
- albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist]))
- for _, a := range track.Participants[model.RoleAlbumArtist] {
- albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
- }
- trackInfo := &api.TrackInfo{
- Id: track.ID,
- Mbid: track.MbzRecordingID,
- Name: track.Title,
- Album: track.Album,
- AlbumMbid: track.MbzAlbumID,
- Artists: artists,
- AlbumArtists: albumArtists,
- Length: int32(track.Duration),
- }
+ trackInfo := w.toTrackInfo(&s.MediaFile, 0)
_, err := callMethod(ctx, w, "Scrobble", func(inst api.Scrobbler) (struct{}, error) {
resp, err := inst.Scrobble(ctx, &api.ScrobblerScrobbleRequest{
UserId: userId,
@@ -152,3 +110,27 @@ func (w *wasmScrobblerPlugin) Scrobble(ctx context.Context, userId string, s scr
})
return err
}
+
+func (w *wasmScrobblerPlugin) toTrackInfo(track *model.MediaFile, position int) *api.TrackInfo {
+ artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist]))
+
+ for _, a := range track.Participants[model.RoleArtist] {
+ artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
+ }
+ albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist]))
+ for _, a := range track.Participants[model.RoleAlbumArtist] {
+ albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
+ }
+ trackInfo := &api.TrackInfo{
+ Id: track.ID,
+ Mbid: track.MbzRecordingID,
+ Name: track.Title,
+ Album: track.Album,
+ AlbumMbid: track.MbzAlbumID,
+ Artists: artists,
+ AlbumArtists: albumArtists,
+ Length: int32(track.Duration),
+ Position: int32(position),
+ }
+ return trackInfo
+}
diff --git a/plugins/adapter_websocket_callback.go b/plugins/adapter_websocket_callback.go
index c45ee342e..83b8dd567 100644
--- a/plugins/adapter_websocket_callback.go
+++ b/plugins/adapter_websocket_callback.go
@@ -9,14 +9,14 @@ import (
)
// newWasmWebSocketCallback creates a new adapter for a WebSocketCallback plugin
-func newWasmWebSocketCallback(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
+func newWasmWebSocketCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
loader, err := api.NewWebSocketCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
if err != nil {
log.Error("Error creating WebSocket callback plugin", "plugin", pluginID, "path", wasmPath, err)
return nil
}
return &wasmWebSocketCallback{
- wasmBasePlugin: newWasmBasePlugin[api.WebSocketCallback, *api.WebSocketCallbackPlugin](
+ baseCapability: newBaseCapability[api.WebSocketCallback, *api.WebSocketCallbackPlugin](
wasmPath,
pluginID,
CapabilityWebSocketCallback,
@@ -31,5 +31,5 @@ func newWasmWebSocketCallback(wasmPath, pluginID string, m *Manager, runtime api
// wasmWebSocketCallback adapts a WebSocketCallback plugin
type wasmWebSocketCallback struct {
- *wasmBasePlugin[api.WebSocketCallback, *api.WebSocketCallbackPlugin]
+ *baseCapability[api.WebSocketCallback, *api.WebSocketCallbackPlugin]
}
diff --git a/plugins/api/api.pb.go b/plugins/api/api.pb.go
index 473598904..b570d5c61 100644
--- a/plugins/api/api.pb.go
+++ b/plugins/api/api.pb.go
@@ -903,7 +903,6 @@ type InitRequest struct {
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
- // Empty for now
Config map[string]string `protobuf:"bytes,1,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` // Configuration specific to this plugin
}
diff --git a/plugins/api/api.proto b/plugins/api/api.proto
index c451a82fc..7929ff9e6 100644
--- a/plugins/api/api.proto
+++ b/plugins/api/api.proto
@@ -194,7 +194,6 @@ service LifecycleManagement {
}
message InitRequest {
- // Empty for now
map config = 1; // Configuration specific to this plugin
}
diff --git a/plugins/api/api_plugin_dev_named_registry.go b/plugins/api/api_plugin_dev_named_registry.go
index 05421ad73..2ddb68779 100644
--- a/plugins/api/api_plugin_dev_named_registry.go
+++ b/plugins/api/api_plugin_dev_named_registry.go
@@ -88,3 +88,7 @@ func (n *namedSchedulerService) CancelSchedule(ctx context.Context, request *sch
request.ScheduleId = key
return n.svc.CancelSchedule(ctx, request)
}
+
+func (n *namedSchedulerService) TimeNow(ctx context.Context, request *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) {
+ return n.svc.TimeNow(ctx, request)
+}
diff --git a/plugins/api/errors.go b/plugins/api/errors.go
index e6d952b4f..796774b15 100644
--- a/plugins/api/errors.go
+++ b/plugins/api/errors.go
@@ -3,6 +3,10 @@ package api
import "errors"
var (
- ErrNotFound = errors.New("plugin:not_found")
+ // ErrNotImplemented indicates that the plugin does not implement the requested method.
+ // No logic should be executed by the plugin.
ErrNotImplemented = errors.New("plugin:not_implemented")
+
+ // ErrNotFound indicates that the requested resource was not found by the plugin.
+ ErrNotFound = errors.New("plugin:not_found")
)
diff --git a/plugins/base_capability.go b/plugins/base_capability.go
new file mode 100644
index 000000000..6572a25ec
--- /dev/null
+++ b/plugins/base_capability.go
@@ -0,0 +1,159 @@
+package plugins
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/navidrome/navidrome/core/metrics"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model/id"
+ "github.com/navidrome/navidrome/plugins/api"
+)
+
+// newBaseCapability creates a new instance of baseCapability with the required parameters.
+func newBaseCapability[S any, P any](wasmPath, id, capability string, m metrics.Metrics, loader P, loadFunc loaderFunc[S, P]) *baseCapability[S, P] {
+ return &baseCapability[S, P]{
+ wasmPath: wasmPath,
+ id: id,
+ capability: capability,
+ loader: loader,
+ loadFunc: loadFunc,
+ metrics: m,
+ }
+}
+
+// LoaderFunc is a generic function type that loads a plugin instance.
+type loaderFunc[S any, P any] func(ctx context.Context, loader P, path string) (S, error)
+
+// baseCapability is a generic base implementation for WASM plugins.
+// S is the capability interface type and P is the plugin loader type.
+type baseCapability[S any, P any] struct {
+ wasmPath string
+ id string
+ capability string
+ loader P
+ loadFunc loaderFunc[S, P]
+ metrics metrics.Metrics
+}
+
+func (w *baseCapability[S, P]) PluginID() string {
+ return w.id
+}
+
+func (w *baseCapability[S, P]) serviceName() string {
+ return w.id + "_" + w.capability
+}
+
+func (w *baseCapability[S, P]) getMetrics() metrics.Metrics {
+ return w.metrics
+}
+
+// getInstance loads a new plugin instance and returns a cleanup function.
+func (w *baseCapability[S, P]) getInstance(ctx context.Context, methodName string) (S, func(), error) {
+ start := time.Now()
+ // Add context metadata for tracing
+ ctx = log.NewContext(ctx, "capability", w.serviceName(), "method", methodName)
+
+ inst, err := w.loadFunc(ctx, w.loader, w.wasmPath)
+ if err != nil {
+ var zero S
+ return zero, func() {}, fmt.Errorf("baseCapability: failed to load instance for %s: %w", w.serviceName(), err)
+ }
+ // Add context metadata for tracing
+ ctx = log.NewContext(ctx, "instanceID", getInstanceID(inst))
+ log.Trace(ctx, "baseCapability: loaded instance", "elapsed", time.Since(start))
+ return inst, func() {
+ log.Trace(ctx, "baseCapability: finished using instance", "elapsed", time.Since(start))
+ if closer, ok := any(inst).(interface{ Close(context.Context) error }); ok {
+ _ = closer.Close(ctx)
+ }
+ }, nil
+}
+
+type wasmPlugin[S any] interface {
+ PluginID() string
+ getInstance(ctx context.Context, methodName string) (S, func(), error)
+ getMetrics() metrics.Metrics
+}
+
+func callMethod[S any, R any](ctx context.Context, wp WasmPlugin, methodName string, fn func(inst S) (R, error)) (R, error) {
+ // Add a unique call ID to the context for tracing
+ ctx = log.NewContext(ctx, "callID", id.NewRandom())
+ var r R
+
+ p, ok := wp.(wasmPlugin[S])
+ if !ok {
+ log.Error(ctx, "callMethod: not a wasm plugin", "method", methodName, "pluginID", wp.PluginID())
+ return r, fmt.Errorf("wasm plugin: not a wasm plugin: %s", wp.PluginID())
+ }
+
+ inst, done, err := p.getInstance(ctx, methodName)
+ if err != nil {
+ return r, err
+ }
+ start := time.Now()
+ defer done()
+ r, err = checkErr(fn(inst))
+ elapsed := time.Since(start)
+
+ if !errors.Is(err, api.ErrNotImplemented) {
+ id := p.PluginID()
+ isOk := err == nil
+ metrics := p.getMetrics()
+ if metrics != nil {
+ metrics.RecordPluginRequest(ctx, id, methodName, isOk, elapsed.Milliseconds())
+ log.Trace(ctx, "callMethod: sending metrics", "plugin", id, "method", methodName, "ok", isOk, "elapsed", elapsed)
+ }
+ }
+
+ return r, err
+}
+
+// errorResponse is an interface that defines a method to retrieve an error message.
+// It is automatically implemented (generated) by all plugin responses that have an Error field
+type errorResponse interface {
+ GetError() string
+}
+
+// checkErr returns an updated error if the response implements errorResponse and contains an error message.
+// If the response is nil, it returns the original error. Otherwise, it wraps or creates an error as needed.
+// It also maps error strings to their corresponding api.Err* constants.
+func checkErr[T any](resp T, err error) (T, error) {
+ if any(resp) == nil {
+ return resp, mapAPIError(err)
+ }
+ respErr, ok := any(resp).(errorResponse)
+ if ok && respErr.GetError() != "" {
+ respErrMsg := respErr.GetError()
+ respErrErr := errors.New(respErrMsg)
+ mappedErr := mapAPIError(respErrErr)
+ // Check if the error was mapped to an API error (different from the temp error)
+ if errors.Is(mappedErr, api.ErrNotImplemented) || errors.Is(mappedErr, api.ErrNotFound) {
+ // Return the mapped API error instead of wrapping
+ return resp, mappedErr
+ }
+ // For non-API errors, use wrap the original error if it is not nil
+ return resp, errors.Join(respErrErr, err)
+ }
+ return resp, mapAPIError(err)
+}
+
+// mapAPIError maps error strings to their corresponding api.Err* constants.
+// This is needed as errors from plugins may not be of type api.Error, due to serialization/deserialization.
+func mapAPIError(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ errStr := err.Error()
+ switch errStr {
+ case api.ErrNotImplemented.Error():
+ return api.ErrNotImplemented
+ case api.ErrNotFound.Error():
+ return api.ErrNotFound
+ default:
+ return err
+ }
+}
diff --git a/plugins/base_capability_test.go b/plugins/base_capability_test.go
new file mode 100644
index 000000000..3bece8dcd
--- /dev/null
+++ b/plugins/base_capability_test.go
@@ -0,0 +1,285 @@
+package plugins
+
+import (
+ "context"
+ "errors"
+
+ "github.com/navidrome/navidrome/plugins/api"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+type nilInstance struct{}
+
+var _ = Describe("baseCapability", func() {
+ var ctx = context.Background()
+
+ It("should load instance using loadFunc", func() {
+ called := false
+ plugin := &baseCapability[*nilInstance, any]{
+ wasmPath: "",
+ id: "test",
+ capability: "test",
+ loadFunc: func(ctx context.Context, _ any, path string) (*nilInstance, error) {
+ called = true
+ return &nilInstance{}, nil
+ },
+ }
+ inst, done, err := plugin.getInstance(ctx, "test")
+ defer done()
+ Expect(err).To(BeNil())
+ Expect(inst).ToNot(BeNil())
+ Expect(called).To(BeTrue())
+ })
+})
+
+var _ = Describe("checkErr", func() {
+ Context("when resp is nil", func() {
+ It("should return nil error when both resp and err are nil", func() {
+ var resp *testErrorResponse
+
+ result, err := checkErr(resp, nil)
+
+ Expect(result).To(BeNil())
+ Expect(err).To(BeNil())
+ })
+
+ It("should return original error unchanged for non-API errors", func() {
+ var resp *testErrorResponse
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(BeNil())
+ Expect(err).To(Equal(originalErr))
+ })
+
+ It("should return mapped API error for ErrNotImplemented", func() {
+ var resp *testErrorResponse
+ err := errors.New("plugin:not_implemented")
+
+ result, mappedErr := checkErr(resp, err)
+
+ Expect(result).To(BeNil())
+ Expect(mappedErr).To(Equal(api.ErrNotImplemented))
+ })
+
+ It("should return mapped API error for ErrNotFound", func() {
+ var resp *testErrorResponse
+ err := errors.New("plugin:not_found")
+
+ result, mappedErr := checkErr(resp, err)
+
+ Expect(result).To(BeNil())
+ Expect(mappedErr).To(Equal(api.ErrNotFound))
+ })
+ })
+
+ Context("when resp is a typed nil that implements errorResponse", func() {
+ It("should not panic and return original error", func() {
+ var resp *testErrorResponse // typed nil
+ originalErr := errors.New("original error")
+
+ // This should not panic
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(BeNil())
+ Expect(err).To(Equal(originalErr))
+ })
+
+ It("should handle typed nil with nil error gracefully", func() {
+ var resp *testErrorResponse // typed nil
+
+ // This should not panic
+ result, err := checkErr(resp, nil)
+
+ Expect(result).To(BeNil())
+ Expect(err).To(BeNil())
+ })
+ })
+
+ Context("when resp implements errorResponse with non-empty error", func() {
+ It("should create new error when original error is nil", func() {
+ resp := &testErrorResponse{errorMsg: "plugin error"}
+
+ result, err := checkErr(resp, nil)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError("plugin error"))
+ })
+
+ It("should wrap original error when both exist", func() {
+ resp := &testErrorResponse{errorMsg: "plugin error"}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(HaveOccurred())
+ // Check that both error messages are present in the joined error
+ errStr := err.Error()
+ Expect(errStr).To(ContainSubstring("plugin error"))
+ Expect(errStr).To(ContainSubstring("original error"))
+ })
+
+ It("should return mapped API error for ErrNotImplemented when no original error", func() {
+ resp := &testErrorResponse{errorMsg: "plugin:not_implemented"}
+
+ result, err := checkErr(resp, nil)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(api.ErrNotImplemented))
+ })
+
+ It("should return mapped API error for ErrNotFound when no original error", func() {
+ resp := &testErrorResponse{errorMsg: "plugin:not_found"}
+
+ result, err := checkErr(resp, nil)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(api.ErrNotFound))
+ })
+
+ It("should return mapped API error for ErrNotImplemented even with original error", func() {
+ resp := &testErrorResponse{errorMsg: "plugin:not_implemented"}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(api.ErrNotImplemented))
+ })
+
+ It("should return mapped API error for ErrNotFound even with original error", func() {
+ resp := &testErrorResponse{errorMsg: "plugin:not_found"}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(api.ErrNotFound))
+ })
+ })
+
+ Context("when resp implements errorResponse with empty error", func() {
+ It("should return original error unchanged", func() {
+ resp := &testErrorResponse{errorMsg: ""}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(originalErr))
+ })
+
+ It("should return nil error when both are empty/nil", func() {
+ resp := &testErrorResponse{errorMsg: ""}
+
+ result, err := checkErr(resp, nil)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(BeNil())
+ })
+
+ It("should map original API error when response error is empty", func() {
+ resp := &testErrorResponse{errorMsg: ""}
+ originalErr := errors.New("plugin:not_implemented")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(api.ErrNotImplemented))
+ })
+ })
+
+ Context("when resp does not implement errorResponse", func() {
+ It("should return original error unchanged", func() {
+ resp := &testNonErrorResponse{data: "some data"}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(Equal(originalErr))
+ })
+
+ It("should return nil error when original error is nil", func() {
+ resp := &testNonErrorResponse{data: "some data"}
+
+ result, err := checkErr(resp, nil)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(BeNil())
+ })
+
+ It("should map original API error when response doesn't implement errorResponse", func() {
+ resp := &testNonErrorResponse{data: "some data"}
+ originalErr := errors.New("plugin:not_found")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(api.ErrNotFound))
+ })
+ })
+
+ Context("when resp is a value type (not pointer)", func() {
+ It("should handle value types that implement errorResponse", func() {
+ resp := testValueErrorResponse{errorMsg: "value error"}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(HaveOccurred())
+ // Check that both error messages are present in the joined error
+ errStr := err.Error()
+ Expect(errStr).To(ContainSubstring("value error"))
+ Expect(errStr).To(ContainSubstring("original error"))
+ })
+
+ It("should handle value types with empty error", func() {
+ resp := testValueErrorResponse{errorMsg: ""}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(originalErr))
+ })
+
+ It("should handle value types with API error", func() {
+ resp := testValueErrorResponse{errorMsg: "plugin:not_implemented"}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(api.ErrNotImplemented))
+ })
+ })
+})
+
+// Test helper types
+type testErrorResponse struct {
+ errorMsg string
+}
+
+func (t *testErrorResponse) GetError() string {
+ if t == nil {
+ return "" // This is what would typically happen with a typed nil
+ }
+ return t.errorMsg
+}
+
+type testNonErrorResponse struct {
+ data string
+}
+
+type testValueErrorResponse struct {
+ errorMsg string
+}
+
+func (t testValueErrorResponse) GetError() string {
+ return t.errorMsg
+}
diff --git a/plugins/examples/README.md b/plugins/examples/README.md
index 6527026fd..61d6b2ef9 100644
--- a/plugins/examples/README.md
+++ b/plugins/examples/README.md
@@ -4,11 +4,11 @@ This directory contains example plugins for Navidrome, intended for demonstratio
## Contents
-- `wikimedia/`: Example plugin that retrieves artist information from Wikidata.
-- `coverartarchive/`: Example plugin that retrieves album cover images from the Cover Art Archive.
-- `crypto-ticker/`: Example plugin using websockets to log real-time cryptocurrency prices.
-- `discord-rich-presence/`: Example plugin that integrates with Discord Rich Presence to display currently playing tracks on Discord profiles.
-- `subsonicapi-demo/`: Example plugin that demonstrates how to interact with the Navidrome's Subsonic API from a plugin.
+- `wikimedia/`: Retrieves artist information from Wikidata.
+- `coverartarchive/`: Fetches album cover images from the Cover Art Archive.
+- `crypto-ticker/`: Uses websockets to log real-time cryptocurrency prices.
+- `discord-rich-presence/`: Integrates with Discord Rich Presence to display currently playing tracks on Discord profiles.
+- `subsonicapi-demo/`: Demonstrates interaction with Navidrome's Subsonic API from a plugin.
## Building
diff --git a/plugins/examples/coverartarchive/plugin.go b/plugins/examples/coverartarchive/plugin.go
index f91546de3..ee612c31c 100644
--- a/plugins/examples/coverartarchive/plugin.go
+++ b/plugins/examples/coverartarchive/plugin.go
@@ -143,5 +143,9 @@ func (CoverArtArchiveAgent) GetArtistTopSongs(ctx context.Context, req *api.Arti
func main() {}
func init() {
+ // Configure logging: No timestamps, no source file/line
+ log.SetFlags(0)
+ log.SetPrefix("[CAA] ")
+
api.RegisterMetadataAgent(CoverArtArchiveAgent{})
}
diff --git a/plugins/examples/crypto-ticker/README.md b/plugins/examples/crypto-ticker/README.md
index c550ebfe9..ca6d2c44a 100644
--- a/plugins/examples/crypto-ticker/README.md
+++ b/plugins/examples/crypto-ticker/README.md
@@ -15,7 +15,7 @@ This is a WebSocket-based WASM plugin for Navidrome that displays real-time cryp
In your `navidrome.toml` file, add:
```toml
-[PluginSettings.crypto-ticker]
+[PluginConfig.crypto-ticker]
tickers = "BTC,ETH,SOL,MATIC"
```
diff --git a/plugins/examples/crypto-ticker/plugin.go b/plugins/examples/crypto-ticker/plugin.go
index e7c646c21..3fced6d5c 100644
--- a/plugins/examples/crypto-ticker/plugin.go
+++ b/plugins/examples/crypto-ticker/plugin.go
@@ -294,6 +294,10 @@ func calculatePercentChange(open, current string) string {
func main() {}
func init() {
+ // Configure logging: No timestamps, no source file/line, prepend [Crypto]
+ log.SetFlags(0)
+ log.SetPrefix("[Crypto] ")
+
api.RegisterWebSocketCallback(CryptoTickerPlugin{})
api.RegisterLifecycleManagement(CryptoTickerPlugin{})
api.RegisterSchedulerCallback(CryptoTickerPlugin{})
diff --git a/plugins/examples/discord-rich-presence/README.md b/plugins/examples/discord-rich-presence/README.md
index 8cb97224a..80b12166f 100644
--- a/plugins/examples/discord-rich-presence/README.md
+++ b/plugins/examples/discord-rich-presence/README.md
@@ -59,7 +59,7 @@ clientID, users, err := d.getConfig(ctx)
Add the following to `navidrome.toml` and adjust for your tokens:
```toml
-[PluginSettings.discord-rich-presence]
+[PluginConfig.discord-rich-presence]
ClientID = "123456789012345678"
Users = "alice:token123,bob:token456"
```
diff --git a/plugins/examples/discord-rich-presence/rpc.go b/plugins/examples/discord-rich-presence/rpc.go
index 4b383c53a..4fab42f41 100644
--- a/plugins/examples/discord-rich-presence/rpc.go
+++ b/plugins/examples/discord-rich-presence/rpc.go
@@ -248,9 +248,37 @@ func (r *discordRPC) sendHeartbeat(ctx context.Context, username string) error {
return r.sendMessage(ctx, username, heartbeatOpCode, resp.Value)
}
+func (r *discordRPC) cleanupFailedConnection(ctx context.Context, username string) {
+ log.Printf("Cleaning up failed connection for user %s", username)
+
+ // Cancel the heartbeat schedule
+ if resp, _ := r.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: username}); resp.Error != "" {
+ log.Printf("Failed to cancel heartbeat schedule for user %s: %s", username, resp.Error)
+ }
+
+ // Close the WebSocket connection
+ if resp, _ := r.ws.Close(ctx, &websocket.CloseRequest{
+ ConnectionId: username,
+ Code: 1000,
+ Reason: "Connection lost",
+ }); resp.Error != "" {
+ log.Printf("Failed to close WebSocket connection for user %s: %s", username, resp.Error)
+ }
+
+ // Clean up cache entries (just the sequence number, no failure tracking needed)
+ _, _ = r.mem.Remove(ctx, &cache.RemoveRequest{Key: fmt.Sprintf("discord.seq.%s", username)})
+
+ log.Printf("Cleaned up connection for user %s", username)
+}
+
func (r *discordRPC) isConnected(ctx context.Context, username string) bool {
+ // Try to send a heartbeat to test the connection
err := r.sendHeartbeat(ctx, username)
- return err == nil
+ if err != nil {
+ log.Printf("Heartbeat test failed for user %s: %v", username, err)
+ return false
+ }
+ return true
}
func (r *discordRPC) connect(ctx context.Context, username string, token string) error {
@@ -361,5 +389,14 @@ func (r *discordRPC) OnClose(_ context.Context, req *api.OnCloseRequest) (*api.O
}
func (r *discordRPC) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) {
- return nil, r.sendHeartbeat(ctx, req.ScheduleId)
+ err := r.sendHeartbeat(ctx, req.ScheduleId)
+ if err != nil {
+ // On first heartbeat failure, immediately clean up the connection
+ // The next NowPlaying call will reconnect if needed
+ log.Printf("Heartbeat failed for user %s, cleaning up connection: %v", req.ScheduleId, err)
+ r.cleanupFailedConnection(ctx, req.ScheduleId)
+ return nil, fmt.Errorf("heartbeat failed, connection cleaned up: %w", err)
+ }
+
+ return nil, nil
}
diff --git a/plugins/examples/subsonicapi-demo/plugin.go b/plugins/examples/subsonicapi-demo/plugin.go
index c3adc6579..4ca087ac7 100644
--- a/plugins/examples/subsonicapi-demo/plugin.go
+++ b/plugins/examples/subsonicapi-demo/plugin.go
@@ -60,5 +60,9 @@ func (SubsonicAPIDemoPlugin) OnInit(ctx context.Context, req *api.InitRequest) (
func main() {}
func init() {
+ // Configure logging: No timestamps, no source file/line
+ log.SetFlags(0)
+ log.SetPrefix("[Subsonic Plugin] ")
+
api.RegisterLifecycleManagement(&SubsonicAPIDemoPlugin{})
}
diff --git a/plugins/examples/wikimedia/plugin.go b/plugins/examples/wikimedia/plugin.go
index b64e8cd86..6b60e69da 100644
--- a/plugins/examples/wikimedia/plugin.go
+++ b/plugins/examples/wikimedia/plugin.go
@@ -383,5 +383,9 @@ func (WikimediaAgent) GetAlbumImages(context.Context, *api.AlbumImagesRequest) (
func main() {}
func init() {
+ // Configure logging: No timestamps, no source file/line
+ log.SetFlags(0)
+ log.SetPrefix("[Wikimedia] ")
+
api.RegisterMetadataAgent(WikimediaAgent{})
}
diff --git a/plugins/host/scheduler/scheduler.pb.go b/plugins/host/scheduler/scheduler.pb.go
index 6d4c29205..07d250cc5 100644
--- a/plugins/host/scheduler/scheduler.pb.go
+++ b/plugins/host/scheduler/scheduler.pb.go
@@ -154,6 +154,51 @@ func (x *CancelResponse) GetError() string {
return ""
}
+type TimeNowRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+}
+
+func (x *TimeNowRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+type TimeNowResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Rfc3339Nano string `protobuf:"bytes,1,opt,name=rfc3339_nano,json=rfc3339Nano,proto3" json:"rfc3339_nano,omitempty"` // Current time in RFC3339Nano format
+ UnixMilli int64 `protobuf:"varint,2,opt,name=unix_milli,json=unixMilli,proto3" json:"unix_milli,omitempty"` // Current time as Unix milliseconds timestamp
+ LocalTimeZone string `protobuf:"bytes,3,opt,name=local_time_zone,json=localTimeZone,proto3" json:"local_time_zone,omitempty"` // Local timezone name (e.g., "America/New_York", "UTC")
+}
+
+func (x *TimeNowResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *TimeNowResponse) GetRfc3339Nano() string {
+ if x != nil {
+ return x.Rfc3339Nano
+ }
+ return ""
+}
+
+func (x *TimeNowResponse) GetUnixMilli() int64 {
+ if x != nil {
+ return x.UnixMilli
+ }
+ return 0
+}
+
+func (x *TimeNowResponse) GetLocalTimeZone() string {
+ if x != nil {
+ return x.LocalTimeZone
+ }
+ return ""
+}
+
// go:plugin type=host version=1
type SchedulerService interface {
// One-time event scheduling
@@ -162,4 +207,6 @@ type SchedulerService interface {
ScheduleRecurring(context.Context, *ScheduleRecurringRequest) (*ScheduleResponse, error)
// Cancel any scheduled job
CancelSchedule(context.Context, *CancelRequest) (*CancelResponse, error)
+ // Get current time in multiple formats
+ TimeNow(context.Context, *TimeNowRequest) (*TimeNowResponse, error)
}
diff --git a/plugins/host/scheduler/scheduler.proto b/plugins/host/scheduler/scheduler.proto
index 39fd32a58..d164b4f90 100644
--- a/plugins/host/scheduler/scheduler.proto
+++ b/plugins/host/scheduler/scheduler.proto
@@ -14,6 +14,9 @@ service SchedulerService {
// Cancel any scheduled job
rpc CancelSchedule(CancelRequest) returns (CancelResponse);
+
+ // Get current time in multiple formats
+ rpc TimeNow(TimeNowRequest) returns (TimeNowResponse);
}
message ScheduleOneTimeRequest {
@@ -39,4 +42,14 @@ message CancelRequest {
message CancelResponse {
bool success = 1; // Whether cancellation was successful
string error = 2; // Error message if cancellation failed
+}
+
+message TimeNowRequest {
+ // Empty request - no parameters needed
+}
+
+message TimeNowResponse {
+ string rfc3339_nano = 1; // Current time in RFC3339Nano format
+ int64 unix_milli = 2; // Current time as Unix milliseconds timestamp
+ string local_time_zone = 3; // Local timezone name (e.g., "America/New_York", "UTC")
}
\ No newline at end of file
diff --git a/plugins/host/scheduler/scheduler_host.pb.go b/plugins/host/scheduler/scheduler_host.pb.go
index 289f3f0bb..714603a3b 100644
--- a/plugins/host/scheduler/scheduler_host.pb.go
+++ b/plugins/host/scheduler/scheduler_host.pb.go
@@ -44,6 +44,11 @@ func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions SchedulerS
WithParameterNames("offset", "size").
Export("cancel_schedule")
+ envBuilder.NewFunctionBuilder().
+ WithGoModuleFunction(api.GoModuleFunc(h._TimeNow), []api.ValueType{i32, i32}, []api.ValueType{i64}).
+ WithParameterNames("offset", "size").
+ Export("time_now")
+
_, err := envBuilder.Instantiate(ctx)
return err
}
@@ -134,3 +139,32 @@ func (h _schedulerService) _CancelSchedule(ctx context.Context, m api.Module, st
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
+
+// Get current time in multiple formats
+
+func (h _schedulerService) _TimeNow(ctx context.Context, m api.Module, stack []uint64) {
+ offset, size := uint32(stack[0]), uint32(stack[1])
+ buf, err := wasm.ReadMemory(m.Memory(), offset, size)
+ if err != nil {
+ panic(err)
+ }
+ request := new(TimeNowRequest)
+ err = request.UnmarshalVT(buf)
+ if err != nil {
+ panic(err)
+ }
+ resp, err := h.TimeNow(ctx, request)
+ if err != nil {
+ panic(err)
+ }
+ buf, err = resp.MarshalVT()
+ if err != nil {
+ panic(err)
+ }
+ ptr, err := wasm.WriteMemory(ctx, m, buf)
+ if err != nil {
+ panic(err)
+ }
+ ptrLen := (ptr << uint64(32)) | uint64(len(buf))
+ stack[0] = ptrLen
+}
diff --git a/plugins/host/scheduler/scheduler_plugin.pb.go b/plugins/host/scheduler/scheduler_plugin.pb.go
index afbed2bf0..ab7f8cd48 100644
--- a/plugins/host/scheduler/scheduler_plugin.pb.go
+++ b/plugins/host/scheduler/scheduler_plugin.pb.go
@@ -88,3 +88,26 @@ func (h schedulerService) CancelSchedule(ctx context.Context, request *CancelReq
}
return response, nil
}
+
+//go:wasmimport env time_now
+func _time_now(ptr uint32, size uint32) uint64
+
+func (h schedulerService) TimeNow(ctx context.Context, request *TimeNowRequest) (*TimeNowResponse, error) {
+ buf, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ ptr, size := wasm.ByteToPtr(buf)
+ ptrSize := _time_now(ptr, size)
+ wasm.Free(ptr)
+
+ ptr = uint32(ptrSize >> 32)
+ size = uint32(ptrSize)
+ buf = wasm.PtrToByte(ptr, size)
+
+ response := new(TimeNowResponse)
+ if err = response.UnmarshalVT(buf); err != nil {
+ return nil, err
+ }
+ return response, nil
+}
diff --git a/plugins/host/scheduler/scheduler_vtproto.pb.go b/plugins/host/scheduler/scheduler_vtproto.pb.go
index 1606ab7f0..ee6421783 100644
--- a/plugins/host/scheduler/scheduler_vtproto.pb.go
+++ b/plugins/host/scheduler/scheduler_vtproto.pb.go
@@ -256,6 +256,91 @@ func (m *CancelResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
return len(dAtA) - i, nil
}
+func (m *TimeNowRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *TimeNowRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *TimeNowRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *TimeNowResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *TimeNowResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *TimeNowResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.LocalTimeZone) > 0 {
+ i -= len(m.LocalTimeZone)
+ copy(dAtA[i:], m.LocalTimeZone)
+ i = encodeVarint(dAtA, i, uint64(len(m.LocalTimeZone)))
+ i--
+ dAtA[i] = 0x1a
+ }
+ if m.UnixMilli != 0 {
+ i = encodeVarint(dAtA, i, uint64(m.UnixMilli))
+ i--
+ dAtA[i] = 0x10
+ }
+ if len(m.Rfc3339Nano) > 0 {
+ i -= len(m.Rfc3339Nano)
+ copy(dAtA[i:], m.Rfc3339Nano)
+ i = encodeVarint(dAtA, i, uint64(len(m.Rfc3339Nano)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
func encodeVarint(dAtA []byte, offset int, v uint64) int {
offset -= sov(v)
base := offset
@@ -355,6 +440,37 @@ func (m *CancelResponse) SizeVT() (n int) {
return n
}
+func (m *TimeNowRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *TimeNowResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Rfc3339Nano)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ if m.UnixMilli != 0 {
+ n += 1 + sov(uint64(m.UnixMilli))
+ }
+ l = len(m.LocalTimeZone)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
func sov(x uint64) (n int) {
return (bits.Len64(x|1) + 6) / 7
}
@@ -915,6 +1031,191 @@ func (m *CancelResponse) UnmarshalVT(dAtA []byte) error {
}
return nil
}
+func (m *TimeNowRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: TimeNowRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: TimeNowRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *TimeNowResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: TimeNowResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: TimeNowResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Rfc3339Nano", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Rfc3339Nano = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 0 {
+ return fmt.Errorf("proto: wrong wireType = %d for field UnixMilli", wireType)
+ }
+ m.UnixMilli = 0
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ m.UnixMilli |= int64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ case 3:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field LocalTimeZone", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.LocalTimeZone = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
func skip(dAtA []byte) (n int, err error) {
l := len(dAtA)
diff --git a/plugins/host_scheduler.go b/plugins/host_scheduler.go
index 6cea93280..26c5e92f8 100644
--- a/plugins/host_scheduler.go
+++ b/plugins/host_scheduler.go
@@ -8,7 +8,6 @@ import (
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/plugins/api"
"github.com/navidrome/navidrome/plugins/host/scheduler"
navidsched "github.com/navidrome/navidrome/scheduler"
)
@@ -46,16 +45,20 @@ func (s SchedulerHostFunctions) CancelSchedule(ctx context.Context, req *schedul
return s.ss.cancelSchedule(ctx, s.pluginID, req)
}
+func (s SchedulerHostFunctions) TimeNow(ctx context.Context, req *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) {
+ return s.ss.timeNow(ctx, req)
+}
+
type schedulerService struct {
// Map of schedule IDs to their callback info
schedules map[string]*ScheduledCallback
- manager *Manager
+ manager *managerImpl
navidSched navidsched.Scheduler // Navidrome scheduler for recurring jobs
mu sync.Mutex
}
// newSchedulerService creates a new schedulerService instance
-func newSchedulerService(manager *Manager) *schedulerService {
+func newSchedulerService(manager *managerImpl) *schedulerService {
return &schedulerService{
schedules: make(map[string]*ScheduledCallback),
manager: manager,
@@ -261,6 +264,17 @@ func (s *schedulerService) cancelSchedule(_ context.Context, pluginID string, re
}, nil
}
+// timeNow returns the current time in multiple formats
+func (s *schedulerService) timeNow(_ context.Context, req *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) {
+ now := time.Now()
+
+ return &scheduler.TimeNowResponse{
+ Rfc3339Nano: now.Format(time.RFC3339Nano),
+ UnixMilli: now.UnixMilli(),
+ LocalTimeZone: now.Location().String(),
+ }, nil
+}
+
// runOneTimeSchedule handles the one-time schedule execution and callback
func (s *schedulerService) runOneTimeSchedule(ctx context.Context, internalScheduleId string, delay time.Duration) {
tmr := time.NewTimer(delay)
@@ -295,21 +309,10 @@ func (s *schedulerService) executeCallback(ctx context.Context, internalSchedule
return
}
- callbackType := "one-time"
- if isRecurring {
- callbackType = "recurring"
- }
-
- log.Debug("Executing schedule callback", "plugin", callback.PluginID, "scheduleID", callback.ID, "type", callbackType)
+ ctx = log.NewContext(ctx, "plugin", callback.PluginID, "scheduleID", callback.ID, "type", callback.Type)
+ log.Debug("Executing schedule callback")
start := time.Now()
- // Create a SchedulerCallbackRequest
- req := &api.SchedulerCallbackRequest{
- ScheduleId: callback.ID,
- Payload: callback.Payload,
- IsRecurring: isRecurring,
- }
-
// Get the plugin
p := s.manager.LoadPlugin(callback.PluginID, CapabilitySchedulerCallback)
if p == nil {
@@ -317,31 +320,19 @@ func (s *schedulerService) executeCallback(ctx context.Context, internalSchedule
return
}
- // Get instance
- inst, closeFn, err := p.Instantiate(ctx)
- if err != nil {
- log.Error("Error getting plugin instance for callback", "plugin", callback.PluginID, err)
- return
- }
- defer closeFn()
-
// Type-check the plugin
- plugin, ok := inst.(api.SchedulerCallback)
+ plugin, ok := p.(*wasmSchedulerCallback)
if !ok {
log.Error("Plugin does not implement SchedulerCallback", "plugin", callback.PluginID)
return
}
// Call the plugin's OnSchedulerCallback method
- log.Trace(ctx, "Executing schedule callback", "plugin", callback.PluginID, "scheduleID", callback.ID, "type", callbackType)
- resp, err := plugin.OnSchedulerCallback(ctx, req)
+ log.Trace(ctx, "Executing schedule callback")
+ err := plugin.OnSchedulerCallback(ctx, callback.ID, callback.Payload, isRecurring)
if err != nil {
- log.Error("Error executing schedule callback", "plugin", callback.PluginID, "elapsed", time.Since(start), err)
+ log.Error("Error executing schedule callback", "elapsed", time.Since(start), err)
return
}
- log.Debug("Schedule callback executed", "plugin", callback.PluginID, "elapsed", time.Since(start))
-
- if resp.Error != "" {
- log.Error("Plugin reported error in schedule callback", "plugin", callback.PluginID, resp.Error)
- }
+ log.Debug("Schedule callback executed", "elapsed", time.Since(start))
}
diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go
index f544d716e..1a3efaae9 100644
--- a/plugins/host_scheduler_test.go
+++ b/plugins/host_scheduler_test.go
@@ -2,7 +2,9 @@ package plugins
import (
"context"
+ "time"
+ "github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/plugins/host/scheduler"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -11,12 +13,12 @@ import (
var _ = Describe("SchedulerService", func() {
var (
ss *schedulerService
- manager *Manager
+ manager *managerImpl
pluginName = "test_plugin"
)
BeforeEach(func() {
- manager = createManager(nil, nil)
+ manager = createManager(nil, metrics.NewNoopInstance())
ss = manager.schedulerService
})
@@ -163,4 +165,28 @@ var _ = Describe("SchedulerService", func() {
Expect(ss.scheduleCount()).To(Equal(beforeCount), "Job count should remain the same after replacement")
})
})
+
+ Describe("TimeNow", func() {
+ It("returns current time in RFC3339Nano, Unix milliseconds, and local timezone", func() {
+ now := time.Now()
+ req := &scheduler.TimeNowRequest{}
+ resp, err := ss.timeNow(context.Background(), req)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.UnixMilli).To(BeNumerically(">=", now.UnixMilli()))
+ Expect(resp.LocalTimeZone).ToNot(BeEmpty())
+
+ // Validate RFC3339Nano format can be parsed
+ parsedTime, parseErr := time.Parse(time.RFC3339Nano, resp.Rfc3339Nano)
+ Expect(parseErr).ToNot(HaveOccurred())
+
+ // Validate that Unix milliseconds is reasonably close to the RFC3339Nano time
+ expectedMillis := parsedTime.UnixMilli()
+ Expect(resp.UnixMilli).To(Equal(expectedMillis))
+
+ // Validate local timezone matches the current system timezone
+ expectedTimezone := now.Location().String()
+ Expect(resp.LocalTimeZone).To(Equal(expectedTimezone))
+ })
+ })
})
diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go
index d3008798a..937dd044f 100644
--- a/plugins/host_subsonicapi.go
+++ b/plugins/host_subsonicapi.go
@@ -93,8 +93,12 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, req *subsonicapi.Call
RawQuery: query.Encode(),
}
- // Create HTTP request with internal authentication
- httpReq, err := http.NewRequestWithContext(ctx, "GET", finalURL.String(), nil)
+ // Create HTTP request with a fresh context to avoid Chi RouteContext pollution.
+ // Using http.NewRequest (instead of http.NewRequestWithContext) ensures the internal
+ // SubsonicAPI call doesn't inherit routing information from the parent handler,
+ // which would cause Chi to invoke the wrong handler. Authentication context is
+ // explicitly added in the next step via request.WithInternalAuth.
+ httpReq, err := http.NewRequest("GET", finalURL.String(), nil)
if err != nil {
return &subsonicapi.CallResponse{
Error: fmt.Sprintf("failed to create HTTP request: %v", err),
diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go
index 131596b94..e90d1363d 100644
--- a/plugins/host_websocket.go
+++ b/plugins/host_websocket.go
@@ -50,12 +50,12 @@ func (s WebSocketHostFunctions) Close(ctx context.Context, req *websocket.CloseR
// websocketService implements the WebSocket service functionality
type websocketService struct {
connections map[string]*WebSocketConnection
- manager *Manager
+ manager *managerImpl
mu sync.RWMutex
}
// newWebsocketService creates a new websocketService instance
-func newWebsocketService(manager *Manager) *websocketService {
+func newWebsocketService(manager *managerImpl) *websocketService {
return &websocketService{
connections: make(map[string]*WebSocketConnection),
manager: manager,
@@ -314,7 +314,7 @@ func (s *websocketService) handleMessages(internalID string, conn *WebSocketConn
// executeCallback is a common function that handles the plugin loading and execution
// for all types of callbacks
-func (s *websocketService) executeCallback(ctx context.Context, pluginID string, fn func(context.Context, api.WebSocketCallback) error) {
+func (s *websocketService) executeCallback(ctx context.Context, pluginID, methodName string, fn func(context.Context, api.WebSocketCallback) error) {
log.Debug(ctx, "WebSocket received")
start := time.Now()
@@ -326,30 +326,16 @@ func (s *websocketService) executeCallback(ctx context.Context, pluginID string,
return
}
- // Get instance
- inst, closeFn, err := p.Instantiate(ctx)
- if err != nil {
- log.Error(ctx, "Error getting plugin instance for WebSocket callback", err)
- return
- }
- defer closeFn()
-
- // Type-check the plugin
- plugin, ok := inst.(api.WebSocketCallback)
- if !ok {
- log.Error(ctx, "Plugin does not implement WebSocketCallback")
- return
- }
-
- // Call the appropriate callback function
- log.Trace(ctx, "Executing WebSocket callback")
-
- if err = fn(ctx, plugin); err != nil {
- log.Error(ctx, "Error executing WebSocket callback", "elapsed", time.Since(start), err)
- return
- }
-
- log.Debug(ctx, "WebSocket callback executed", "elapsed", time.Since(start))
+ _, _ = callMethod(ctx, p, methodName, func(inst api.WebSocketCallback) (struct{}, error) {
+ // Call the appropriate callback function
+ log.Trace(ctx, "Executing WebSocket callback")
+ if err := fn(ctx, inst); err != nil {
+ log.Error(ctx, "Error executing WebSocket callback", "elapsed", time.Since(start), err)
+ return struct{}{}, fmt.Errorf("error executing WebSocket callback: %w", err)
+ }
+ log.Debug(ctx, "WebSocket callback executed", "elapsed", time.Since(start))
+ return struct{}{}, nil
+ })
}
// notifyTextCallback notifies the plugin of a text message
@@ -361,8 +347,8 @@ func (s *websocketService) notifyTextCallback(ctx context.Context, connectionID
ctx = log.NewContext(ctx, "callback", "OnTextMessage", "size", len(message))
- s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error {
- _, err := plugin.OnTextMessage(ctx, req)
+ s.executeCallback(ctx, conn.PluginName, "OnTextMessage", func(ctx context.Context, plugin api.WebSocketCallback) error {
+ _, err := checkErr(plugin.OnTextMessage(ctx, req))
return err
})
}
@@ -376,8 +362,8 @@ func (s *websocketService) notifyBinaryCallback(ctx context.Context, connectionI
ctx = log.NewContext(ctx, "callback", "OnBinaryMessage", "size", len(data))
- s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error {
- _, err := plugin.OnBinaryMessage(ctx, req)
+ s.executeCallback(ctx, conn.PluginName, "OnBinaryMessage", func(ctx context.Context, plugin api.WebSocketCallback) error {
+ _, err := checkErr(plugin.OnBinaryMessage(ctx, req))
return err
})
}
@@ -391,8 +377,8 @@ func (s *websocketService) notifyErrorCallback(ctx context.Context, connectionID
ctx = log.NewContext(ctx, "callback", "OnError", "error", errorMsg)
- s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error {
- _, err := plugin.OnError(ctx, req)
+ s.executeCallback(ctx, conn.PluginName, "OnError", func(ctx context.Context, plugin api.WebSocketCallback) error {
+ _, err := checkErr(plugin.OnError(ctx, req))
return err
})
}
@@ -407,8 +393,8 @@ func (s *websocketService) notifyCloseCallback(ctx context.Context, connectionID
ctx = log.NewContext(ctx, "callback", "OnClose", "code", code, "reason", reason)
- s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error {
- _, err := plugin.OnClose(ctx, req)
+ s.executeCallback(ctx, conn.PluginName, "OnClose", func(ctx context.Context, plugin api.WebSocketCallback) error {
+ _, err := checkErr(plugin.OnClose(ctx, req))
return err
})
}
diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go
index b6f4e2094..ecadc6463 100644
--- a/plugins/host_websocket_test.go
+++ b/plugins/host_websocket_test.go
@@ -6,9 +6,11 @@ import (
"net/http/httptest"
"strings"
"sync"
+ "testing"
"time"
gorillaws "github.com/gorilla/websocket"
+ "github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/plugins/host/websocket"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -17,7 +19,7 @@ import (
var _ = Describe("WebSocket Host Service", func() {
var (
wsService *websocketService
- manager *Manager
+ manager *managerImpl
ctx context.Context
server *httptest.Server
upgrader gorillaws.Upgrader
@@ -84,7 +86,7 @@ var _ = Describe("WebSocket Host Service", func() {
DeferCleanup(server.Close)
// Create a new manager and websocket service
- manager = createManager(nil, nil)
+ manager = createManager(nil, metrics.NewNoopInstance())
wsService = newWebsocketService(manager)
})
@@ -188,6 +190,10 @@ var _ = Describe("WebSocket Host Service", func() {
})
It("handles connection errors gracefully", func() {
+ if testing.Short() {
+ GinkgoT().Skip("skipping test in short mode.")
+ }
+
// Try to connect to an invalid URL
req := &websocket.ConnectRequest{
Url: "ws://invalid-url-that-does-not-exist",
diff --git a/plugins/manager.go b/plugins/manager.go
index 89ff854ae..35a1130fd 100644
--- a/plugins/manager.go
+++ b/plugins/manager.go
@@ -10,10 +10,10 @@ package plugins
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/subsonicapi/subsonicapi.proto
import (
- "context"
"fmt"
"net/http"
"os"
+ "slices"
"sync"
"sync/atomic"
"time"
@@ -40,7 +40,7 @@ const (
)
// pluginCreators maps capability types to their respective creator functions
-type pluginConstructor func(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin
+type pluginConstructor func(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin
var pluginCreators = map[string]pluginConstructor{
CapabilityMetadataAgent: newWasmMediaAgent,
@@ -53,8 +53,6 @@ var pluginCreators = map[string]pluginConstructor{
type WasmPlugin interface {
// PluginID returns the unique identifier of the plugin (folder name)
PluginID() string
- // Instantiate creates a new instance of the plugin and returns it along with a cleanup function
- Instantiate(ctx context.Context) (any, func(), error)
}
type plugin struct {
@@ -86,10 +84,21 @@ func (p *plugin) waitForCompilation() error {
type SubsonicRouter http.Handler
-// Manager is a singleton that manages plugins
-type Manager struct {
+type Manager interface {
+ SetSubsonicRouter(router SubsonicRouter)
+ EnsureCompiled(name string) error
+ PluginList() map[string]schema.PluginManifest
+ PluginNames(capability string) []string
+ LoadPlugin(name string, capability string) WasmPlugin
+ LoadMediaAgent(name string) (agents.Interface, bool)
+ LoadScrobbler(name string) (scrobbler.Scrobbler, bool)
+ ScanPlugins()
+}
+
+// managerImpl is a singleton that manages plugins
+type managerImpl struct {
plugins map[string]*plugin // Map of plugin folder name to plugin info
- mu sync.RWMutex // Protects plugins map
+ pluginsMu sync.RWMutex // Protects plugins map
subsonicRouter atomic.Pointer[SubsonicRouter] // Subsonic API router
schedulerService *schedulerService // Service for handling scheduled tasks
websocketService *websocketService // Service for handling WebSocket connections
@@ -99,18 +108,21 @@ type Manager struct {
metrics metrics.Metrics
}
-// GetManager returns the singleton instance of Manager
-func GetManager(ds model.DataStore, metrics metrics.Metrics) *Manager {
- return singleton.GetInstance(func() *Manager {
+// GetManager returns the singleton instance of managerImpl
+func GetManager(ds model.DataStore, metrics metrics.Metrics) Manager {
+ if !conf.Server.Plugins.Enabled {
+ return &noopManager{}
+ }
+ return singleton.GetInstance(func() *managerImpl {
return createManager(ds, metrics)
})
}
-// createManager creates a new Manager instance. Used in tests
-func createManager(ds model.DataStore, metrics metrics.Metrics) *Manager {
- m := &Manager{
+// createManager creates a new managerImpl instance. Used in tests
+func createManager(ds model.DataStore, metrics metrics.Metrics) *managerImpl {
+ m := &managerImpl{
plugins: make(map[string]*plugin),
- lifecycle: newPluginLifecycleManager(),
+ lifecycle: newPluginLifecycleManager(metrics),
ds: ds,
metrics: metrics,
}
@@ -122,14 +134,14 @@ func createManager(ds model.DataStore, metrics metrics.Metrics) *Manager {
return m
}
-// SetSubsonicRouter sets the SubsonicRouter after Manager initialization
-func (m *Manager) SetSubsonicRouter(router SubsonicRouter) {
+// SetSubsonicRouter sets the SubsonicRouter after managerImpl initialization
+func (m *managerImpl) SetSubsonicRouter(router SubsonicRouter) {
m.subsonicRouter.Store(&router)
}
// registerPlugin adds a plugin to the registry with the given parameters
// Used internally by ScanPlugins to register plugins
-func (m *Manager) registerPlugin(pluginID, pluginDir, wasmPath string, manifest *schema.PluginManifest) *plugin {
+func (m *managerImpl) registerPlugin(pluginID, pluginDir, wasmPath string, manifest *schema.PluginManifest) *plugin {
// Create custom runtime function
customRuntime := m.createRuntime(pluginID, manifest.Permissions)
@@ -154,16 +166,8 @@ func (m *Manager) registerPlugin(pluginID, pluginDir, wasmPath string, manifest
compilationReady: make(chan struct{}),
}
- // Start pre-compilation of WASM module in background
- go func() {
- precompilePlugin(p)
- // Check if this plugin implements InitService and hasn't been initialized yet
- m.initializePluginIfNeeded(p)
- }()
-
- // Register the plugin
- m.mu.Lock()
- defer m.mu.Unlock()
+ // Register the plugin first
+ m.pluginsMu.Lock()
m.plugins[pluginID] = p
// Register one plugin adapter for each capability
@@ -184,35 +188,57 @@ func (m *Manager) registerPlugin(pluginID, pluginDir, wasmPath string, manifest
}
m.adapters[pluginID+"_"+capabilityStr] = adapter
}
+ m.pluginsMu.Unlock()
log.Info("Discovered plugin", "folder", pluginID, "name", manifest.Name, "capabilities", manifest.Capabilities, "wasm", wasmPath, "dev_mode", isSymlink)
return m.plugins[pluginID]
}
// initializePluginIfNeeded calls OnInit on plugins that implement LifecycleManagement
-func (m *Manager) initializePluginIfNeeded(plugin *plugin) {
+func (m *managerImpl) initializePluginIfNeeded(plugin *plugin) {
// Skip if already initialized
if m.lifecycle.isInitialized(plugin) {
return
}
// Check if the plugin implements LifecycleManagement
- for _, capability := range plugin.Manifest.Capabilities {
- if capability == CapabilityLifecycleManagement {
- m.lifecycle.callOnInit(plugin)
- m.lifecycle.markInitialized(plugin)
- break
+ if slices.Contains(plugin.Manifest.Capabilities, CapabilityLifecycleManagement) {
+ if err := m.lifecycle.callOnInit(plugin); err != nil {
+ m.unregisterPlugin(plugin.ID)
}
}
}
+// unregisterPlugin removes a plugin from the manager
+func (m *managerImpl) unregisterPlugin(pluginID string) {
+ m.pluginsMu.Lock()
+ defer m.pluginsMu.Unlock()
+
+ plugin, ok := m.plugins[pluginID]
+ if !ok {
+ return
+ }
+
+ // Clear initialization state from lifecycle manager
+ m.lifecycle.clearInitialized(plugin)
+
+ // Unregister plugin adapters
+ for _, capability := range plugin.Manifest.Capabilities {
+ delete(m.adapters, pluginID+"_"+string(capability))
+ }
+
+ // Unregister plugin
+ delete(m.plugins, pluginID)
+ log.Info("Unregistered plugin", "plugin", pluginID)
+}
+
// ScanPlugins scans the plugins directory, discovers all valid plugins, and registers them for use.
-func (m *Manager) ScanPlugins() {
+func (m *managerImpl) ScanPlugins() {
// Clear existing plugins
- m.mu.Lock()
+ m.pluginsMu.Lock()
m.plugins = make(map[string]*plugin)
m.adapters = make(map[string]WasmPlugin)
- m.mu.Unlock()
+ m.pluginsMu.Unlock()
// Get plugins directory from config
root := conf.Server.Plugins.Folder
@@ -229,6 +255,7 @@ func (m *Manager) ScanPlugins() {
discoveries := DiscoverPlugins(root)
var validPluginNames []string
+ var registeredPlugins []*plugin
for _, discovery := range discoveries {
if discovery.Error != nil {
// Handle global errors (like directory read failure)
@@ -252,16 +279,43 @@ func (m *Manager) ScanPlugins() {
validPluginNames = append(validPluginNames, discovery.ID)
// Register the plugin
- m.registerPlugin(discovery.ID, discovery.Path, discovery.WasmPath, discovery.Manifest)
+ plugin := m.registerPlugin(discovery.ID, discovery.Path, discovery.WasmPath, discovery.Manifest)
+ if plugin != nil {
+ registeredPlugins = append(registeredPlugins, plugin)
+ }
+ }
+
+ // Start background processing for all registered plugins after registration is complete
+ // This avoids race conditions between registration and goroutines that might unregister plugins
+ for _, p := range registeredPlugins {
+ go func(plugin *plugin) {
+ precompilePlugin(plugin)
+ // Check if this plugin implements InitService and hasn't been initialized yet
+ m.initializePluginIfNeeded(plugin)
+ }(p)
}
log.Debug("Found valid plugins", "count", len(validPluginNames), "plugins", validPluginNames)
}
+// PluginList returns a map of all registered plugins with their manifests
+func (m *managerImpl) PluginList() map[string]schema.PluginManifest {
+ m.pluginsMu.RLock()
+ defer m.pluginsMu.RUnlock()
+
+ // Create a map to hold the plugin manifests
+ pluginList := make(map[string]schema.PluginManifest, len(m.plugins))
+ for name, plugin := range m.plugins {
+ // Use the plugin ID as the key and the manifest as the value
+ pluginList[name] = *plugin.Manifest
+ }
+ return pluginList
+}
+
// PluginNames returns the folder names of all plugins that implement the specified capability
-func (m *Manager) PluginNames(capability string) []string {
- m.mu.RLock()
- defer m.mu.RUnlock()
+func (m *managerImpl) PluginNames(capability string) []string {
+ m.pluginsMu.RLock()
+ defer m.pluginsMu.RUnlock()
var names []string
for name, plugin := range m.plugins {
@@ -275,28 +329,26 @@ func (m *Manager) PluginNames(capability string) []string {
return names
}
-func (m *Manager) getPlugin(name string, capability string) (*plugin, WasmPlugin) {
- m.mu.RLock()
- defer m.mu.RUnlock()
+func (m *managerImpl) getPlugin(name string, capability string) (*plugin, WasmPlugin, error) {
+ m.pluginsMu.RLock()
+ defer m.pluginsMu.RUnlock()
info, infoOk := m.plugins[name]
adapter, adapterOk := m.adapters[name+"_"+capability]
if !infoOk {
- log.Warn("Plugin not found", "name", name)
- return nil, nil
+ return nil, nil, fmt.Errorf("plugin not registered: %s", name)
}
if !adapterOk {
- log.Warn("Plugin adapter not found", "name", name, "capability", capability)
- return nil, nil
+ return nil, nil, fmt.Errorf("plugin adapter not registered: %s, capability: %s", name, capability)
}
- return info, adapter
+ return info, adapter, nil
}
// LoadPlugin instantiates and returns a plugin by folder name
-func (m *Manager) LoadPlugin(name string, capability string) WasmPlugin {
- info, adapter := m.getPlugin(name, capability)
- if info == nil {
- log.Warn("Plugin not found", "name", name, "capability", capability)
+func (m *managerImpl) LoadPlugin(name string, capability string) WasmPlugin {
+ info, adapter, err := m.getPlugin(name, capability)
+ if err != nil {
+ log.Warn("Error loading plugin", err)
return nil
}
@@ -318,10 +370,10 @@ func (m *Manager) LoadPlugin(name string, capability string) WasmPlugin {
// EnsureCompiled waits for a plugin to finish compilation and returns any compilation error.
// This is useful when you need to wait for compilation without loading a specific capability,
// such as during plugin refresh operations or health checks.
-func (m *Manager) EnsureCompiled(name string) error {
- m.mu.RLock()
+func (m *managerImpl) EnsureCompiled(name string) error {
+ m.pluginsMu.RLock()
plugin, ok := m.plugins[name]
- m.mu.RUnlock()
+ m.pluginsMu.RUnlock()
if !ok {
return fmt.Errorf("plugin not found: %s", name)
@@ -330,25 +382,8 @@ func (m *Manager) EnsureCompiled(name string) error {
return plugin.waitForCompilation()
}
-// LoadAllPlugins instantiates and returns all plugins that implement the specified capability
-func (m *Manager) LoadAllPlugins(capability string) []WasmPlugin {
- names := m.PluginNames(capability)
- if len(names) == 0 {
- return nil
- }
-
- var plugins []WasmPlugin
- for _, name := range names {
- plugin := m.LoadPlugin(name, capability)
- if plugin != nil {
- plugins = append(plugins, plugin)
- }
- }
- return plugins
-}
-
// LoadMediaAgent instantiates and returns a media agent plugin by folder name
-func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
+func (m *managerImpl) LoadMediaAgent(name string) (agents.Interface, bool) {
plugin := m.LoadPlugin(name, CapabilityMetadataAgent)
if plugin == nil {
return nil, false
@@ -357,17 +392,8 @@ func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
return agent, ok
}
-// LoadAllMediaAgents instantiates and returns all media agent plugins
-func (m *Manager) LoadAllMediaAgents() []agents.Interface {
- plugins := m.LoadAllPlugins(CapabilityMetadataAgent)
-
- return slice.Map(plugins, func(p WasmPlugin) agents.Interface {
- return p.(agents.Interface)
- })
-}
-
// LoadScrobbler instantiates and returns a scrobbler plugin by folder name
-func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
+func (m *managerImpl) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
plugin := m.LoadPlugin(name, CapabilityScrobbler)
if plugin == nil {
return nil, false
@@ -376,11 +402,20 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
return s, ok
}
-// LoadAllScrobblers instantiates and returns all scrobbler plugins
-func (m *Manager) LoadAllScrobblers() []scrobbler.Scrobbler {
- plugins := m.LoadAllPlugins(CapabilityScrobbler)
+type noopManager struct{}
- return slice.Map(plugins, func(p WasmPlugin) scrobbler.Scrobbler {
- return p.(scrobbler.Scrobbler)
- })
-}
+func (n noopManager) SetSubsonicRouter(router SubsonicRouter) {}
+
+func (n noopManager) EnsureCompiled(name string) error { return nil }
+
+func (n noopManager) PluginList() map[string]schema.PluginManifest { return nil }
+
+func (n noopManager) PluginNames(capability string) []string { return nil }
+
+func (n noopManager) LoadPlugin(name string, capability string) WasmPlugin { return nil }
+
+func (n noopManager) LoadMediaAgent(name string) (agents.Interface, bool) { return nil, false }
+
+func (n noopManager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { return nil, false }
+
+func (n noopManager) ScanPlugins() {}
diff --git a/plugins/manager_test.go b/plugins/manager_test.go
index 55a3b8f72..8b361f8b3 100644
--- a/plugins/manager_test.go
+++ b/plugins/manager_test.go
@@ -4,15 +4,18 @@ import (
"context"
"os"
"path/filepath"
+ "time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
+ "github.com/navidrome/navidrome/core/metrics"
+ "github.com/navidrome/navidrome/plugins/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Plugin Manager", func() {
- var mgr *Manager
+ var mgr *managerImpl
var ctx context.Context
BeforeEach(func() {
@@ -20,15 +23,28 @@ var _ = Describe("Plugin Manager", func() {
// but, as this is an integration test, we can't use configtest.SetupConfig() as it causes
// data races.
originalPluginsFolder := conf.Server.Plugins.Folder
+ originalTimeout := conf.Server.DevPluginCompilationTimeout
+ conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
DeferCleanup(func() {
conf.Server.Plugins.Folder = originalPluginsFolder
+ conf.Server.DevPluginCompilationTimeout = originalTimeout
})
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = testDataDir
ctx = GinkgoT().Context()
- mgr = createManager(nil, nil)
+ mgr = createManager(nil, metrics.NewNoopInstance())
mgr.ScanPlugins()
+
+ // Wait for all plugins to compile to avoid race conditions
+ err := mgr.EnsureCompiled("fake_artist_agent")
+ Expect(err).NotTo(HaveOccurred(), "fake_artist_agent should compile successfully")
+ err = mgr.EnsureCompiled("fake_album_agent")
+ Expect(err).NotTo(HaveOccurred(), "fake_album_agent should compile successfully")
+ err = mgr.EnsureCompiled("multi_plugin")
+ Expect(err).NotTo(HaveOccurred(), "multi_plugin should compile successfully")
+ err = mgr.EnsureCompiled("unauthorized_plugin")
+ Expect(err).NotTo(HaveOccurred(), "unauthorized_plugin should compile successfully")
})
It("should scan and discover plugins from the testdata folder", func() {
@@ -36,17 +52,28 @@ var _ = Describe("Plugin Manager", func() {
mediaAgentNames := mgr.PluginNames("MetadataAgent")
Expect(mediaAgentNames).To(HaveLen(4))
- Expect(mediaAgentNames).To(ContainElement("fake_artist_agent"))
- Expect(mediaAgentNames).To(ContainElement("fake_album_agent"))
- Expect(mediaAgentNames).To(ContainElement("multi_plugin"))
- Expect(mediaAgentNames).To(ContainElement("unauthorized_plugin"))
+ Expect(mediaAgentNames).To(ContainElements(
+ "fake_artist_agent",
+ "fake_album_agent",
+ "multi_plugin",
+ "unauthorized_plugin",
+ ))
scrobblerNames := mgr.PluginNames("Scrobbler")
Expect(scrobblerNames).To(ContainElement("fake_scrobbler"))
initServiceNames := mgr.PluginNames("LifecycleManagement")
- Expect(initServiceNames).To(ContainElement("multi_plugin"))
- Expect(initServiceNames).To(ContainElement("fake_init_service"))
+ Expect(initServiceNames).To(ContainElements("multi_plugin", "fake_init_service"))
+
+ schedulerCallbackNames := mgr.PluginNames("SchedulerCallback")
+ Expect(schedulerCallbackNames).To(ContainElement("multi_plugin"))
+ })
+
+ It("should load all plugins from folder", func() {
+ all := mgr.PluginList()
+ Expect(all).To(HaveLen(6))
+ Expect(all["fake_artist_agent"].Name).To(Equal("fake_artist_agent"))
+ Expect(all["unauthorized_plugin"].Capabilities).To(HaveExactElements(schema.PluginManifestCapabilitiesElem("MetadataAgent")))
})
It("should load a MetadataAgent plugin and invoke artist-related methods", func() {
@@ -65,18 +92,23 @@ var _ = Describe("Plugin Manager", func() {
})
It("should load all MetadataAgent plugins", func() {
- agents := mgr.LoadAllMediaAgents()
- Expect(agents).To(HaveLen(4))
- var names []string
- for _, a := range agents {
- names = append(names, a.AgentName())
+ mediaAgentNames := mgr.PluginNames("MetadataAgent")
+ Expect(mediaAgentNames).To(HaveLen(4))
+
+ var agentNames []string
+ for _, name := range mediaAgentNames {
+ agent, ok := mgr.LoadMediaAgent(name)
+ if ok {
+ agentNames = append(agentNames, agent.AgentName())
+ }
}
- Expect(names).To(ContainElements("fake_artist_agent", "fake_album_agent", "multi_plugin", "unauthorized_plugin"))
+
+ Expect(agentNames).To(ContainElements("fake_artist_agent", "fake_album_agent", "multi_plugin", "unauthorized_plugin"))
})
Describe("ScanPlugins", func() {
var tempPluginsDir string
- var m *Manager
+ var m *managerImpl
BeforeEach(func() {
tempPluginsDir, _ = os.MkdirTemp("", "navidrome-plugins-test-*")
@@ -85,7 +117,7 @@ var _ = Describe("Plugin Manager", func() {
})
conf.Server.Plugins.Folder = tempPluginsDir
- m = createManager(nil, nil)
+ m = createManager(nil, metrics.NewNoopInstance())
})
// Helper to create a complete valid plugin for manager testing
@@ -193,21 +225,8 @@ var _ = Describe("Plugin Manager", func() {
Describe("Invoke Methods", func() {
It("should load all MetadataAgent plugins and invoke methods", func() {
- mediaAgentNames := mgr.PluginNames("MetadataAgent")
- Expect(mediaAgentNames).NotTo(BeEmpty())
-
- plugins := mgr.LoadAllPlugins("MetadataAgent")
- Expect(plugins).To(HaveLen(len(mediaAgentNames)))
-
- var fakeAlbumPlugin agents.Interface
- for _, p := range plugins {
- if agent, ok := p.(agents.Interface); ok {
- if agent.AgentName() == "fake_album_agent" {
- fakeAlbumPlugin = agent
- break
- }
- }
- }
+ fakeAlbumPlugin, isMediaAgent := mgr.LoadMediaAgent("fake_album_agent")
+ Expect(isMediaAgent).To(BeTrue())
Expect(fakeAlbumPlugin).NotTo(BeNil(), "fake_album_agent should be loaded")
@@ -254,4 +273,95 @@ var _ = Describe("Plugin Manager", func() {
}
})
})
+
+ Describe("Plugin Initialization Lifecycle", func() {
+ BeforeEach(func() {
+ conf.Server.Plugins.Enabled = true
+ conf.Server.Plugins.Folder = testDataDir
+ })
+
+ Context("when OnInit is successful", func() {
+ It("should register and initialize the plugin", func() {
+ conf.Server.PluginConfig = nil
+ mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config
+ mgr.ScanPlugins()
+
+ plugin := mgr.plugins["fake_init_service"]
+ Expect(plugin).NotTo(BeNil())
+
+ Eventually(func() bool {
+ return mgr.lifecycle.isInitialized(plugin)
+ }).Should(BeTrue())
+
+ // Check that the plugin is still registered
+ names := mgr.PluginNames(CapabilityLifecycleManagement)
+ Expect(names).To(ContainElement("fake_init_service"))
+ })
+ })
+
+ Context("when OnInit fails", func() {
+ It("should unregister the plugin if OnInit returns an error string", func() {
+ conf.Server.PluginConfig = map[string]map[string]string{
+ "fake_init_service": {
+ "returnError": "response_error",
+ },
+ }
+ mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config
+ mgr.ScanPlugins()
+
+ Eventually(func() []string {
+ return mgr.PluginNames(CapabilityLifecycleManagement)
+ }).ShouldNot(ContainElement("fake_init_service"))
+ })
+
+ It("should unregister the plugin if OnInit returns a Go error", func() {
+ conf.Server.PluginConfig = map[string]map[string]string{
+ "fake_init_service": {
+ "returnError": "go_error",
+ },
+ }
+ mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config
+ mgr.ScanPlugins()
+
+ Eventually(func() []string {
+ return mgr.PluginNames(CapabilityLifecycleManagement)
+ }).ShouldNot(ContainElement("fake_init_service"))
+ })
+ })
+
+ It("should clear lifecycle state when unregistering a plugin", func() {
+ // Create a manager and register a plugin
+ mgr := createManager(nil, metrics.NewNoopInstance())
+
+ // Create a mock plugin with LifecycleManagement capability
+ plugin := &plugin{
+ ID: "test-plugin",
+ Capabilities: []string{CapabilityLifecycleManagement},
+ Manifest: &schema.PluginManifest{
+ Version: "1.0.0",
+ },
+ }
+
+ // Register the plugin in the manager
+ mgr.pluginsMu.Lock()
+ mgr.plugins[plugin.ID] = plugin
+ mgr.pluginsMu.Unlock()
+
+ // Mark the plugin as initialized in the lifecycle manager
+ mgr.lifecycle.markInitialized(plugin)
+ Expect(mgr.lifecycle.isInitialized(plugin)).To(BeTrue())
+
+ // Unregister the plugin
+ mgr.unregisterPlugin(plugin.ID)
+
+ // Verify that the plugin is no longer in the manager
+ mgr.pluginsMu.RLock()
+ _, exists := mgr.plugins[plugin.ID]
+ mgr.pluginsMu.RUnlock()
+ Expect(exists).To(BeFalse())
+
+ // Verify that the lifecycle state has been cleared
+ Expect(mgr.lifecycle.isInitialized(plugin)).To(BeFalse())
+ })
+ })
})
diff --git a/plugins/manifest_permissions_test.go b/plugins/manifest_permissions_test.go
index da221eb56..7a3df5f2d 100644
--- a/plugins/manifest_permissions_test.go
+++ b/plugins/manifest_permissions_test.go
@@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/plugins/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -47,7 +48,7 @@ func createTestPlugin(tempDir, name string, permissions schema.PluginManifestPer
var _ = Describe("Plugin Permissions", func() {
var (
- mgr *Manager
+ mgr *managerImpl
tempDir string
ctx context.Context
)
@@ -55,7 +56,7 @@ var _ = Describe("Plugin Permissions", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = context.Background()
- mgr = createManager(nil, nil)
+ mgr = createManager(nil, metrics.NewNoopInstance())
tempDir = GinkgoT().TempDir()
})
diff --git a/plugins/plugin_lifecycle_manager.go b/plugins/plugin_lifecycle_manager.go
index 7df0921d8..e00e7e5f3 100644
--- a/plugins/plugin_lifecycle_manager.go
+++ b/plugins/plugin_lifecycle_manager.go
@@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/api"
)
@@ -16,13 +17,15 @@ import (
type pluginLifecycleManager struct {
plugins sync.Map // string -> bool
config map[string]map[string]string
+ metrics metrics.Metrics
}
// newPluginLifecycleManager creates a new plugin lifecycle manager
-func newPluginLifecycleManager() *pluginLifecycleManager {
+func newPluginLifecycleManager(metrics metrics.Metrics) *pluginLifecycleManager {
config := maps.Clone(conf.Server.PluginConfig)
return &pluginLifecycleManager{
- config: config,
+ config: config,
+ metrics: metrics,
}
}
@@ -39,8 +42,14 @@ func (m *pluginLifecycleManager) markInitialized(plugin *plugin) {
m.plugins.Store(key, true)
}
+// clearInitialized removes the initialization state of a plugin
+func (m *pluginLifecycleManager) clearInitialized(plugin *plugin) {
+ key := plugin.ID + consts.Zwsp + plugin.Manifest.Version
+ m.plugins.Delete(key)
+}
+
// callOnInit calls the OnInit method on a plugin that implements LifecycleManagement
-func (m *pluginLifecycleManager) callOnInit(plugin *plugin) {
+func (m *pluginLifecycleManager) callOnInit(plugin *plugin) error {
ctx := context.Background()
log.Debug("Initializing plugin", "name", plugin.ID)
start := time.Now()
@@ -49,13 +58,13 @@ func (m *pluginLifecycleManager) callOnInit(plugin *plugin) {
loader, err := api.NewLifecycleManagementPlugin(ctx, api.WazeroRuntime(plugin.Runtime), api.WazeroModuleConfig(plugin.ModConfig))
if loader == nil || err != nil {
log.Error("Error creating LifecycleManagement plugin", "plugin", plugin.ID, err)
- return
+ return err
}
initPlugin, err := loader.Load(ctx, plugin.WasmPath)
if err != nil {
log.Error("Error loading LifecycleManagement plugin", "plugin", plugin.ID, "path", plugin.WasmPath, err)
- return
+ return err
}
defer initPlugin.Close(ctx)
@@ -71,16 +80,16 @@ func (m *pluginLifecycleManager) callOnInit(plugin *plugin) {
}
// Call OnInit
- resp, err := initPlugin.OnInit(ctx, req)
+ callStart := time.Now()
+ _, err = checkErr(initPlugin.OnInit(ctx, req))
+ m.metrics.RecordPluginRequest(ctx, plugin.ID, "OnInit", err == nil, time.Since(callStart).Milliseconds())
if err != nil {
log.Error("Error initializing plugin", "plugin", plugin.ID, "elapsed", time.Since(start), err)
- return
- }
-
- if resp.Error != "" {
- log.Error("Plugin reported error during initialization", "plugin", plugin.ID, "error", resp.Error)
- return
+ return err
}
+ // Mark the plugin as initialized
+ m.markInitialized(plugin)
log.Debug("Plugin initialized successfully", "plugin", plugin.ID, "elapsed", time.Since(start))
+ return nil
}
diff --git a/plugins/plugin_lifecycle_manager_test.go b/plugins/plugin_lifecycle_manager_test.go
index c0621b2a7..800630ce9 100644
--- a/plugins/plugin_lifecycle_manager_test.go
+++ b/plugins/plugin_lifecycle_manager_test.go
@@ -2,6 +2,7 @@ package plugins
import (
"github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/plugins/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -22,7 +23,7 @@ var _ = Describe("LifecycleManagement", func() {
var lifecycleManager *pluginLifecycleManager
BeforeEach(func() {
- lifecycleManager = newPluginLifecycleManager()
+ lifecycleManager = newPluginLifecycleManager(metrics.NewNoopInstance())
})
It("should track initialization state of plugins", func() {
@@ -140,5 +141,26 @@ var _ = Describe("LifecycleManagement", func() {
Expect(actualKey).To(Equal(expectedKey))
})
+
+ It("should clear initialization state when requested", func() {
+ plugin := &plugin{
+ ID: "test-plugin",
+ Capabilities: []string{CapabilityLifecycleManagement},
+ Manifest: &schema.PluginManifest{
+ Version: "1.0.0",
+ },
+ }
+
+ // Initially not initialized
+ Expect(lifecycleManager.isInitialized(plugin)).To(BeFalse())
+
+ // Mark as initialized
+ lifecycleManager.markInitialized(plugin)
+ Expect(lifecycleManager.isInitialized(plugin)).To(BeTrue())
+
+ // Clear initialization state
+ lifecycleManager.clearInitialized(plugin)
+ Expect(lifecycleManager.isInitialized(plugin)).To(BeFalse())
+ })
})
})
diff --git a/plugins/runtime.go b/plugins/runtime.go
index f68175efc..ee298e63d 100644
--- a/plugins/runtime.go
+++ b/plugins/runtime.go
@@ -41,7 +41,7 @@ var (
// createRuntime returns a function that creates a new wazero runtime and instantiates the required host functions
// based on the given plugin permissions
-func (m *Manager) createRuntime(pluginID string, permissions schema.PluginManifestPermissions) api.WazeroNewRuntime {
+func (m *managerImpl) createRuntime(pluginID string, permissions schema.PluginManifestPermissions) api.WazeroNewRuntime {
return func(ctx context.Context) (wazero.Runtime, error) {
// Check if runtime already exists
if rt, ok := runtimePool.Load(pluginID); ok {
@@ -70,7 +70,7 @@ func (m *Manager) createRuntime(pluginID string, permissions schema.PluginManife
}
// createCachingRuntime handles the complex logic of setting up a new cachingRuntime
-func (m *Manager) createCachingRuntime(ctx context.Context, pluginID string, permissions schema.PluginManifestPermissions) (*cachingRuntime, error) {
+func (m *managerImpl) createCachingRuntime(ctx context.Context, pluginID string, permissions schema.PluginManifestPermissions) (*cachingRuntime, error) {
// Get compilation cache
compCache, err := getCompilationCache()
if err != nil {
@@ -94,7 +94,7 @@ func (m *Manager) createCachingRuntime(ctx context.Context, pluginID string, per
}
// setupHostServices configures all the permitted host services for a plugin
-func (m *Manager) setupHostServices(ctx context.Context, r wazero.Runtime, pluginID string, permissions schema.PluginManifestPermissions) error {
+func (m *managerImpl) setupHostServices(ctx context.Context, r wazero.Runtime, pluginID string, permissions schema.PluginManifestPermissions) error {
// Define all available host services
type hostService struct {
name string
diff --git a/plugins/runtime_test.go b/plugins/runtime_test.go
index 32cd42118..05efe1d1d 100644
--- a/plugins/runtime_test.go
+++ b/plugins/runtime_test.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/plugins/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -34,13 +35,13 @@ var _ = Describe("Runtime", func() {
var _ = Describe("CachingRuntime", func() {
var (
ctx context.Context
- mgr *Manager
+ mgr *managerImpl
plugin *wasmScrobblerPlugin
)
BeforeEach(func() {
ctx = GinkgoT().Context()
- mgr = createManager(nil, nil)
+ mgr = createManager(nil, metrics.NewNoopInstance())
// Add permissions for the test plugin using typed struct
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
diff --git a/plugins/testdata/fake_init_service/plugin.go b/plugins/testdata/fake_init_service/plugin.go
index 5b279b09c..9e6171623 100644
--- a/plugins/testdata/fake_init_service/plugin.go
+++ b/plugins/testdata/fake_init_service/plugin.go
@@ -4,6 +4,7 @@ package main
import (
"context"
+ "errors"
"log"
"github.com/navidrome/navidrome/plugins/api"
@@ -13,6 +14,22 @@ type initServicePlugin struct{}
func (p *initServicePlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) {
log.Printf("OnInit called with %v", req)
+
+ // Check for specific error conditions in the config
+ if req.Config != nil {
+ if errorType, exists := req.Config["returnError"]; exists {
+ switch errorType {
+ case "go_error":
+ return nil, errors.New("initialization failed with Go error")
+ case "response_error":
+ return &api.InitResponse{
+ Error: "initialization failed with response error",
+ }, nil
+ }
+ }
+ }
+
+ // Default: successful initialization
return &api.InitResponse{}, nil
}
diff --git a/plugins/wasm_base_plugin.go b/plugins/wasm_base_plugin.go
deleted file mode 100644
index bc1f1d2f5..000000000
--- a/plugins/wasm_base_plugin.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package plugins
-
-import (
- "context"
- "errors"
- "fmt"
- "time"
-
- "github.com/navidrome/navidrome/core/agents"
- "github.com/navidrome/navidrome/core/metrics"
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model/id"
-)
-
-// newWasmBasePlugin creates a new instance of wasmBasePlugin with the required parameters.
-func newWasmBasePlugin[S any, P any](wasmPath, id, capability string, m metrics.Metrics, loader P, loadFunc loaderFunc[S, P]) *wasmBasePlugin[S, P] {
- return &wasmBasePlugin[S, P]{
- wasmPath: wasmPath,
- id: id,
- capability: capability,
- loader: loader,
- loadFunc: loadFunc,
- metrics: m,
- }
-}
-
-// LoaderFunc is a generic function type that loads a plugin instance.
-type loaderFunc[S any, P any] func(ctx context.Context, loader P, path string) (S, error)
-
-// wasmBasePlugin is a generic base implementation for WASM plugins.
-// S is the service interface type and P is the plugin loader type.
-type wasmBasePlugin[S any, P any] struct {
- wasmPath string
- id string
- capability string
- loader P
- loadFunc loaderFunc[S, P]
- metrics metrics.Metrics
-}
-
-func (w *wasmBasePlugin[S, P]) PluginID() string {
- return w.id
-}
-
-func (w *wasmBasePlugin[S, P]) Instantiate(ctx context.Context) (any, func(), error) {
- return w.getInstance(ctx, "")
-}
-
-func (w *wasmBasePlugin[S, P]) serviceName() string {
- return w.id + "_" + w.capability
-}
-
-func (w *wasmBasePlugin[S, P]) getMetrics() metrics.Metrics {
- return w.metrics
-}
-
-// getInstance loads a new plugin instance and returns a cleanup function.
-func (w *wasmBasePlugin[S, P]) getInstance(ctx context.Context, methodName string) (S, func(), error) {
- start := time.Now()
- // Add context metadata for tracing
- ctx = log.NewContext(ctx, "capability", w.serviceName(), "method", methodName)
-
- inst, err := w.loadFunc(ctx, w.loader, w.wasmPath)
- if err != nil {
- var zero S
- return zero, func() {}, fmt.Errorf("wasmBasePlugin: failed to load instance for %s: %w", w.serviceName(), err)
- }
- // Add context metadata for tracing
- ctx = log.NewContext(ctx, "instanceID", getInstanceID(inst))
- log.Trace(ctx, "wasmBasePlugin: loaded instance", "elapsed", time.Since(start))
- return inst, func() {
- log.Trace(ctx, "wasmBasePlugin: finished using instance", "elapsed", time.Since(start))
- if closer, ok := any(inst).(interface{ Close(context.Context) error }); ok {
- _ = closer.Close(ctx)
- }
- }, nil
-}
-
-type wasmPlugin[S any] interface {
- PluginID() string
- getInstance(ctx context.Context, methodName string) (S, func(), error)
- getMetrics() metrics.Metrics
-}
-
-type errorMapper interface {
- mapError(err error) error
-}
-
-func callMethod[S any, R any](ctx context.Context, w wasmPlugin[S], methodName string, fn func(inst S) (R, error)) (R, error) {
- // Add a unique call ID to the context for tracing
- ctx = log.NewContext(ctx, "callID", id.NewRandom())
-
- inst, done, err := w.getInstance(ctx, methodName)
- var r R
- if err != nil {
- return r, err
- }
- start := time.Now()
- defer done()
- r, err = fn(inst)
- elapsed := time.Since(start)
-
- if em, ok := any(w).(errorMapper); ok {
- mappedErr := em.mapError(err)
-
- if !errors.Is(mappedErr, agents.ErrNotFound) {
- id := w.PluginID()
- isOk := mappedErr == nil
- metrics := w.getMetrics()
- if metrics != nil {
- metrics.RecordPluginRequest(ctx, id, methodName, isOk, elapsed.Milliseconds())
- }
- log.Trace(ctx, "callMethod", "plugin", id, "method", methodName, "ok", isOk, elapsed)
- }
-
- return r, mappedErr
- }
- return r, err
-}
diff --git a/plugins/wasm_base_plugin_test.go b/plugins/wasm_base_plugin_test.go
deleted file mode 100644
index 6d6421598..000000000
--- a/plugins/wasm_base_plugin_test.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package plugins
-
-import (
- "context"
-
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-type nilInstance struct{}
-
-var _ = Describe("wasmBasePlugin", func() {
- var ctx = context.Background()
-
- It("should load instance using loadFunc", func() {
- called := false
- plugin := &wasmBasePlugin[*nilInstance, any]{
- wasmPath: "",
- id: "test",
- capability: "test",
- loadFunc: func(ctx context.Context, _ any, path string) (*nilInstance, error) {
- called = true
- return &nilInstance{}, nil
- },
- }
- inst, done, err := plugin.getInstance(ctx, "test")
- defer done()
- Expect(err).To(BeNil())
- Expect(inst).ToNot(BeNil())
- Expect(called).To(BeTrue())
- })
-})
diff --git a/release/goreleaser.yml b/release/goreleaser.yml
index f71c38f31..30c0d6f3b 100644
--- a/release/goreleaser.yml
+++ b/release/goreleaser.yml
@@ -83,6 +83,15 @@ nfpms:
owner: navidrome
group: navidrome
+ - src: release/linux/.package.rpm # contents: "rpm"
+ dst: /var/lib/navidrome/.package
+ type: "config|noreplace"
+ packager: rpm
+ - src: release/linux/.package.deb # contents: "deb"
+ dst: /var/lib/navidrome/.package
+ type: "config|noreplace"
+ packager: deb
+
scripts:
preinstall: "release/linux/preinstall.sh"
postinstall: "release/linux/postinstall.sh"
diff --git a/release/linux/.package.deb b/release/linux/.package.deb
new file mode 100644
index 000000000..811c85f42
--- /dev/null
+++ b/release/linux/.package.deb
@@ -0,0 +1 @@
+deb
\ No newline at end of file
diff --git a/release/linux/.package.rpm b/release/linux/.package.rpm
new file mode 100644
index 000000000..7c88ef3c0
--- /dev/null
+++ b/release/linux/.package.rpm
@@ -0,0 +1 @@
+rpm
\ No newline at end of file
diff --git a/release/linux/postinstall.sh b/release/linux/postinstall.sh
index 65f1d208d..f3d9c9277 100644
--- a/release/linux/postinstall.sh
+++ b/release/linux/postinstall.sh
@@ -4,7 +4,7 @@
# the package manager (in particular, deb) thinks that the file exists, while it is
# no longer on disk. Specifically, doing a `rm /etc/navidrome/navidrome.toml`
# without something like `apt purge navidrome` will result in the system believing that
-# the file still exists. In this case, during isntall it will NOT extract the configuration
+# the file still exists. In this case, during install it will NOT extract the configuration
# file (as to not override it). Since `navidrome service install` depends on this file existing,
# we will create it with the defaults anyway.
if [ ! -f /etc/navidrome/navidrome.toml ]; then
diff --git a/release/wix/build_msi.sh b/release/wix/build_msi.sh
index 9fc008446..7e595311e 100755
--- a/release/wix/build_msi.sh
+++ b/release/wix/build_msi.sh
@@ -49,6 +49,9 @@ cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.exe "$MSI_OUT
cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR"
cp "$BINARY" "$MSI_OUTPUT_DIR"
+# package type indicator file
+echo "msi" > "$MSI_OUTPUT_DIR/.package"
+
# workaround for wixl WixVariable not working to override bmp locations
cp "$WORKSPACE"/release/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp
cp "$WORKSPACE"/release/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp
diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs
index ec8b164e8..8ebba4632 100644
--- a/release/wix/navidrome.wxs
+++ b/release/wix/navidrome.wxs
@@ -69,6 +69,12 @@
+
+
+
+
+
+
@@ -81,6 +87,7 @@
+
diff --git a/resources/i18n/bs.json b/resources/i18n/bs.json
new file mode 100644
index 000000000..9d5c552e7
--- /dev/null
+++ b/resources/i18n/bs.json
@@ -0,0 +1,628 @@
+{
+ "languageName": "Bosanski",
+ "resources": {
+ "song": {
+ "name": "Pjesma |||| Pjesme",
+ "fields": {
+ "albumArtist": "Izvođač albuma",
+ "duration": "Trajanje",
+ "trackNumber": "Pjesma #",
+ "playCount": "Reprodukcija",
+ "title": "Naslov",
+ "artist": "Izvođač",
+ "album": "Album",
+ "path": "Putanja datoteke",
+ "genre": "Žanr",
+ "compilation": "Kompilacija",
+ "year": "Godina",
+ "size": "Veličina datoteke",
+ "updatedAt": "Dodano",
+ "bitRate": "Brzina prijenosa",
+ "discSubtitle": "Podnaslov CD-a",
+ "starred": "Favorit",
+ "comment": "Komentar",
+ "rating": "Ocjena",
+ "quality": "Kvaliteta",
+ "bpm": "BPM",
+ "playDate": "Posljednja reprodukcija",
+ "channels": "Kanali",
+ "createdAt": "Dodano",
+ "grouping": "Grupisanje",
+ "mood": "Raspoloženje",
+ "participants": "Dodatni učesnici",
+ "tags": "Dodatne oznake",
+ "mappedTags": "Mapirane oznake",
+ "rawTags": "Sirovi podaci oznaka",
+ "bitDepth": "Dubina bita",
+ "sampleRate": "Uzorkovanje",
+ "missing": "Nedostaje",
+ "libraryName": "Biblioteka"
+ },
+ "actions": {
+ "addToQueue": "Reprodukcija kasnije",
+ "playNow": "Reprodukcija sada",
+ "addToPlaylist": "Dodaj u playlistu",
+ "shuffleAll": "Nasumična reprodukcija",
+ "download": "Preuzmi",
+ "playNext": "Reprodukcija sljedeće",
+ "info": "Više informacija",
+ "showInPlaylist": "Prikaži u playlisti"
+ }
+ },
+ "album": {
+ "name": "Album |||| Albumi",
+ "fields": {
+ "albumArtist": "Izvođač albuma",
+ "artist": "Izvođač",
+ "duration": "Trajanje",
+ "songCount": "Broj pjesama",
+ "playCount": "Reprodukcija",
+ "name": "Naziv",
+ "genre": "Žanr",
+ "compilation": "Kompilacija",
+ "year": "Godina",
+ "updatedAt": "Ažurirano",
+ "comment": "Komentar",
+ "rating": "Ocjena",
+ "createdAt": "Dodano",
+ "size": "Veličina",
+ "originalDate": "Originalni datum",
+ "releaseDate": "Datum izdanja",
+ "releases": "Izdanje |||| Izdanja",
+ "released": "Objavljeno",
+ "recordLabel": "Izdavač",
+ "catalogNum": "Kataloški broj",
+ "releaseType": "Tip",
+ "grouping": "Grupisanje",
+ "media": "Medij",
+ "mood": "Raspoloženje",
+ "date": "Datum snimanja",
+ "missing": "Nedostaje",
+ "libraryName": "Biblioteka"
+ },
+ "actions": {
+ "playAll": "Reprodukcija",
+ "playNext": "Reprodukcija sljedeće",
+ "addToQueue": "Dodaj u red",
+ "shuffle": "Nasumična reprodukcija",
+ "addToPlaylist": "Dodaj u playlistu",
+ "download": "Preuzmi",
+ "info": "Više informacija",
+ "share": "Podijeli"
+ },
+ "lists": {
+ "all": "Sve",
+ "random": "Nasumično",
+ "recentlyAdded": "Nedavno dodano",
+ "recentlyPlayed": "Nedavno reproducirano",
+ "mostPlayed": "Najviše reproducirano",
+ "starred": "Favoriti",
+ "topRated": "Najbolje ocijenjeno"
+ }
+ },
+ "artist": {
+ "name": "Izvođač |||| Izvođači",
+ "fields": {
+ "name": "Naziv",
+ "albumCount": "Broj albuma",
+ "songCount": "Broj pjesama",
+ "playCount": "Reprodukcija",
+ "rating": "Ocjena",
+ "genre": "Žanr",
+ "size": "Veličina",
+ "role": "Uloga",
+ "missing": "Nedostaje"
+ },
+ "roles": {
+ "albumartist": "Izvođač albuma |||| Izvođači albuma",
+ "artist": "Izvođač |||| Izvođači",
+ "composer": "Kompozitor |||| Kompozitori",
+ "conductor": "Dirigent |||| Dirigenti",
+ "lyricist": "Tekstopisac |||| Tekstopisci",
+ "arranger": "Aranžer |||| Aranžeri",
+ "producer": "Producent |||| Producenti",
+ "director": "Direktor |||| Direktori",
+ "engineer": "Inženjer |||| Inženjeri",
+ "mixer": "Mikser |||| Mikseri",
+ "remixer": "Remikser |||| Remikseri",
+ "djmixer": "DJ Mikser |||| DJ Mikseri",
+ "performer": "Izvođač |||| Izvođači",
+ "maincredit": "Izvođač albuma ili izvođač |||| Izvođači albuma ili izvođači"
+ },
+ "actions": {
+ "shuffle": "Nasumična reprodukcija",
+ "radio": "Radio",
+ "topSongs": "Najpopularnije pjesme"
+ }
+ },
+ "user": {
+ "name": "Korisnik |||| Korisnici",
+ "fields": {
+ "userName": "Korisničko ime",
+ "isAdmin": "Je admin",
+ "lastLoginAt": "Posljednja prijava",
+ "updatedAt": "Ažurirano",
+ "name": "Ime",
+ "password": "Lozinka",
+ "createdAt": "Kreirano",
+ "changePassword": "Promijeni lozinku?",
+ "currentPassword": "Trenutna lozinka",
+ "newPassword": "Nova lozinka",
+ "token": "Token",
+ "lastAccessAt": "Posljednji pristup",
+ "libraries": "Biblioteke"
+ },
+ "helperTexts": {
+ "name": "Promjena će biti aktivna nakon sljedeće prijave",
+ "libraries": "Odaberi specifične biblioteke za ovog korisnika ili ostavi prazno za standardne biblioteke"
+ },
+ "notifications": {
+ "created": "Korisnik kreiran",
+ "updated": "Korisnik ažuriran",
+ "deleted": "Korisnik obrisan"
+ },
+ "message": {
+ "listenBrainzToken": "Unesite svoj ListenBrainz korisnički token",
+ "clickHereForToken": "Kliknite ovdje za dobijanje tokena",
+ "selectAllLibraries": "Odaberi sve biblioteke",
+ "adminAutoLibraries": "Administratori automatski imaju pristup svim bibliotekama"
+ },
+ "validation": {
+ "librariesRequired": "Ne-administratori moraju imati barem jednu odabranu biblioteku"
+ }
+ },
+ "player": {
+ "name": "Player |||| Playeri",
+ "fields": {
+ "name": "Naziv",
+ "transcodingId": "ID transkodiranja",
+ "maxBitRate": "Maks. brzina prijenosa",
+ "client": "Klijent",
+ "userName": "Korisničko ime",
+ "lastSeen": "Posljednji put viđen",
+ "reportRealPath": "Prikaži stvarnu putanju",
+ "scrobbleEnabled": "Slanje podataka o reprodukciji (scrobbling)"
+ }
+ },
+ "transcoding": {
+ "name": "Transkodiranje |||| Transkodiranja",
+ "fields": {
+ "name": "Naziv",
+ "targetFormat": "Ciljani format",
+ "defaultBitRate": "Zadana brzina prijenosa",
+ "command": "Komanda"
+ }
+ },
+ "playlist": {
+ "name": "Playlista |||| Playliste",
+ "fields": {
+ "name": "Naziv",
+ "duration": "Trajanje",
+ "ownerName": "Vlasnik",
+ "public": "Javna",
+ "updatedAt": "Ažurirano",
+ "createdAt": "Kreirano",
+ "songCount": "Broj pjesama",
+ "comment": "Komentar",
+ "sync": "Auto-uvoz",
+ "path": "Uvezi iz"
+ },
+ "actions": {
+ "selectPlaylist": "Odaberi playlistu:",
+ "addNewPlaylist": "Kreiraj \"%{name}\"",
+ "export": "Izvezi",
+ "makePublic": "Učini javnom",
+ "makePrivate": "Učini privatnom",
+ "saveQueue": "Sačuvaj red čekanja u playlistu",
+ "searchOrCreate": "Pretraži playlistu ili kreiraj novu...",
+ "pressEnterToCreate": "Pritisni Enter za kreiranje nove playliste",
+ "removeFromSelection": "Ukloni iz odabira"
+ },
+ "message": {
+ "duplicate_song": "Dodaj duplikate",
+ "song_exist": "Neke pjesme su već u playlisti. Želiš li ih ipak dodati ili preskočiti?",
+ "noPlaylistsFound": "Nije pronađena nijedna playlista",
+ "noPlaylists": "Nema playlisti"
+ }
+ },
+ "radio": {
+ "name": "Radio |||| Radiji",
+ "fields": {
+ "name": "Naziv",
+ "streamUrl": "Stream URL",
+ "homePageUrl": "URL početne stranice",
+ "updatedAt": "Ažurirano",
+ "createdAt": "Dodano"
+ },
+ "actions": {
+ "playNow": "Reprodukcija sada"
+ }
+ },
+ "share": {
+ "name": "Dijeljenje |||| Dijeljenja",
+ "fields": {
+ "username": "Podijeljeno od strane",
+ "url": "URL",
+ "description": "Opis",
+ "contents": "Sadržaj",
+ "expiresAt": "Vrijedi do",
+ "lastVisitedAt": "Posljednja posjeta",
+ "visitCount": "Posjete",
+ "format": "Format",
+ "maxBitRate": "Maks. brzina prijenosa",
+ "updatedAt": "Ažurirano",
+ "createdAt": "Kreirano",
+ "downloadable": "Dozvoli preuzimanje?"
+ }
+ },
+ "missing": {
+ "name": "Nedostajuća datoteka |||| Nedostajuće datoteke",
+ "fields": {
+ "path": "Putanja",
+ "size": "Veličina",
+ "updatedAt": "Nedostaje od",
+ "libraryName": "Biblioteka"
+ },
+ "actions": {
+ "remove": "Ukloni",
+ "remove_all": "ukloni sve"
+ },
+ "notifications": {
+ "removed": "Nedostajuća(e) datoteka(e) uklonjena(e)"
+ },
+ "empty": "nema nedostajućih datoteka"
+ },
+ "library": {
+ "name": "Biblioteka |||| Biblioteke",
+ "fields": {
+ "name": "Naziv",
+ "path": "Putanja",
+ "remotePath": "Udaljena putanja",
+ "lastScanAt": "Posljednje skeniranje",
+ "songCount": "Pjesme",
+ "albumCount": "Albumi",
+ "artistCount": "Izvođači",
+ "totalSongs": "Pjesme",
+ "totalAlbums": "Albumi",
+ "totalArtists": "Izvođači",
+ "totalFolders": "Folderi",
+ "totalFiles": "Datoteke",
+ "totalMissingFiles": "Nedostajuće datoteke",
+ "totalSize": "Veličina",
+ "totalDuration": "Trajanje",
+ "defaultNewUsers": "Standardno za nove korisnike",
+ "createdAt": "Kreirano",
+ "updatedAt": "Ažurirano"
+ },
+ "sections": {
+ "basic": "Osnovne informacije",
+ "statistics": "Statistika"
+ },
+ "actions": {
+ "scan": "Skeniraj biblioteku",
+ "manageUsers": "Upravljaj pristupima",
+ "viewDetails": "Pogledaj detalje"
+ },
+ "notifications": {
+ "created": "Biblioteka uspješno kreirana",
+ "updated": "Biblioteka uspješno ažurirana",
+ "deleted": "Biblioteka uspješno obrisana",
+ "scanStarted": "Skeniranje biblioteke započeto",
+ "scanCompleted": "Skeniranje biblioteke završeno"
+ },
+ "validation": {
+ "nameRequired": "Naziv biblioteke je obavezan",
+ "pathRequired": "Putanja biblioteke je obavezna",
+ "pathNotDirectory": "Putanja biblioteke mora biti folder",
+ "pathNotFound": "Putanja biblioteke nije pronađena",
+ "pathNotAccessible": "Putanja biblioteke nije dostupna",
+ "pathInvalid": "Putanja biblioteke nije validna"
+ },
+ "messages": {
+ "deleteConfirm": "Da li zaista želiš obrisati ovu biblioteku? Pristup i podaci će biti uklonjeni.",
+ "scanInProgress": "Skeniranje biblioteke u toku...",
+ "noLibrariesAssigned": "Nema dodijeljenih biblioteka"
+ }
+ }
+ },
+ "ra": {
+ "auth": {
+ "welcome1": "Hvala što ste instalirali Navidrome!",
+ "welcome2": "Prvo kreirajte admin korisnika",
+ "confirmPassword": "Potvrdi lozinku",
+ "buttonCreateAdmin": "Kreiraj admina",
+ "auth_check_error": "Prijavite se da biste nastavili",
+ "user_menu": "Profil",
+ "username": "Korisničko ime",
+ "password": "Lozinka",
+ "sign_in": "Prijava",
+ "sign_in_error": "Greška pri prijavi",
+ "logout": "Odjava",
+ "insightsCollectionNote": "Navidrome prikuplja anonimne statistike \nda podrži razvoj projekta. \nKliknite [ovdje] za više informacija ili da isključite \"Insights\""
+ },
+ "validation": {
+ "invalidChars": "Koristite samo slova i brojeve",
+ "passwordDoesNotMatch": "Lozinke se ne podudaraju",
+ "required": "Obavezno",
+ "minLength": "Mora imati najmanje %{min} znakova",
+ "maxLength": "Mora imati najviše %{max} znakova",
+ "minValue": "Mora biti najmanje %{min}",
+ "maxValue": "Mora biti %{max} ili manje",
+ "number": "Mora biti broj",
+ "email": "Mora biti validna e-mail adresa",
+ "oneOf": "Mora biti jedan od: %{options}",
+ "regex": "Mora odgovarati regularnom izrazu: %{pattern}",
+ "unique": "Mora biti jedinstveno",
+ "url": "Mora biti validan URL"
+ },
+ "action": {
+ "add_filter": "Dodaj filter",
+ "add": "Dodaj",
+ "back": "Nazad",
+ "bulk_actions": "1 odabrana stavka |||| %{smart_count} odabrane stavke",
+ "cancel": "Otkaži",
+ "clear_input_value": "Obriši unos",
+ "clone": "Kloniraj",
+ "confirm": "Potvrdi",
+ "create": "Kreiraj",
+ "delete": "Obriši",
+ "edit": "Uredi",
+ "export": "Izvezi",
+ "list": "Lista",
+ "refresh": "Osvježi",
+ "remove_filter": "Ukloni filter",
+ "remove": "Ukloni",
+ "save": "Sačuvaj",
+ "search": "Pretraži",
+ "show": "Prikaži",
+ "sort": "Sortiraj",
+ "undo": "Poništi",
+ "expand": "Proširi",
+ "close": "Zatvori",
+ "open_menu": "Otvori meni",
+ "close_menu": "Zatvori meni",
+ "unselect": "Poništi odabir",
+ "skip": "Preskoči",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
+ "share": "Podijeli",
+ "download": "Preuzmi"
+ },
+ "boolean": {
+ "true": "Da",
+ "false": "Ne"
+ },
+ "page": {
+ "create": "Kreiraj %{name}",
+ "dashboard": "Kontrolna tabla",
+ "edit": "%{name} #%{id}",
+ "error": "Nešto je pošlo po zlu",
+ "list": "%{name}",
+ "loading": "Učitavanje",
+ "not_found": "Nije pronađeno",
+ "show": "%{name} #%{id}",
+ "empty": "Još nema %{name}.",
+ "invite": "Želiš li dodati jednu?"
+ },
+ "input": {
+ "file": {
+ "upload_several": "Povuci datoteke ovdje za prijenos ili klikni za odabir.",
+ "upload_single": "Povuci datoteku ovdje za prijenos ili klikni za odabir."
+ },
+ "image": {
+ "upload_several": "Povuci slike ovdje za prijenos ili klikni za odabir.",
+ "upload_single": "Povuci sliku ovdje za prijenos ili klikni za odabir."
+ },
+ "references": {
+ "all_missing": "Povezane reference nisu pronađene.",
+ "many_missing": "Neke povezane reference više nisu dostupne.",
+ "single_missing": "Povezana referenca više nije dostupna."
+ },
+ "password": {
+ "toggle_visible": "Sakrij lozinku",
+ "toggle_hidden": "Prikaži lozinku"
+ }
+ },
+ "message": {
+ "about": "O aplikaciji",
+ "are_you_sure": "Jesi li siguran?",
+ "bulk_delete_content": "Da li zaista želiš obrisati \"%{name}\"? |||| Da li zaista želiš obrisati %{smart_count} stavki?",
+ "bulk_delete_title": "Obriši %{name} |||| Obriši %{smart_count} %{name} stavki",
+ "delete_content": "Da li zaista želiš obrisati ovaj sadržaj?",
+ "delete_title": "Obriši %{name} #%{id}",
+ "details": "Detalji",
+ "error": "Došlo je do greške i zahtjev nije mogao biti završen.",
+ "invalid_form": "Formular nije validan. Provjeri unose.",
+ "loading": "Stranica se učitava",
+ "no": "Ne",
+ "not_found": "Stranica nije pronađena.",
+ "yes": "Da",
+ "unsaved_changes": "Neke promjene nisu sačuvane. Želiš li ih ignorisati?"
+ },
+ "navigation": {
+ "no_results": "Nema rezultata",
+ "no_more_results": "Stranica %{page} nema sadržaja.",
+ "page_out_of_boundaries": "Stranica %{page} je izvan opsega",
+ "page_out_from_end": "Posljednja stranica",
+ "page_out_from_begin": "Prva stranica",
+ "page_range_info": "%{offsetBegin}-%{offsetEnd} od %{total}",
+ "page_rows_per_page": "Redova po stranici:",
+ "next": "Sljedeća",
+ "prev": "Prethodna",
+ "skip_nav": "Preskoči na sadržaj"
+ },
+ "notification": {
+ "updated": "Stavka ažurirana |||| %{smart_count} stavki ažurirano",
+ "created": "Stavka kreirana",
+ "deleted": "Stavka obrisana |||| %{smart_count} stavki obrisano",
+ "bad_item": "Neispravna stavka",
+ "item_doesnt_exist": "Stavka ne postoji",
+ "http_error": "Greška u komunikaciji sa serverom",
+ "data_provider_error": "Greška u dataProvider-u. Provjeri konzolu za detalje.",
+ "i18n_error": "Prijevod za odabrani jezik nije dostupan",
+ "canceled": "Akcija otkazana",
+ "logged_out": "Sesija je istekla. Ponovo se prijavi.",
+ "new_version": "Nova verzija dostupna! Osveži stranicu."
+ },
+ "toggleFieldsMenu": {
+ "columnsToDisplay": "Odaberi kolone",
+ "layout": "Izgled",
+ "grid": "Mreža",
+ "table": "Tabela"
+ }
+ },
+ "message": {
+ "note": "NAPOMENA",
+ "transcodingDisabled": "Izmjena postavki transkodiranja preko web sučelja je onemogućena iz sigurnosnih razloga. Ako želiš promijeniti opcije transkodiranja (urediti ili dodati), ponovo pokreni server sa konfiguracijskom opcijom %{config}.",
+ "transcodingEnabled": "Navidrome trenutno radi sa %{config}, što omogućava izvršavanje sistemskih komandi kroz postavke transkodiranja preko web sučelja. Preporučujemo da ovo onemogućiš iz sigurnosnih razloga i koristiš samo prilikom konfiguracije transkodiranja.",
+ "songsAddedToPlaylist": "1 pjesma dodana u playlistu |||| %{smart_count} pjesme dodane u playlistu",
+ "noPlaylistsAvailable": "Nema dostupnih playlisti",
+ "delete_user_title": "Obriši korisnika '%{name}'",
+ "delete_user_content": "Da li zaista želiš obrisati ovog korisnika i sve njegove podatke (uključujući playliste i postavke)?",
+ "notifications_blocked": "Blokirali ste obavijesti za ovu stranicu u postavkama preglednika",
+ "notifications_not_available": "Ovaj preglednik ne podržava desktop obavijesti",
+ "lastfmLinkSuccess": "Last.fm veza uspostavljena i scrobbling omogućen",
+ "lastfmLinkFailure": "Last.fm veza nije uspjela",
+ "lastfmUnlinkSuccess": "Last.fm veza uklonjena i scrobbling onemogućen",
+ "lastfmUnlinkFailure": "Last.fm veza nije uklonjena",
+ "openIn": {
+ "lastfm": "Prikaži na Last.fm",
+ "musicbrainz": "Prikaži na MusicBrainz"
+ },
+ "lastfmLink": "Pročitaj više",
+ "listenBrainzLinkSuccess": "ListenBrainz veza uspostavljena i scrobbling omogućen kao korisnik: %{user}",
+ "listenBrainzLinkFailure": "ListenBrainz veza nije uspjela: %{error}",
+ "listenBrainzUnlinkSuccess": "ListenBrainz veza uklonjena i scrobbling onemogućen",
+ "listenBrainzUnlinkFailure": "ListenBrainz veza nije uklonjena",
+ "downloadOriginalFormat": "Preuzmi u originalnom formatu",
+ "shareOriginalFormat": "Podijeli u originalnom formatu",
+ "shareDialogTitle": "Podijeli %{resource} '%{name}'",
+ "shareBatchDialogTitle": "Podijeli 1 %{resource} |||| Podijeli %{smart_count} %{resource}",
+ "shareSuccess": "URL kopiran u međuspremnik: %{url}",
+ "shareFailure": "Greška pri kopiranju URL-a %{url} u međuspremnik",
+ "downloadDialogTitle": "Preuzmi %{resource} '%{name}' (%{size})",
+ "shareCopyToClipboard": "Kopiraj u međuspremnik: Ctrl+C, Enter",
+ "remove_missing_title": "Ukloni nedostajuće datoteke",
+ "remove_missing_content": "Da li zaista želiš ukloniti odabrane nedostajuće datoteke iz baze podataka? Sve reference na datoteke (broj reprodukcija, ocjene) bit će trajno obrisane.",
+ "remove_all_missing_title": "Ukloni sve nedostajuće datoteke",
+ "remove_all_missing_content": "Da li zaista želiš ukloniti sve nedostajuće datoteke iz baze podataka? Sve reference na datoteke (broj reprodukcija, ocjene) bit će trajno obrisane.",
+ "noSimilarSongsFound": "Nema sličnih pjesama",
+ "noTopSongsFound": "Nema popularnih pjesama"
+ },
+ "menu": {
+ "library": "Biblioteka",
+ "settings": "Postavke",
+ "version": "Verzija",
+ "theme": "Tema",
+ "personal": {
+ "name": "Lično",
+ "options": {
+ "theme": "Tema",
+ "language": "Jezik",
+ "defaultView": "Zadani pregled",
+ "desktop_notifications": "Desktop obavijesti",
+ "lastfmScrobbling": "Last.fm scrobbling",
+ "listenBrainzScrobbling": "ListenBrainz scrobbling",
+ "replaygain": "ReplayGain mod",
+ "preAmp": "ReplayGain pojačanje (dB)",
+ "gain": {
+ "none": "Isključeno",
+ "album": "Koristi album gain",
+ "track": "Koristi pjesmu gain"
+ },
+ "lastfmNotConfigured": "Last.fm API ključ nije konfiguriran"
+ }
+ },
+ "albumList": "Albumi",
+ "about": "O aplikaciji",
+ "playlists": "Playliste",
+ "sharedPlaylists": "Dijeljene playliste",
+ "librarySelector": {
+ "allLibraries": "Sve biblioteke (%{count})",
+ "multipleLibraries": "%{selected} od %{total} biblioteka",
+ "selectLibraries": "Odaberi biblioteke",
+ "none": "Nijedna"
+ }
+ },
+ "player": {
+ "playListsText": "Reprodukcija reda čekanja",
+ "openText": "Otvori",
+ "closeText": "Zatvori",
+ "notContentText": "Nema muzike",
+ "clickToPlayText": "Klikni za reprodukciju",
+ "clickToPauseText": "Klikni za pauzu",
+ "nextTrackText": "Sljedeća pjesma",
+ "previousTrackText": "Prethodna pjesma",
+ "reloadText": "Ponovo učitaj",
+ "volumeText": "Glasnoća",
+ "toggleLyricText": "Prikaži/sakrij tekst",
+ "toggleMiniModeText": "Minimiziraj",
+ "destroyText": "Uništi",
+ "downloadText": "Preuzmi",
+ "removeAudioListsText": "Ukloni audio liste",
+ "clickToDeleteText": "Klikni za brisanje %{name}",
+ "emptyLyricText": "Nema teksta",
+ "playModeText": {
+ "order": "Redom",
+ "orderLoop": "Ponavljaj",
+ "singleLoop": "Ponavljaj jednu",
+ "shufflePlay": "Nasumična reprodukcija"
+ }
+ },
+ "about": {
+ "links": {
+ "homepage": "Početna stranica",
+ "source": "Izvorni kod",
+ "featureRequests": "Zahtjevi za funkcijama",
+ "lastInsightsCollection": "Posljednje prikupljanje \"Insights\"",
+ "insights": {
+ "disabled": "Isključeno",
+ "waiting": "Čekanje"
+ }
+ },
+ "tabs": {
+ "about": "O aplikaciji",
+ "config": "Konfiguracija"
+ },
+ "config": {
+ "configName": "Postavka",
+ "environmentVariable": "Varijabla okruženja",
+ "currentValue": "Vrijednost",
+ "configurationFile": "Konfiguracijska datoteka",
+ "exportToml": "Izvezi konfiguraciju (TOML)",
+ "exportSuccess": "Konfiguracija kopirana u međuspremnik u TOML formatu",
+ "exportFailed": "Greška pri kopiranju konfiguracije",
+ "devFlagsHeader": "Dev postavke (mogu se promijeniti)",
+ "devFlagsComment": "Eksperimentalne postavke koje mogu biti uklonjene ili promijenjene u budućnosti"
+ }
+ },
+ "activity": {
+ "title": "Aktivnost",
+ "totalScanned": "Ukupno skeniranih foldera",
+ "quickScan": "Brzo skeniranje",
+ "fullScan": "Potpuno skeniranje",
+ "serverUptime": "Vrijeme rada servera",
+ "serverDown": "ISKLJUČEN",
+ "scanType": "Tip",
+ "status": "Greška pri skeniranju",
+ "elapsedTime": "Proteklo vrijeme"
+ },
+ "help": {
+ "title": "Navidrome prečice",
+ "hotkeys": {
+ "show_help": "Prikaži ovu pomoć",
+ "toggle_menu": "Uključi/isključi bočnu traku",
+ "toggle_play": "Reprodukcija / Pauza",
+ "prev_song": "Prethodna pjesma",
+ "next_song": "Sljedeća pjesma",
+ "vol_up": "Glasnije",
+ "vol_down": "Tiše",
+ "toggle_love": "Dodaj u favorite",
+ "current_song": "Prikaži trenutnu pjesmu"
+ }
+ },
+ "nowPlaying": {
+ "title": "Trenutna reprodukcija",
+ "empty": "Nema reprodukcije",
+ "minutesAgo": "Prije %{smart_count} minute |||| Prije %{smart_count} minuta"
+ }
+}
diff --git a/resources/i18n/da.json b/resources/i18n/da.json
index 7d4258d2a..550c8841a 100644
--- a/resources/i18n/da.json
+++ b/resources/i18n/da.json
@@ -1,460 +1,634 @@
{
- "languageName": "Dansk",
- "resources": {
- "song": {
- "name": "Sang |||| Sange",
- "fields": {
- "albumArtist": "Album kunstner",
- "duration": "Varighed",
- "trackNumber": "#",
- "playCount": "Afspilninger",
- "title": "Titel",
- "artist": "Kunstner",
- "album": "Album navn",
- "path": "Fil placering",
- "genre": "Genre",
- "compilation": "Samling",
- "year": "År",
- "size": "Fil størrelse",
- "updatedAt": "Opdateret den",
- "bitRate": "Bitrate",
- "discSubtitle": "Plade undernavn",
- "starred": "Stjernemarkeret",
- "comment": "Kommentar",
- "rating": "",
- "quality": "",
- "bpm": "",
- "playDate": "",
- "channels": "",
- "createdAt": ""
- },
- "actions": {
- "addToQueue": "Afspil senere",
- "playNow": "Afspil nu",
- "addToPlaylist": "Tilføj til afspilningsliste",
- "shuffleAll": "Bland alle",
- "download": "Hent",
- "playNext": "Spil næste",
- "info": ""
- }
- },
- "album": {
- "name": "Album |||| Albums",
- "fields": {
- "albumArtist": "Album kunstner",
- "artist": "Kunstner",
- "duration": "Varighed",
- "songCount": "Sange",
- "playCount": "Afspilninger",
- "name": "Navn",
- "genre": "Genre",
- "compilation": "Samling",
- "year": "År",
- "updatedAt": "Opdateret den",
- "comment": "Kommentar",
- "rating": "",
- "createdAt": "",
- "size": "",
- "originalDate": "",
- "releaseDate": "",
- "releases": "",
- "released": ""
- },
- "actions": {
- "playAll": "Afspil",
- "playNext": "Afspil næste",
- "addToQueue": "Afspil senere",
- "shuffle": "Bland",
- "addToPlaylist": "Tilføj til afspilningsliste",
- "download": "Hent",
- "info": "",
- "share": ""
- },
- "lists": {
- "all": "Alle",
- "random": "Tilfældig",
- "recentlyAdded": "Nyligt tilføjet",
- "recentlyPlayed": "Nyligt Afspillet",
- "mostPlayed": "Mest Afspillet",
- "starred": "Stjernemarkeret",
- "topRated": ""
- }
- },
- "artist": {
- "name": "Kunstner |||| Kunstnere",
- "fields": {
- "name": "Navn",
- "albumCount": "Antal album",
- "songCount": "Antal sange",
- "playCount": "Afspilninger",
- "rating": "",
- "genre": "",
- "size": ""
- }
- },
- "user": {
- "name": "Bruger |||| Brugere",
- "fields": {
- "userName": "Brugernavn",
- "isAdmin": "Er administrator",
- "lastLoginAt": "Sidste login",
- "updatedAt": "Opdateret den",
- "name": "Navn",
- "password": "Kodeord",
- "createdAt": "Oprettet den",
- "changePassword": "",
- "currentPassword": "",
- "newPassword": "",
- "token": ""
- },
- "helperTexts": {
- "name": ""
- },
- "notifications": {
- "created": "",
- "updated": "",
- "deleted": ""
- },
- "message": {
- "listenBrainzToken": "",
- "clickHereForToken": ""
- }
- },
- "player": {
- "name": "Afspiller |||| Afspillere",
- "fields": {
- "name": "Navn",
- "transcodingId": "Omkodning",
- "maxBitRate": "Maks. bitrate",
- "client": "Klient",
- "userName": "Brugernavn",
- "lastSeen": "Sidst set",
- "reportRealPath": "",
- "scrobbleEnabled": ""
- }
- },
- "transcoding": {
- "name": "Omkodning |||| Omkodninger",
- "fields": {
- "name": "Navn",
- "targetFormat": "Målformat",
- "defaultBitRate": "Standard bitrate",
- "command": "Kommando"
- }
- },
- "playlist": {
- "name": "Afspilningsliste |||| Afspilningslister",
- "fields": {
- "name": "Navn",
- "duration": "Varighed",
- "ownerName": "Ejer",
- "public": "Offentlig",
- "updatedAt": "Opdateret den",
- "createdAt": "Oprettet den",
- "songCount": "Sange",
- "comment": "Kommentar",
- "sync": "Auto-importér",
- "path": "Importér fra"
- },
- "actions": {
- "selectPlaylist": "Vælg en afspilningsliste:",
- "addNewPlaylist": "Opret \"%{name}\"",
- "export": "Eksporter",
- "makePublic": "",
- "makePrivate": ""
- },
- "message": {
- "duplicate_song": "",
- "song_exist": ""
- }
- },
- "radio": {
- "name": "",
- "fields": {
- "name": "",
- "streamUrl": "",
- "homePageUrl": "",
- "updatedAt": "",
- "createdAt": ""
- },
- "actions": {
- "playNow": ""
- }
- },
- "share": {
- "name": "",
- "fields": {
- "username": "",
- "url": "",
- "description": "",
- "contents": "",
- "expiresAt": "",
- "lastVisitedAt": "",
- "visitCount": "",
- "format": "",
- "maxBitRate": "",
- "updatedAt": "",
- "createdAt": "",
- "downloadable": ""
- }
- }
+ "languageName": "Dansk",
+ "resources": {
+ "song": {
+ "name": "Sang |||| Sange",
+ "fields": {
+ "albumArtist": "Album kunstner",
+ "duration": "Varighed",
+ "trackNumber": "#",
+ "playCount": "Afspilninger",
+ "title": "Titel",
+ "artist": "Kunstner",
+ "album": "Album navn",
+ "path": "Filsti",
+ "genre": "Genre",
+ "compilation": "Opsamling",
+ "year": "År",
+ "size": "Fil størrelse",
+ "updatedAt": "Opdateret den",
+ "bitRate": "Bitrate",
+ "discSubtitle": "Plade undertitel",
+ "starred": "Stjernemarkeret",
+ "comment": "Kommentar",
+ "rating": "Bedømmelse",
+ "quality": "Kvalitet",
+ "bpm": "BPM",
+ "playDate": "Senest afspillet",
+ "channels": "Kanaler",
+ "createdAt": "Tilføjet d.",
+ "grouping": "Gruppering",
+ "mood": "Humør",
+ "participants": "Yderligere deltagere",
+ "tags": "Yderligere tags",
+ "mappedTags": "Mappede tags",
+ "rawTags": "Rå tags",
+ "bitDepth": "Bitdybde",
+ "sampleRate": "Samplingfrekvens",
+ "missing": "Manglende",
+ "libraryName": "Bibliotek"
+ },
+ "actions": {
+ "addToQueue": "Afspil senere",
+ "playNow": "Afspil nu",
+ "addToPlaylist": "Føj til afspilningsliste",
+ "shuffleAll": "Bland alle",
+ "download": "Download",
+ "playNext": "Afspil næste",
+ "info": "Hent info",
+ "showInPlaylist": "Vis i afspilningsliste"
+ }
},
- "ra": {
- "auth": {
- "welcome1": "Tak fordi du installerede Navidrome!",
- "welcome2": "Opret administrator for at begynde",
- "confirmPassword": "Bekræft kodeord",
- "buttonCreateAdmin": "Opret administrator",
- "auth_check_error": "Venligst login for at fortsætte",
- "user_menu": "Profil",
- "username": "Brugernavn",
- "password": "Password",
- "sign_in": "Log ind",
- "sign_in_error": "Dit log ind fejlede, prøv igen",
- "logout": "Log ud"
- },
- "validation": {
- "invalidChars": "Vær venlig kun at benytte bogstaver og tal",
- "passwordDoesNotMatch": "Kodeord er ikke ens",
- "required": "Obligatorisk",
- "minLength": "Skal være mindst %{min} tegn",
- "maxLength": "Skal være max %{max} tegn",
- "minValue": "Skal være mindst %{min}",
- "maxValue": "Skal være max %{max}",
- "number": "Skal være et nummer",
- "email": "Skal være en gyldig e-mail-adresse",
- "oneOf": "Skal være en af: %{options}",
- "regex": "Skal matche et bestemt format (regexp): %{pattern}",
- "unique": "",
- "url": ""
- },
- "action": {
- "add_filter": "Tilføj filter",
- "add": "Tilføj",
- "back": "Tilbage",
- "bulk_actions": "%{smart_count} valgt",
- "cancel": "Annuller",
- "clear_input_value": "Ryd",
- "clone": "Klon",
- "confirm": "Bekræft",
- "create": "Opret",
- "delete": "Slet",
- "edit": "Rediger",
- "export": "Eksporter",
- "list": "Liste",
- "refresh": "Opdater",
- "remove_filter": "Slet filter",
- "remove": "Fjern",
- "save": "Gem",
- "search": "Søg",
- "show": "Vis",
- "sort": "Sortér",
- "undo": "Fortryd",
- "expand": "Udvid",
- "close": "Luk",
- "open_menu": "Åben menu",
- "close_menu": "Luk menu",
- "unselect": "Fravælg",
- "skip": "",
- "bulk_actions_mobile": "",
- "share": "",
- "download": ""
- },
- "boolean": {
- "true": "Ja",
- "false": "Nej"
- },
- "page": {
- "create": "Opret %{name}",
- "dashboard": "Dashboard",
- "edit": "%{name} #%{id}",
- "error": "Noget gik galt",
- "list": "%{name} liste",
- "loading": "Henter",
- "not_found": "Ikke fundet",
- "show": "%{name} #%{id}",
- "empty": "Ingen %{name} endnu",
- "invite": "Vil du tilføje en?"
- },
- "input": {
- "file": {
- "upload_several": "Træk og slip filer for at uploade, eller klik for at vælge filer.",
- "upload_single": "Træk og slip en fil for at uploade, eller klik for at vælge en fil."
- },
- "image": {
- "upload_several": "Træk og slip filer for at uploade, eller klik for at vælge filer.",
- "upload_single": "Træk og slip et billede for at uploade, eller klik for at vælge en fil."
- },
- "references": {
- "all_missing": "Kan ikke finde nogle referencedata.",
- "many_missing": "Mindst en af de tilknyttede referencer synes ikke længere at være tilgængelig.",
- "single_missing": "Tilknyttede referencer synes ikke længere at være tilgængelige."
- },
- "password": {
- "toggle_visible": "Skjul kodeord",
- "toggle_hidden": "Vis kodeord"
- }
- },
- "message": {
- "about": "Om",
- "are_you_sure": "Er du sikker?",
- "bulk_delete_content": "Er du sikker på du vil slette %{name}? |||| Er du sikker på du ville slette %{smart_count} poster?",
- "bulk_delete_title": "Slet %{name} |||| Sletter %{smart_count} %{name} poster",
- "delete_content": "Er du sikker på du ville slette denne post?",
- "delete_title": "Slet %{name} #%{id}",
- "details": "Detaljer",
- "error": "Der opstod en klientfejl, og din forespørgsel kunne ikke udføres.",
- "invalid_form": "Formularen er ikke gyldig. Kontroller for fejl",
- "loading": "Siden indlæses, Vent et øjeblik",
- "no": "Nej",
- "not_found": "Enten har du skrevet en forkert URL eller du har fulgt et invalidt link.",
- "yes": "Ja",
- "unsaved_changes": "Du har lavet ændringer der ikke er gemt. Er du sikker på at du vil ignorere dem?"
- },
- "navigation": {
- "no_results": "Ingen resultater fundet",
- "no_more_results": "Sidenummeret %{page} eksistere ikke. Gå tilbage til forrige side.",
- "page_out_of_boundaries": "Sidenummeret %{page} eksistere ikke",
- "page_out_from_end": "Der findes ikke flere sider",
- "page_out_from_begin": "Der er ingen side før end side 1",
- "page_range_info": "%{offsetBegin}-%{offsetEnd} af %{total}",
- "page_rows_per_page": "Rækker pr. side:",
- "next": "Næste",
- "prev": "Forrige",
- "skip_nav": ""
- },
- "notification": {
- "updated": "Objekt opdateret |||| %{smart_count} objekter opdateret",
- "created": "Objekt oprettet",
- "deleted": "Objekt slettet |||| %{smart_count} objekter slettet",
- "bad_item": "Incorrect element",
- "item_doesnt_exist": "Objektet findes ikke",
- "http_error": "Kommunikationsfejl med serveren",
- "data_provider_error": "dataProvider fejl. Check din console for detaljer.",
- "i18n_error": "Kan ikke indlæse oversættelse af det ønskede sprog",
- "canceled": "Handling blev annulleret",
- "logged_out": "Din session er udløbet, venligst tilslut igen",
- "new_version": ""
- },
- "toggleFieldsMenu": {
- "columnsToDisplay": "",
- "layout": "",
- "grid": "",
- "table": ""
- }
+ "album": {
+ "name": "Album |||| Albums",
+ "fields": {
+ "albumArtist": "Album kunstner",
+ "artist": "Kunstner",
+ "duration": "Varighed",
+ "songCount": "Sange",
+ "playCount": "Afspilninger",
+ "name": "Navn",
+ "genre": "Genre",
+ "compilation": "Opsamling",
+ "year": "År",
+ "updatedAt": "Opdateret d.",
+ "comment": "Kommentar",
+ "rating": "Bedømmelse",
+ "createdAt": "Tilføjet d.",
+ "size": "Størrelse",
+ "originalDate": "Original",
+ "releaseDate": "Udgivet",
+ "releases": "Udgivelse |||| Udgivelser",
+ "released": "Udgivet",
+ "recordLabel": "Plademærke",
+ "catalogNum": "Katalognummer",
+ "releaseType": "Type",
+ "grouping": "Gruppering",
+ "media": "Medier",
+ "mood": "Humør",
+ "date": "Optagelsesdato",
+ "missing": "Manglende",
+ "libraryName": "Bibliotek"
+ },
+ "actions": {
+ "playAll": "Afspil",
+ "playNext": "Afspil næste",
+ "addToQueue": "Føj til kø",
+ "shuffle": "Bland",
+ "addToPlaylist": "Føj til afspilningsliste",
+ "download": "Download",
+ "info": "Hent info",
+ "share": "Del"
+ },
+ "lists": {
+ "all": "Alle",
+ "random": "Tilfældig",
+ "recentlyAdded": "Nyligt tilføjet",
+ "recentlyPlayed": "Nyligt Afspillet",
+ "mostPlayed": "Mest Afspillet",
+ "starred": "Stjernemarkerede",
+ "topRated": "Top bedømmelse"
+ }
},
- "message": {
- "note": "NOTE",
- "transcodingDisabled": "Skift af indstillinger for omkodning gennem web platformen er frakoblet af sikkerhedsgrunde. Genstart serveren med %{config} indstilling tilvalgt.",
- "transcodingEnabled": "Navidrome kører i øjeblikket med %{config}, hvilket gør det muligt at køre system kommandoer fra web platformen. Vi anbefaler at slå det fra af sikkerhedsgrunde og kun slå det til ved indstilling af omkodning.",
- "songsAddedToPlaylist": "Tilføjede 1 sang til afspilningsliste |||| Tilføjede %{smart_count} sange til afspilningsliste",
- "noPlaylistsAvailable": "Ingen tilgængelige",
- "delete_user_title": "Slet bruger '%{name}'",
- "delete_user_content": "Er du sikker på at du vil slette denne bruger og tilhørende data (inklusive afspilningslister og indstillinger)?",
- "notifications_blocked": "",
- "notifications_not_available": "",
- "lastfmLinkSuccess": "",
- "lastfmLinkFailure": "",
- "lastfmUnlinkSuccess": "",
- "lastfmUnlinkFailure": "",
- "openIn": {
- "lastfm": "",
- "musicbrainz": ""
- },
- "lastfmLink": "",
- "listenBrainzLinkSuccess": "",
- "listenBrainzLinkFailure": "",
- "listenBrainzUnlinkSuccess": "",
- "listenBrainzUnlinkFailure": "",
- "downloadOriginalFormat": "",
- "shareOriginalFormat": "",
- "shareDialogTitle": "",
- "shareBatchDialogTitle": "",
- "shareSuccess": "",
- "shareFailure": "",
- "downloadDialogTitle": "",
- "shareCopyToClipboard": ""
+ "artist": {
+ "name": "Kunstner |||| Kunstnere",
+ "fields": {
+ "name": "Navn",
+ "albumCount": "Antal albums",
+ "songCount": "Antal sange",
+ "playCount": "Afspilninger",
+ "rating": "Bedømmelse",
+ "genre": "Genre",
+ "size": "Størrelse",
+ "role": "Rolle",
+ "missing": "Manglende"
+ },
+ "roles": {
+ "albumartist": "Albumkunstner |||| Albumkunstnere",
+ "artist": "Kunstner |||| Kunstnere",
+ "composer": "Komponist |||| Komponister",
+ "conductor": "Dirigent |||| Dirigenter",
+ "lyricist": "Tekstforfatter |||| Tekstforfattere",
+ "arranger": "Arrangør |||| Arrangører",
+ "producer": "Producent |||| Producenter",
+ "director": "Instruktør |||| Instruktører",
+ "engineer": "Tekniker||||Teknikere",
+ "mixer": "Mixer |||| Mixere",
+ "remixer": "Remixer |||| Remixere",
+ "djmixer": "DJ-mixer |||| DJ-mixere",
+ "performer": "Udførende kunstner |||| Udførende kunstnere",
+ "maincredit": "Albumkunstner eller kunstner |||| Albumkunstnere eller kunstnere"
+ },
+ "actions": {
+ "shuffle": "Bland",
+ "radio": "Radio",
+ "topSongs": "Topsange"
+ }
},
- "menu": {
- "library": "Bibliotek",
- "settings": "Indstillinger",
- "version": "Version",
- "theme": "Tema",
- "personal": {
- "name": "Personligt",
- "options": {
- "theme": "Tema",
- "language": "Sprog",
- "defaultView": "Standardopsætning",
- "desktop_notifications": "",
- "lastfmScrobbling": "",
- "listenBrainzScrobbling": "",
- "replaygain": "",
- "preAmp": "",
- "gain": {
- "none": "",
- "album": "",
- "track": ""
- }
- }
- },
- "albumList": "Albums",
- "about": "Om",
- "playlists": "",
- "sharedPlaylists": ""
+ "user": {
+ "name": "Bruger |||| Brugere",
+ "fields": {
+ "userName": "Brugernavn",
+ "isAdmin": "Er administrator",
+ "lastLoginAt": "Seneste login",
+ "updatedAt": "Opdateret d.",
+ "name": "Navn",
+ "password": "Kodeord",
+ "createdAt": "Oprettet d.",
+ "changePassword": "Skifte kodeord?",
+ "currentPassword": "Nuværende kodeord",
+ "newPassword": "Nyt kodeord",
+ "token": "Token",
+ "lastAccessAt": "Senest tilgået",
+ "libraries": "Biblioteker"
+ },
+ "helperTexts": {
+ "name": "Ændringer i dit navn vises først ved næste login",
+ "libraries": "Vælg specifikke biblioteker til denne bruger, eller lad det stå tomt for at bruge standardbiblioteker"
+ },
+ "notifications": {
+ "created": "Bruger oprettet",
+ "updated": "Bruger opdateret",
+ "deleted": "Bruger slettet"
+ },
+ "message": {
+ "listenBrainzToken": "Skriv dit ListenBrainz token",
+ "clickHereForToken": "Tryk her for at få dit token",
+ "selectAllLibraries": "Vælg alle biblioteker",
+ "adminAutoLibraries": "Administratorbrugere har automatisk adgang til alle biblioteker"
+ },
+ "validation": {
+ "librariesRequired": "Der skal være valgt mindst ét bibliotek til ikke-administrative brugere"
+ }
},
"player": {
- "playListsText": "Afspilnings kø",
- "openText": "Åben",
- "closeText": "Luk",
- "notContentText": "Ingen musik",
- "clickToPlayText": "Tryk for at afspille",
- "clickToPauseText": "Tryk for at pause",
- "nextTrackText": "Næste nummer",
- "previousTrackText": "Forrige nummer",
- "reloadText": "Genindlæs",
- "volumeText": "Lydstyrke",
- "toggleLyricText": "Skift sangtekst",
- "toggleMiniModeText": "Minimer",
- "destroyText": "Fjern",
- "downloadText": "Hent",
- "removeAudioListsText": "Slet afspillingsliste",
- "clickToDeleteText": "Tryk for at slette %{name}",
- "emptyLyricText": "Ingen sangtekst",
- "playModeText": {
- "order": "I rækkefølge",
- "orderLoop": "Gentag",
- "singleLoop": "Gentag enkelt",
- "shufflePlay": "Bland"
- }
+ "name": "Afspiller |||| Afspillere",
+ "fields": {
+ "name": "Navn",
+ "transcodingId": "Transkodning",
+ "maxBitRate": "Maks. bitrate",
+ "client": "Klient",
+ "userName": "Brugernavn",
+ "lastSeen": "Sidst set",
+ "reportRealPath": "Vis den virkelige sti",
+ "scrobbleEnabled": "Send scrobbles til eksterne tjenester"
+ }
},
- "about": {
- "links": {
- "homepage": "Hjme",
- "source": "Kildekode",
- "featureRequests": "Ønskede funktioner"
- }
+ "transcoding": {
+ "name": "Transkodning |||| Transkodninger",
+ "fields": {
+ "name": "Navn",
+ "targetFormat": "Målformat",
+ "defaultBitRate": "Standard bitrate",
+ "command": "Kommando"
+ }
},
- "activity": {
- "title": "Aktivitet",
- "totalScanned": "Antal sange fundet",
- "quickScan": "Hurtig søgning",
- "fullScan": "Fuld søgning\n",
- "serverUptime": "Server uptime",
- "serverDown": "OFFLINE"
+ "playlist": {
+ "name": "Afspilningsliste |||| Afspilningslister",
+ "fields": {
+ "name": "Navn",
+ "duration": "Varighed",
+ "ownerName": "Ejer",
+ "public": "Offentlig",
+ "updatedAt": "Opdateret d.",
+ "createdAt": "Oprettet d.",
+ "songCount": "Sange",
+ "comment": "Kommentar",
+ "sync": "Auto-importér",
+ "path": "Importér fra"
+ },
+ "actions": {
+ "selectPlaylist": "Vælg en afspilningsliste:",
+ "addNewPlaylist": "Opret \"%{name}\"",
+ "export": "Eksportér",
+ "makePublic": "Offentliggør",
+ "makePrivate": "Gør privat",
+ "saveQueue": "Gem kø på afspilningsliste",
+ "searchOrCreate": "Søg i afspilningslister eller skriv for at oprette nye...",
+ "pressEnterToCreate": "Tryk Enter for at oprette en ny afspilningsliste",
+ "removeFromSelection": "Fjern fra valg"
+ },
+ "message": {
+ "duplicate_song": "Tilføj dubletter af sange",
+ "song_exist": "Der føjes dubletter til playlisten",
+ "noPlaylistsFound": "Ingen playlister fundet",
+ "noPlaylists": "Ingen tilgængelige playlister"
+ }
},
- "help": {
- "title": "",
- "hotkeys": {
- "show_help": "",
- "toggle_menu": "",
- "toggle_play": "",
- "prev_song": "",
- "next_song": "",
- "vol_up": "",
- "vol_down": "",
- "toggle_love": "",
- "current_song": ""
- }
+ "radio": {
+ "name": "Radio |||| Radioer",
+ "fields": {
+ "name": "Navn",
+ "streamUrl": "Stream-URL",
+ "homePageUrl": "Hjemmeside-URL",
+ "updatedAt": "Opdateret d.",
+ "createdAt": "Oprettet d."
+ },
+ "actions": {
+ "playNow": "Afspil nu"
+ }
+ },
+ "share": {
+ "name": "Del |||| Delinger",
+ "fields": {
+ "username": "Delt af",
+ "url": "URL",
+ "description": "Beskrivelse",
+ "contents": "Indhold",
+ "expiresAt": "Udløber",
+ "lastVisitedAt": "Senest besøgt",
+ "visitCount": "Besøg",
+ "format": "Format",
+ "maxBitRate": "Maks. bitrate",
+ "updatedAt": "Opdateret d.",
+ "createdAt": "Oprettet d.",
+ "downloadable": "Tillad downloads?"
+ }
+ },
+ "missing": {
+ "name": "Manglende fil |||| Manglende filer",
+ "fields": {
+ "path": "Sti",
+ "size": "Størrelse",
+ "updatedAt": "Forsvandt d.",
+ "libraryName": "Bibliotek"
+ },
+ "actions": {
+ "remove": "Fjern",
+ "remove_all": "Fjern alle"
+ },
+ "notifications": {
+ "removed": "Manglende fil(er) fjernet"
+ },
+ "empty": "Ingen manglende filer"
+ },
+ "library": {
+ "name": "Bibliotek |||| Biblioteker",
+ "fields": {
+ "name": "Navn",
+ "path": "Sti",
+ "remotePath": "Fjernsti",
+ "lastScanAt": "Sidste scanning",
+ "songCount": "Sange",
+ "albumCount": "Albummer",
+ "artistCount": "Kunstnere",
+ "totalSongs": "Sange",
+ "totalAlbums": "Albummer",
+ "totalArtists": "Kunstnere",
+ "totalFolders": "Mapper",
+ "totalFiles": "Filer",
+ "totalMissingFiles": "Manglende filer",
+ "totalSize": "Samlet størrelse",
+ "totalDuration": "Varighed",
+ "defaultNewUsers": "Standard for nye brugere",
+ "createdAt": "Oprettet d.",
+ "updatedAt": "Opdateret d."
+ },
+ "sections": {
+ "basic": "Grundlæggende oplysninger",
+ "statistics": "Statistik"
+ },
+ "actions": {
+ "scan": "Scanningsbibliotek",
+ "manageUsers": "Administrer brugeradgang",
+ "viewDetails": "Se detaljer",
+ "quickScan": "hurtig skanning",
+ "fullScan": "Fuld skanning"
+ },
+ "notifications": {
+ "created": "Bibliotek oprettet",
+ "updated": "Biblioteket er blevet opdateret",
+ "deleted": "Biblioteket er blevet slettet",
+ "scanStarted": "Biblioteksscanning startet",
+ "scanCompleted": "Biblioteksscanning fuldført",
+ "quickScanStarted": "hurtig skanning startet",
+ "fullScanStarted": "Fuld skanning startet",
+ "scanError": "Kan ikke starte skanning. Tjek loggen"
+ },
+ "validation": {
+ "nameRequired": "Biblioteksnavn er påkrævet",
+ "pathRequired": "Bibliotekssti er påkrævet",
+ "pathNotDirectory": "Biblioteksstien skal være en mappe",
+ "pathNotFound": "Biblioteksstien blev ikke fundet",
+ "pathNotAccessible": "Biblioteksstien er ikke tilgængelig",
+ "pathInvalid": "Ugyldig bibliotekssti"
+ },
+ "messages": {
+ "deleteConfirm": "Er du sikker på, at du vil slette dette bibliotek? Dét vil fjerne alle tilknyttede data og brugeradgange",
+ "scanInProgress": "Scanning i gang...",
+ "noLibrariesAssigned": "Ingen biblioteker tildelt denne bruger"
+ }
}
+ },
+ "ra": {
+ "auth": {
+ "welcome1": "Tak fordi du installerede Navidrome!",
+ "welcome2": "Først, opret en administrator",
+ "confirmPassword": "Bekræft kodeord",
+ "buttonCreateAdmin": "Opret administrator",
+ "auth_check_error": "Venligst login for at fortsætte",
+ "user_menu": "Profil",
+ "username": "Brugernavn",
+ "password": "Kodeord",
+ "sign_in": "Log ind",
+ "sign_in_error": "Dit log ind slog fejl, prøv igen",
+ "logout": "Log ud",
+ "insightsCollectionNote": "Navidrome indsamler anonyme brugsdata for at forbedre projektet. Klik [her] for at få mere at vide og fravælge, hvis du ønsker det."
+ },
+ "validation": {
+ "invalidChars": "Venligst, benyt kun bogstaver og tal",
+ "passwordDoesNotMatch": "Kodeord er ikke ens",
+ "required": "Nødvendig",
+ "minLength": "Skal være mindst %{min} tegn",
+ "maxLength": "Skal være op til %{max} tegn",
+ "minValue": "Skal være mindst %{min}",
+ "maxValue": "Skal være op til %{max}",
+ "number": "Skal være et tal",
+ "email": "Skal være en gyldig e-mail-adresse",
+ "oneOf": "Skal være én af: %{options}",
+ "regex": "Skal matche et specifikt format (regexp): %{pattern}",
+ "unique": "Skal være unik",
+ "url": "Skal være en gyldig URL"
+ },
+ "action": {
+ "add_filter": "Tilføj filter",
+ "add": "Tilføj",
+ "back": "Tilbage",
+ "bulk_actions": "1 emne valgt |||| %{smart_count} emner valgt",
+ "cancel": "Annuller",
+ "clear_input_value": "Ryd",
+ "clone": "Klon",
+ "confirm": "Bekræft",
+ "create": "Opret",
+ "delete": "Slet",
+ "edit": "Rediger",
+ "export": "Eksportér",
+ "list": "Liste",
+ "refresh": "Opdater",
+ "remove_filter": "Slet filter",
+ "remove": "Fjern",
+ "save": "Gem",
+ "search": "Søg",
+ "show": "Vis",
+ "sort": "Sortér",
+ "undo": "Fortryd",
+ "expand": "Udvid",
+ "close": "Luk",
+ "open_menu": "Åbn menu",
+ "close_menu": "Luk menu",
+ "unselect": "Fravælg",
+ "skip": "Spring over",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
+ "share": "Del",
+ "download": "Download"
+ },
+ "boolean": {
+ "true": "Ja",
+ "false": "Nej"
+ },
+ "page": {
+ "create": "Opret %{name}",
+ "dashboard": "Instrumentbræt",
+ "edit": "%{name} #%{id}",
+ "error": "Noget gik galt",
+ "list": "%{name} liste",
+ "loading": "Henter",
+ "not_found": "Ikke fundet",
+ "show": "%{name} #%{id}",
+ "empty": "Ingen %{name} endnu.",
+ "invite": "Vil du tilføje en?"
+ },
+ "input": {
+ "file": {
+ "upload_several": "Træk nogle filer herind for at uploade, eller klik for at vælge en.",
+ "upload_single": "Træk en fil herind for at uploade, eller klik for at vælge den."
+ },
+ "image": {
+ "upload_several": "Træk billedfiler herind for at uploade, eller klik for at vælge en.",
+ "upload_single": "Træk en billedfil herind for at uploade, eller klik for at vælge den."
+ },
+ "references": {
+ "all_missing": "Kan ikke finde nogen referencedata.",
+ "many_missing": "Mindst en af de tilknyttede referencer synes ikke længere at være tilgængelig.",
+ "single_missing": "Tilknyttede referencer synes ikke længere at være tilgængelige."
+ },
+ "password": {
+ "toggle_visible": "Skjul kodeord",
+ "toggle_hidden": "Vis kodeord"
+ }
+ },
+ "message": {
+ "about": "Om",
+ "are_you_sure": "Er du sikker?",
+ "bulk_delete_content": "Er du sikker på, at du vil slette %{name}? |||| Er du sikker på, at du vil slette disse %{smart_count} poster?",
+ "bulk_delete_title": "Slet %{name} |||| Sletter %{smart_count} %{name} poster",
+ "delete_content": "Er du sikker på, at du vil slette denne post?",
+ "delete_title": "Slet %{name} #%{id}",
+ "details": "Detaljer",
+ "error": "Der opstod en klientfejl, og din forespørgsel kunne ikke udføres.",
+ "invalid_form": "Formularen er ikke gyldig. Tjek for fejl",
+ "loading": "Siden indlæses, vent et øjeblik",
+ "no": "Nej",
+ "not_found": "Enten har du skrevet en forkert URL eller du har fulgt et ugyldigt link.",
+ "yes": "Ja",
+ "unsaved_changes": "Du har lavet ændringer der ikke er gemt. Er du sikker på at du vil ignorere dem?"
+ },
+ "navigation": {
+ "no_results": "Ingen resultater fundet",
+ "no_more_results": "Sidenummeret %{page} eksisterer ikke. Gå tilbage til forrige side.",
+ "page_out_of_boundaries": "Sidenummeret %{page} ligger uden for grænserne",
+ "page_out_from_end": "Dette er sidste side",
+ "page_out_from_begin": "Dette er side 1",
+ "page_range_info": "%{offsetBegin}-%{offsetEnd} af %{total}",
+ "page_rows_per_page": "Rækker pr. side:",
+ "next": "Næste",
+ "prev": "Forrige",
+ "skip_nav": "Hop til indhold"
+ },
+ "notification": {
+ "updated": "Element opdateret |||| %{smart_count} elementer opdateret",
+ "created": "Element oprettet",
+ "deleted": "Element slettet |||| %{smart_count} elementer slettet",
+ "bad_item": "Forkert element",
+ "item_doesnt_exist": "Elementet findes ikke",
+ "http_error": "Kommunikationsfejl med serveren",
+ "data_provider_error": "dataProvider fejl. Tjek konsollen for detaljer.",
+ "i18n_error": "Kan ikke indlæse oversættelsen af det ønskede sprog",
+ "canceled": "Handling blev annulleret",
+ "logged_out": "Din session er udløbet, venligst tilslut igen",
+ "new_version": "Ny version tilgængelig! – genopfrisk venligst vinduet"
+ },
+ "toggleFieldsMenu": {
+ "columnsToDisplay": "Antal synlige kolonner",
+ "layout": "Layout",
+ "grid": "Gitter",
+ "table": "Tabel"
+ }
+ },
+ "message": {
+ "note": "NOTE",
+ "transcodingDisabled": "Ændring af indstillinger til transkodning via webgrænsefladen er deaktiveret af sikkerhedshensyn.\nFor at ændre eller tilføje indstillinger skal du genstarte serveren med %{config} konfigurations option.",
+ "transcodingEnabled": "Navidrome kører i øjeblikket med %{config}. Dét gør det muligt at køre systemkommandoer fra transkodningsindstillingerne, via webgrænsefladen.\nVi anbefaler at deaktivere dette af sikkerhedshensyn og kun have det aktiveret, når du konfigurerer indstillinger til transkodning.",
+ "songsAddedToPlaylist": "Føjede 1 sang til afspilningsliste |||| Føjede %{smart_count} sange til afspilningsliste",
+ "noPlaylistsAvailable": "Ingen tilgængelige",
+ "delete_user_title": "Slet bruger '%{name}'",
+ "delete_user_content": "Er du sikker på at du vil slette denne bruger og tilhørende data (inklusive afspilningslister og valgte indstillinger)?",
+ "notifications_blocked": "Du blokerer for notifikationer fra dette site i dine browserindstillinger",
+ "notifications_not_available": "Denne browser understøtter ikke skrivebordsnotifikationer, eller: Du tilgår ikke Navidrome over https",
+ "lastfmLinkSuccess": "Du er koblet til Last.fm, og scrobbling er slået til",
+ "lastfmLinkFailure": "Du kan ikke kobles til Last.fm",
+ "lastfmUnlinkSuccess": "Last.fm frakoblet, og scrobbling deaktiveret",
+ "lastfmUnlinkFailure": "Last.fm kunne ikke frakobles",
+ "openIn": {
+ "lastfm": "Åbn i Last.fm",
+ "musicbrainz": "Åbn i MusicBrainz"
+ },
+ "lastfmLink": "Læs mere...",
+ "listenBrainzLinkSuccess": "Du er koblet til ListenBrainz og scrobbling er aktiveret som bruger: %{user}",
+ "listenBrainzLinkFailure": "Du kunne ikke kobles til ListenBrainz: %{error}",
+ "listenBrainzUnlinkSuccess": "ListenBrainz er frakoblet, og scrobbling deaktiveret",
+ "listenBrainzUnlinkFailure": "ListenBrainz kunne ikke frakobles",
+ "downloadOriginalFormat": "Download i originalformat",
+ "shareOriginalFormat": "Del i originalformat",
+ "shareDialogTitle": "Del %{resource} '%{name}'",
+ "shareBatchDialogTitle": "Del 1 %{resource} |||| Del %{smart_count} %{resource}",
+ "shareSuccess": "URL kopieret til udklipsholder: %{url}",
+ "shareFailure": "Fejl ved kopiering af URL %{url} til udklipsholder",
+ "downloadDialogTitle": "Download %{resource} '%{name}' (%{size})",
+ "shareCopyToClipboard": "Kopiér til udklipsholder: Ctrl+C, Enter",
+ "remove_missing_title": "Fjern manglende filer",
+ "remove_missing_content": "Er du sikker på, at du vil fjerne de valgte manglende filer fra databasen? Dét vil permanent fjerne alle referencer til dem, inklusive deres afspilningstællere og vurderinger.",
+ "remove_all_missing_title": "Fjern alle manglende filer",
+ "remove_all_missing_content": "Er du sikker på, at du vil fjerne alle manglende filer fra databasen? Dét vil permanent fjerne alle referencer til dem, inklusive deres afspilningstællere og vurderinger.",
+ "noSimilarSongsFound": "Ingen lignende sange fundet",
+ "noTopSongsFound": "Ingen topsange fundet"
+ },
+ "menu": {
+ "library": "Bibliotek",
+ "settings": "Indstillinger",
+ "version": "Version",
+ "theme": "Tema",
+ "personal": {
+ "name": "Personligt",
+ "options": {
+ "theme": "Tema",
+ "language": "Sprog",
+ "defaultView": "Standardopsætning",
+ "desktop_notifications": "Skrivebordsnotifikationer",
+ "lastfmScrobbling": "Scrobble til Last.fm",
+ "listenBrainzScrobbling": "Scrobble til ListenBrainz",
+ "replaygain": "ReplayGain-tilstand",
+ "preAmp": "ReplayGain PreAmp (dB)",
+ "gain": {
+ "none": "Slået fra",
+ "album": "Brug Album Gain",
+ "track": "Brug Gain for spor"
+ },
+ "lastfmNotConfigured": "Last.fm API-nøglen er ikke konfigureret"
+ }
+ },
+ "albumList": "Albums",
+ "about": "Om",
+ "playlists": "Afspilningslister",
+ "sharedPlaylists": "Delte afspilningslister",
+ "librarySelector": {
+ "allLibraries": "Alle biblioteker (%{count})",
+ "multipleLibraries": "%{selected} af %{total} biblioteker",
+ "selectLibraries": "Vælg biblioteker",
+ "none": "Ingen"
+ }
+ },
+ "player": {
+ "playListsText": "Afspilningskø",
+ "openText": "Åbn",
+ "closeText": "Luk",
+ "notContentText": "Ingen musik",
+ "clickToPlayText": "Tryk for at afspille",
+ "clickToPauseText": "Tryk for at sætte på pause",
+ "nextTrackText": "Næste nummer",
+ "previousTrackText": "Forrige nummer",
+ "reloadText": "Genindlæs",
+ "volumeText": "Lydstyrke",
+ "toggleLyricText": "Skift sangtekst til/fra",
+ "toggleMiniModeText": "Minimer",
+ "destroyText": "Fjern",
+ "downloadText": "Hent",
+ "removeAudioListsText": "Slet afspilningslister",
+ "clickToDeleteText": "Tryk for at slette %{name}",
+ "emptyLyricText": "Ingen sangtekst",
+ "playModeText": {
+ "order": "I rækkefølge",
+ "orderLoop": "Gentag",
+ "singleLoop": "Gentag enkelt",
+ "shufflePlay": "Bland"
+ }
+ },
+ "about": {
+ "links": {
+ "homepage": "Hjemmeside",
+ "source": "Kildekode",
+ "featureRequests": "Funktionsønsker",
+ "lastInsightsCollection": "Seneste indsamling af indsigter",
+ "insights": {
+ "disabled": "Slået fra",
+ "waiting": "Venter"
+ }
+ },
+ "tabs": {
+ "about": "Om",
+ "config": "Konfiguration"
+ },
+ "config": {
+ "configName": "Navn på konfiguration",
+ "environmentVariable": "Miljøvariabel",
+ "currentValue": "Nuværende værdi",
+ "configurationFile": "Konfigurationsfil",
+ "exportToml": "Eksportér konfigurationen (TOML)",
+ "exportSuccess": "Konfigurationen eksporteret til udklipsholder i TOML-format",
+ "exportFailed": "Kunne ikke kopiere konfigurationen",
+ "devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)",
+ "devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver"
+ }
+ },
+ "activity": {
+ "title": "Aktivitet",
+ "totalScanned": "Antal mapper gennemsøgt",
+ "quickScan": "Hurtig søgning",
+ "fullScan": "Fuld søgning",
+ "serverUptime": "Server oppetid",
+ "serverDown": "OFFLINE",
+ "scanType": "Type",
+ "status": "Scanningsfejl",
+ "elapsedTime": "Medgået tid",
+ "selectiveScan": "Selektiv"
+ },
+ "help": {
+ "title": "Navidrome genvejstaster",
+ "hotkeys": {
+ "show_help": "Vis denne hjælp",
+ "toggle_menu": "Skift menu sidepanel",
+ "toggle_play": "Play / Pause",
+ "prev_song": "Forrige sang",
+ "next_song": "Næste sang",
+ "vol_up": "Volumen op",
+ "vol_down": "Volumen ned",
+ "toggle_love": "Føj dette nummer til dine favoritter",
+ "current_song": "Gå til den aktuelle sang"
+ }
+ },
+ "nowPlaying": {
+ "title": "Afspilles nu",
+ "empty": "Intet afspilles nu",
+ "minutesAgo": "for %{smart_count} minut siden |||| for %{smart_count} minutter siden"
+ }
}
\ No newline at end of file
diff --git a/resources/i18n/de.json b/resources/i18n/de.json
index 8f632dd5d..22e2fab44 100644
--- a/resources/i18n/de.json
+++ b/resources/i18n/de.json
@@ -2,7 +2,7 @@
"languageName": "Deutsch",
"resources": {
"song": {
- "name": "Song |||| Songs",
+ "name": "Titel |||| Titel",
"fields": {
"albumArtist": "Albuminterpret",
"duration": "Dauer",
@@ -35,7 +35,8 @@
"rawTags": "Tag Rohdaten",
"bitDepth": "Bittiefe",
"sampleRate": "Samplerate",
- "missing": "Fehlend"
+ "missing": "Fehlend",
+ "libraryName": "Bibliothek"
},
"actions": {
"addToQueue": "Später abspielen",
@@ -44,7 +45,8 @@
"shuffleAll": "Zufallswiedergabe",
"download": "Herunterladen",
"playNext": "Als nächstes abspielen",
- "info": "Mehr Informationen"
+ "info": "Mehr Informationen",
+ "showInPlaylist": "In Wiedergabeliste anzeigen"
}
},
"album": {
@@ -75,7 +77,8 @@
"media": "Medium",
"mood": "Stimmung",
"date": "Aufnahmedatum",
- "missing": "Fehlend"
+ "missing": "Fehlend",
+ "libraryName": "Bibliothek"
},
"actions": {
"playAll": "Abspielen",
@@ -123,7 +126,13 @@
"mixer": "Mixer |||| Mixer",
"remixer": "Remixer |||| Remixer",
"djmixer": "DJ Mixer |||| DJ Mixer",
- "performer": "ausübender Künstler |||| ausübende Künstler"
+ "performer": "ausübender Künstler |||| ausübende Künstler",
+ "maincredit": "Albuminterpret oder Interpret |||| Albuminterpreten oder Interpreten"
+ },
+ "actions": {
+ "shuffle": "Zufallswiedergabe",
+ "radio": "Radio",
+ "topSongs": "Beliebteste Titel"
}
},
"user": {
@@ -140,10 +149,12 @@
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
"token": "Token",
- "lastAccessAt": "Letzter Zugriff am"
+ "lastAccessAt": "Letzter Zugriff am",
+ "libraries": "Bibliotheken"
},
"helperTexts": {
- "name": "Die Änderung wird erst nach dem nächsten Login gültig"
+ "name": "Die Änderung wird erst nach dem nächsten Login gültig",
+ "libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken"
},
"notifications": {
"created": "Benutzer erstellt",
@@ -152,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Gib deinen ListenBrainz Benutzer Token ein",
- "clickHereForToken": "Hier klicken um deinen Token abzurufen"
+ "clickHereForToken": "Hier klicken um deinen Token abzurufen",
+ "selectAllLibraries": "Wähle alle Bibliotheken",
+ "adminAutoLibraries": "Administrator-Benutzer haben automatisch Zugriff auf alle Bibliotheken"
+ },
+ "validation": {
+ "librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein"
}
},
"player": {
@@ -197,11 +213,16 @@
"export": "Exportieren",
"makePublic": "Öffentlich machen",
"makePrivate": "Privat stellen",
- "saveQueue": "Warteschlange in Wiedergabeliste speichern"
+ "saveQueue": "Warteschlange in Wiedergabeliste speichern",
+ "searchOrCreate": "Wiedergabeliste suchen oder neue erstellen...",
+ "pressEnterToCreate": "Enter drücken um neue Wiedergabeliste zu erstellen",
+ "removeFromSelection": "Von Auswahl entfernen"
},
"message": {
"duplicate_song": "Duplikate hinzufügen",
- "song_exist": "Manche Titel sind bereits in der Playlist. Möchtest du sie trotzdem hinzufügen oder überspringen?"
+ "song_exist": "Manche Titel sind bereits in der Playlist. Möchtest du sie trotzdem hinzufügen oder überspringen?",
+ "noPlaylistsFound": "Keine Wiedergabeliste gefunden",
+ "noPlaylists": "Keine Wiedergabelisten vorhanden"
}
},
"radio": {
@@ -239,7 +260,8 @@
"fields": {
"path": "Pfad",
"size": "Größe",
- "updatedAt": "Fehlt seit"
+ "updatedAt": "Fehlt seit",
+ "libraryName": "Bibliothek"
},
"actions": {
"remove": "Entfernen",
@@ -249,6 +271,63 @@
"removed": "Fehlende Datei(en) entfernt"
},
"empty": "keine fehlenden Dateien"
+ },
+ "library": {
+ "name": "Bibliothek |||| Bibliotheken",
+ "fields": {
+ "name": "Name",
+ "path": "Pfad",
+ "remotePath": "Remote Pfad",
+ "lastScanAt": "Letzter Scan",
+ "songCount": "Lieder",
+ "albumCount": "Alben",
+ "artistCount": "Interpreten",
+ "totalSongs": "Lieder",
+ "totalAlbums": "Alben",
+ "totalArtists": "Interpreten",
+ "totalFolders": "Ordner",
+ "totalFiles": "Dateien",
+ "totalMissingFiles": "Fehlende Dateien",
+ "totalSize": "Größe",
+ "totalDuration": "Dauer",
+ "defaultNewUsers": "Standard für neue Benutzer",
+ "createdAt": "Erstellt",
+ "updatedAt": "Geändert"
+ },
+ "sections": {
+ "basic": "Basis Informationen",
+ "statistics": "Statistik"
+ },
+ "actions": {
+ "scan": "Bibliothek scannen",
+ "manageUsers": "Zugriff verwalten",
+ "viewDetails": "Details ansehen",
+ "quickScan": "Schneller Scan",
+ "fullScan": "Kompletter Scan"
+ },
+ "notifications": {
+ "created": "Bibliothek erfolgreich erstellt",
+ "updated": "Bibliothek erfolgreich geändert",
+ "deleted": "Bibliothek erfolgreich gelöscht",
+ "scanStarted": "Bibliothek Scan gestartet",
+ "scanCompleted": "Bibliothek Scan vollständig",
+ "quickScanStarted": "Schneller Scan gestartet",
+ "fullScanStarted": "Kompletter Scan gestartet",
+ "scanError": "Fehler beim Starten des Scans. Logs prüfen"
+ },
+ "validation": {
+ "nameRequired": "Bibliotheksname ist Pflichtfeld",
+ "pathRequired": "Bibliothekspfad ist Pflichtfeld",
+ "pathNotDirectory": "Bibliothekspfad muss ein Ordner sein",
+ "pathNotFound": "Bibliothekspfad nicht gefunden",
+ "pathNotAccessible": "Bibliothekspfad nicht zugänglich",
+ "pathInvalid": "Bibliothekspfad ungültig"
+ },
+ "messages": {
+ "deleteConfirm": "Möchtest du diese Bibliothek wirklich löschen? Zugriffsrechte und Daten werden entfernt. ",
+ "scanInProgress": "Bibliothek Scan läuft...",
+ "noLibrariesAssigned": "Keine Bibliotheken zugeordnet"
+ }
}
},
"ra": {
@@ -430,7 +509,9 @@
"remove_missing_title": "Fehlende Dateien entfernen",
"remove_missing_content": "Möchtest du die ausgewählten Fehlenden Dateien wirklich aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.",
"remove_all_missing_title": "Alle fehlenden Dateien entfernen",
- "remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht."
+ "remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.",
+ "noSimilarSongsFound": "Keine ähnlichen Titel gefunden",
+ "noTopSongsFound": "Keine beliebten Titel gefunden"
},
"menu": {
"library": "Bibliothek",
@@ -459,10 +540,16 @@
"albumList": "Alben",
"about": "Über",
"playlists": "Wiedergabelisten",
- "sharedPlaylists": "Geteilte Wiedergabelisten"
+ "sharedPlaylists": "Geteilte Wiedergabelisten",
+ "librarySelector": {
+ "allLibraries": "Alle Bibliotheken (%{count})",
+ "multipleLibraries": "%{selected} von %{total} Bibliotheken",
+ "selectLibraries": "Bibliotheken auswählen",
+ "none": "Keine"
+ }
},
"player": {
- "playListsText": "Wiedergabeliste abspielen",
+ "playListsText": "Warteschlange abspielen",
"openText": "Öffnen",
"closeText": "Schließen",
"notContentText": "Keine Musik",
@@ -496,6 +583,21 @@
"disabled": "Deaktiviert",
"waiting": "Warten"
}
+ },
+ "tabs": {
+ "about": "Über",
+ "config": "Konfiguration"
+ },
+ "config": {
+ "configName": "Einstellung",
+ "environmentVariable": "Umbegungsvariable",
+ "currentValue": "Wert",
+ "configurationFile": "Konfigurationsdatei",
+ "exportToml": "Konfiguration exportieren (TOML)",
+ "exportSuccess": "Konfiguration im TOML Format in die Zwischenablage kopiert",
+ "exportFailed": "Fehler beim Kopieren der Konfiguration",
+ "devFlagsHeader": "Entwicklungseinstellungen (können sich ändern)",
+ "devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden"
}
},
"activity": {
@@ -507,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Scan Fehler",
- "elapsedTime": "Laufzeit"
+ "elapsedTime": "Laufzeit",
+ "selectiveScan": "Selektiver Scan"
},
"help": {
"title": "Navidrome Hotkeys",
@@ -522,5 +625,10 @@
"toggle_love": "Titel zu Favoriten hinzufügen",
"current_song": "Aktuellen Titel Anzeigen"
}
+ },
+ "nowPlaying": {
+ "title": "Aktuelle Wiedergabe",
+ "empty": "Keine Wiedergabe",
+ "minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten"
}
}
\ No newline at end of file
diff --git a/resources/i18n/el.json b/resources/i18n/el.json
index 40a7c1dc3..4dd58e9cc 100644
--- a/resources/i18n/el.json
+++ b/resources/i18n/el.json
@@ -35,7 +35,8 @@
"rawTags": "Ακατέργαστες ετικέτες",
"bitDepth": "Λίγο βάθος",
"sampleRate": "Ποσοστό δειγματοληψίας",
- "missing": "Απών"
+ "missing": "Απών",
+ "libraryName": "Βιβλιοθήκη"
},
"actions": {
"addToQueue": "Αναπαραγωγη Μετα",
@@ -44,7 +45,8 @@
"shuffleAll": "Ανακατεμα ολων",
"download": "Ληψη",
"playNext": "Επόμενη Αναπαραγωγή",
- "info": "Εμφάνιση Πληροφοριών"
+ "info": "Εμφάνιση Πληροφοριών",
+ "showInPlaylist": "Εμφάνιση στη λίστα αναπαραγωγής"
}
},
"album": {
@@ -75,7 +77,8 @@
"media": "Μέσα",
"mood": "Διάθεση",
"date": "Ημερομηνία Ηχογράφησης",
- "missing": "Απών"
+ "missing": "Απών",
+ "libraryName": "Βιβλιοθήκη"
},
"actions": {
"playAll": "Αναπαραγωγή",
@@ -123,7 +126,13 @@
"mixer": "Μίξερ |||| Μίξερ",
"remixer": "Ρεμίξερ |||| Ρεμίξερ",
"djmixer": "Dj Μίξερ |||| Dj Μίξερ",
- "performer": "Εκτελεστής |||| Ερμηνευτές"
+ "performer": "Εκτελεστής |||| Ερμηνευτές",
+ "maincredit": "Καλλιτέχνης Άλμπουμ ή Καλλιτέχνης |||| Καλλιτέχνες Άλμπουμ ή Καλλιτέχνες"
+ },
+ "actions": {
+ "shuffle": "Ανάμιξη",
+ "radio": "Ραδιόφωνο",
+ "topSongs": "Κορυφαία τραγούδια"
}
},
"user": {
@@ -140,10 +149,12 @@
"currentPassword": "Υπάρχων Κωδικός Πρόσβασης",
"newPassword": "Νέος Κωδικός Πρόσβασης",
"token": "Token",
- "lastAccessAt": "Τελευταία Πρόσβαση"
+ "lastAccessAt": "Τελευταία Πρόσβαση",
+ "libraries": "Βιβλιοθήκες"
},
"helperTexts": {
- "name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση"
+ "name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση",
+ "libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη"
},
"notifications": {
"created": "Ο χρήστης δημιουργήθηκε",
@@ -152,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Εισάγετε το token του χρήστη σας στο ListenBrainz.",
- "clickHereForToken": "Κάντε κλικ εδώ για να αποκτήσετε το token σας"
+ "clickHereForToken": "Κάντε κλικ εδώ για να αποκτήσετε το token σας",
+ "selectAllLibraries": "Επιλογή όλων των βιβλιοθηκών",
+ "adminAutoLibraries": "Οι χρήστες διαχειριστές έχουν αυτόματα πρόσβαση σε όλες τις βιβλιοθήκες"
+ },
+ "validation": {
+ "librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές"
}
},
"player": {
@@ -197,11 +213,16 @@
"export": "Εξαγωγη",
"makePublic": "Να γίνει δημόσιο",
"makePrivate": "Να γίνει ιδιωτικό",
- "saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής"
+ "saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής",
+ "searchOrCreate": "Αναζητήστε λίστες αναπαραγωγής ή πληκτρολογήστε για να δημιουργήσετε νέες...",
+ "pressEnterToCreate": "Πατήστε Enter για να δημιουργήσετε νέα λίστα αναπαραγωγής",
+ "removeFromSelection": "Αφαίρεση από την επιλογή"
},
"message": {
"duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών",
- "song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε?"
+ "song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε?",
+ "noPlaylistsFound": "Δεν βρέθηκαν λίστες αναπαραγωγής",
+ "noPlaylists": "Δεν υπάρχουν διαθέσιμες λίστες αναπαραγωγής"
}
},
"radio": {
@@ -239,7 +260,8 @@
"fields": {
"path": "Διαδρομή",
"size": "Μέγεθος",
- "updatedAt": "Εξαφανίστηκε"
+ "updatedAt": "Εξαφανίστηκε",
+ "libraryName": "Βιβλιοθήκη"
},
"actions": {
"remove": "Αφαίρεση",
@@ -249,6 +271,63 @@
"removed": "Λείπει αρχείο(α) αφαιρέθηκε"
},
"empty": "Δεν λείπουν αρχεία"
+ },
+ "library": {
+ "name": "Βιβλιοθήκη |||| Βιβλιοθήκες",
+ "fields": {
+ "name": "Ονομα",
+ "path": "διαδρομή",
+ "remotePath": "Απομακρυσμένη διαδρομή",
+ "lastScanAt": "Τελευταία σάρωση",
+ "songCount": "Τραγούδια",
+ "albumCount": "Άλμπουμ",
+ "artistCount": "Καλλιτέχνες",
+ "totalSongs": "Τραγούδια",
+ "totalAlbums": "Άλμπουμ",
+ "totalArtists": "Καλλιτέχνες",
+ "totalFolders": "Φάκελοι",
+ "totalFiles": "Αρχεία",
+ "totalMissingFiles": "Λείπει αρχείο",
+ "totalSize": "Συνολικό μέγεθος",
+ "totalDuration": "Διάρκεια",
+ "defaultNewUsers": "Προεπιλογή για νέους χρήστες",
+ "createdAt": "Δημιουργήθηκε",
+ "updatedAt": "Ενημερώθηκε"
+ },
+ "sections": {
+ "basic": "Βασικές πληροφορίες",
+ "statistics": "Στατιστική"
+ },
+ "actions": {
+ "scan": "Σάρωση βιβλιοθήκης",
+ "manageUsers": "Διαχείριση πρόσβασης χρήστη",
+ "viewDetails": "Προβολή λεπτομερειών",
+ "quickScan": "Γρήγορη σάρωση",
+ "fullScan": "Πλήρης σάρωση"
+ },
+ "notifications": {
+ "created": "Η βιβλιοθήκη δημιουργήθηκε με επιτυχία",
+ "updated": "Η βιβλιοθήκη ενημερώθηκε με επιτυχία",
+ "deleted": "Η βιβλιοθήκη διαγράφηκε με επιτυχία",
+ "scanStarted": "Ξεκίνησε η σάρωση της βιβλιοθήκης",
+ "scanCompleted": "Η σάρωση της βιβλιοθήκης ολοκληρώθηκε",
+ "quickScanStarted": "Η Γρήγορη Σάρωση ξεκίνησε",
+ "fullScanStarted": "Η πλήρης σάρωση ξεκίνησε",
+ "scanError": "Σφάλμα κατά την έναρξη της σάρωσης. Ελέγξτε τα αρχεία καταγραφής."
+ },
+ "validation": {
+ "nameRequired": "Απαιτείται όνομα βιβλιοθήκης",
+ "pathRequired": "Απαιτείται διαδρομή βιβλιοθήκης",
+ "pathNotDirectory": "Η διαδρομή της βιβλιοθήκης πρέπει να είναι ένας κατάλογος",
+ "pathNotFound": "Η διαδρομή της βιβλιοθήκης δεν βρέθηκε",
+ "pathNotAccessible": "Η διαδρομή της βιβλιοθήκης δεν είναι προσβάσιμη",
+ "pathInvalid": "Μη έγκυρη διαδρομή βιβλιοθήκης"
+ },
+ "messages": {
+ "deleteConfirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτήν τη βιβλιοθήκη? Αυτή η ενέργεια θα καταργήσει όλα τα σχετικά δεδομένα και την πρόσβαση των χρηστών.",
+ "scanInProgress": "Σάρωση σε εξέλιξη...",
+ "noLibrariesAssigned": "Δεν έχουν αντιστοιχιστεί βιβλιοθήκες σε αυτόν τον χρήστη"
+ }
}
},
"ra": {
@@ -430,7 +509,9 @@
"remove_missing_title": "Αφαιρέστε τα αρχεία που λείπουν",
"remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους.",
"remove_all_missing_title": "Αφαίρεση όλων των αρχείων που λείπουν",
- "remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους."
+ "remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους.",
+ "noSimilarSongsFound": "Δεν βρέθηκαν παρόμοια τραγούδια",
+ "noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια"
},
"menu": {
"library": "Βιβλιοθήκη",
@@ -459,7 +540,13 @@
"albumList": "Άλμπουμ",
"about": "Σχετικά",
"playlists": "Λίστες Αναπαραγωγής",
- "sharedPlaylists": "Κοινοποιημένες Λίστες Αναπαραγωγής"
+ "sharedPlaylists": "Κοινοποιημένες Λίστες Αναπαραγωγής",
+ "librarySelector": {
+ "allLibraries": "Όλες οι βιβλιοθήκες (%{count})",
+ "multipleLibraries": "%{selected} από %{total} Βιβλιοθήκες",
+ "selectLibraries": "Επιλέξτε βιβλιοθήκες",
+ "none": "Κανένα"
+ }
},
"player": {
"playListsText": "Ουρά Αναπαραγωγής",
@@ -496,6 +583,21 @@
"disabled": "Απενεργοποιημένο",
"waiting": "Αναμονή"
}
+ },
+ "tabs": {
+ "about": "Σχετικά",
+ "config": "Διαμόρφωση"
+ },
+ "config": {
+ "configName": "Όνομα διαμόρφωσης",
+ "environmentVariable": "Μεταβλητή περιβάλλοντος",
+ "currentValue": "Τρέχουσα Αξία",
+ "configurationFile": "Αρχείο διαμόρφωσης",
+ "exportToml": "Ρύθμιση παραμέτρων εξαγωγής (TOML)",
+ "exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML",
+ "exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε",
+ "devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)",
+ "devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις"
}
},
"activity": {
@@ -507,7 +609,8 @@
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ",
"scanType": "Τύπος",
"status": "Σφάλμα σάρωσης",
- "elapsedTime": "Χρόνος που πέρασε"
+ "elapsedTime": "Χρόνος που πέρασε",
+ "selectiveScan": "Εκλεκτικός"
},
"help": {
"title": "Συντομεύσεις του Navidrome",
@@ -522,5 +625,10 @@
"toggle_love": "Προσθήκη αυτού του κομματιού στα αγαπημένα",
"current_song": "Μεταβείτε στο Τρέχον τραγούδι"
}
+ },
+ "nowPlaying": {
+ "title": "Αναπαραγωγή τώρα",
+ "empty": "Δεν παίζει τίποτα",
+ "minutesAgo": "%{smart_count} λεπτό πριν |||| %{smart_count} λεπτά πριν"
}
}
\ No newline at end of file
diff --git a/resources/i18n/es.json b/resources/i18n/es.json
index b640ec115..c620d773f 100644
--- a/resources/i18n/es.json
+++ b/resources/i18n/es.json
@@ -35,7 +35,8 @@
"rawTags": "Etiquetas sin procesar",
"bitDepth": "Profundidad de bits",
"sampleRate": "Frecuencia de muestreo",
- "missing": "Faltante"
+ "missing": "Faltante",
+ "libraryName": "Biblioteca"
},
"actions": {
"addToQueue": "Reproducir después",
@@ -44,7 +45,8 @@
"shuffleAll": "Todas aleatorias",
"download": "Descarga",
"playNext": "Siguiente",
- "info": "Obtener información"
+ "info": "Obtener información",
+ "showInPlaylist": "Mostrar en la lista de reproducción"
}
},
"album": {
@@ -75,7 +77,8 @@
"media": "Medios",
"mood": "Estado de ánimo",
"date": "Fecha de grabación",
- "missing": "Faltante"
+ "missing": "Faltante",
+ "libraryName": "Biblioteca"
},
"actions": {
"playAll": "Reproducir",
@@ -123,7 +126,13 @@
"mixer": "Mezclador",
"remixer": "Remixer",
"djmixer": "DJ Mixer",
- "performer": "Intérprete"
+ "performer": "Intérprete",
+ "maincredit": "Artista del álbum o Artista |||| Artistas del álbum o Artistas"
+ },
+ "actions": {
+ "shuffle": "Aleatorio",
+ "radio": "Radio",
+ "topSongs": "Más destacadas"
}
},
"user": {
@@ -140,10 +149,12 @@
"currentPassword": "Contraseña actual",
"newPassword": "Nueva contraseña",
"token": "Token",
- "lastAccessAt": "Último acceso"
+ "lastAccessAt": "Último acceso",
+ "libraries": "Bibliotecas"
},
"helperTexts": {
- "name": "Los cambios a tu nombre se verán en el próximo inicio de sesión"
+ "name": "Los cambios a tu nombre se verán en el próximo inicio de sesión",
+ "libraries": "Selecciona bibliotecas específicas para este usuario o déjalo vacío para usar las bibliotecas por defecto"
},
"notifications": {
"created": "Usuario creado",
@@ -152,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Escribe tu token de usuario de ListenBrainz",
- "clickHereForToken": "Click aquí para obtener tu token"
+ "clickHereForToken": "Click aquí para obtener tu token",
+ "selectAllLibraries": "Seleccionar todas las bibliotecas",
+ "adminAutoLibraries": "Los usuarios administradores tienen acceso a todas las bibliotecas automáticamente"
+ },
+ "validation": {
+ "librariesRequired": "Se debe seleccionar al menos una biblioteca para los usuarios que no sean administradores"
}
},
"player": {
@@ -197,11 +213,16 @@
"export": "Exportar",
"makePublic": "Hazla pública",
"makePrivate": "Hazla privada",
- "saveQueue": "Guardar la fila de reproducción en una playlist"
+ "saveQueue": "Guardar la fila de reproducción en una playlist",
+ "searchOrCreate": "Buscar listas de reproducción o escribe para crear una nueva…",
+ "pressEnterToCreate": "Pulsa Enter para crear una nueva lista de reproducción",
+ "removeFromSelection": "Quitar de la selección"
},
"message": {
"duplicate_song": "Algunas de las canciones seleccionadas están presentes en la playlist",
- "song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?"
+ "song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?",
+ "noPlaylistsFound": "No se encontraron listas de reproducción",
+ "noPlaylists": "No hay listas de reproducción disponibles"
}
},
"radio": {
@@ -239,7 +260,8 @@
"fields": {
"path": "Ruta",
"size": "Tamaño",
- "updatedAt": "Actualizado el"
+ "updatedAt": "Actualizado el",
+ "libraryName": "Biblioteca"
},
"actions": {
"remove": "Eliminar",
@@ -249,6 +271,63 @@
"removed": "Eliminado"
},
"empty": "No hay archivos perdidos"
+ },
+ "library": {
+ "name": "Biblioteca |||| Bibliotecas",
+ "fields": {
+ "name": "Nombre",
+ "path": "Ruta",
+ "remotePath": "Ruta remota",
+ "lastScanAt": "Último escaneo",
+ "songCount": "Canciones",
+ "albumCount": "Álbumes",
+ "artistCount": "Artistas",
+ "totalSongs": "Canciones",
+ "totalAlbums": "Álbumes",
+ "totalArtists": "Artistas",
+ "totalFolders": "Carpetas",
+ "totalFiles": "Archivos",
+ "totalMissingFiles": "Archivos faltantes",
+ "totalSize": "Tamaño total",
+ "totalDuration": "Duración",
+ "defaultNewUsers": "Valor por defecto para los nuevos usuarios",
+ "createdAt": "Creado",
+ "updatedAt": "Actualizado"
+ },
+ "sections": {
+ "basic": "Información básica",
+ "statistics": "Estadísticas"
+ },
+ "actions": {
+ "scan": "Escanear biblioteca",
+ "manageUsers": "Gestionar el acceso de usarios",
+ "viewDetails": "Ver detalles",
+ "quickScan": "Escaneo rápido",
+ "fullScan": "Escaneo completo"
+ },
+ "notifications": {
+ "created": "La biblioteca se creó correctamente",
+ "updated": "La biblioteca se actualizó correctamente",
+ "deleted": "La biblioteca se eliminó correctamente",
+ "scanStarted": "El escaneo de la biblioteca ha comenzado",
+ "scanCompleted": "El escaneo de la biblioteca se completó",
+ "quickScanStarted": "Escaneo rápido ha comenzado",
+ "fullScanStarted": "Escaneo completo ha comenzado",
+ "scanError": "Error al iniciar el escaneo. Revisa los registros"
+ },
+ "validation": {
+ "nameRequired": "El nombre de la biblioteca es obligatorio",
+ "pathRequired": "La ruta de la biblioteca es obligatoria",
+ "pathNotDirectory": "La ruta de la biblioteca debe ser un directorio",
+ "pathNotFound": "Ruta de la biblioteca no encontrada",
+ "pathNotAccessible": "La ruta de la biblioteca no es accesible",
+ "pathInvalid": "Ruta de la biblioteca no válida"
+ },
+ "messages": {
+ "deleteConfirm": "¿Estás seguro/a de que quieres eliminar esta biblioteca? Esto eliminará todos los datos asociados y el acceso de les usuaries.",
+ "scanInProgress": "Escaneo en curso...",
+ "noLibrariesAssigned": "No hay bibliotecas asignadas a este usuario"
+ }
}
},
"ra": {
@@ -430,7 +509,9 @@
"remove_missing_title": "Eliminar elemento faltante",
"remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
"remove_all_missing_title": "Eliminar todos los archivos perdidos",
- "remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones."
+ "remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
+ "noSimilarSongsFound": "No se encontraron canciones similares",
+ "noTopSongsFound": "No se encontraron canciones destacadas"
},
"menu": {
"library": "Biblioteca",
@@ -459,7 +540,13 @@
"albumList": "Álbumes",
"about": "Acerca de",
"playlists": "Playlists",
- "sharedPlaylists": "Playlists Compartidas"
+ "sharedPlaylists": "Playlists Compartidas",
+ "librarySelector": {
+ "allLibraries": "Todas las bibliotecas (%{count})",
+ "multipleLibraries": "%{selected} de %{total} bibliotecas",
+ "selectLibraries": "Seleccionar bibliotecas",
+ "none": "Ninguno"
+ }
},
"player": {
"playListsText": "Fila de reproducción",
@@ -496,6 +583,21 @@
"disabled": "Deshabilitado",
"waiting": "Esperando"
}
+ },
+ "tabs": {
+ "about": "Acerca de",
+ "config": "Configuración"
+ },
+ "config": {
+ "configName": "Nombre de la configuración",
+ "environmentVariable": "Variables de entorno",
+ "currentValue": "Valor actual",
+ "configurationFile": "Archivo de configuración",
+ "exportToml": "Exportar configuración (TOML)",
+ "exportSuccess": "Configuración exportada al portapapeles en formato TOML",
+ "exportFailed": "Error al copiar la configuración",
+ "devFlagsHeader": "Indicadores de desarrollo (sujetos a cambios o eliminación)",
+ "devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras"
}
},
"activity": {
@@ -507,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Tipo",
"status": "Error de escaneo",
- "elapsedTime": "Tiempo transcurrido"
+ "elapsedTime": "Tiempo transcurrido",
+ "selectiveScan": "Selectivo"
},
"help": {
"title": "Atajos de teclado de Navidrome",
@@ -522,5 +625,10 @@
"toggle_love": "Marca esta canción como favorita",
"current_song": "Canción actual"
}
+ },
+ "nowPlaying": {
+ "title": "En reproducción",
+ "empty": "Nada en reproducción",
+ "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
}
-}
+}
\ No newline at end of file
diff --git a/resources/i18n/eu.json b/resources/i18n/eu.json
index 5470ab38b..0c968e2c4 100644
--- a/resources/i18n/eu.json
+++ b/resources/i18n/eu.json
@@ -12,6 +12,7 @@
"artist": "Artista",
"album": "Albuma",
"path": "Fitxategiaren bidea",
+ "libraryName": "Liburutegia",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
@@ -27,23 +28,25 @@
"rating": "Balorazioa",
"quality": "Kalitatea",
"bpm": "BPM",
- "playDate": "Azkenekoz erreproduzitua:",
+ "playDate": "Azken erreprodukzioa:",
"createdAt": "Gehitu zen data:",
"grouping": "Multzokatzea",
"mood": "Aldartea",
"participants": "Partaide gehiago",
"tags": "Traola gehiago",
"mappedTags": "Esleitutako traolak",
- "rawTags": "Traola gordinak"
+ "rawTags": "Traola gordinak",
+ "missing": "Ez da aurkitu"
},
"actions": {
"addToQueue": "Erreproduzitu ondoren",
"playNow": "Erreproduzitu orain",
"addToPlaylist": "Gehitu erreprodukzio-zerrendara",
+ "showInPlaylist": "Erakutsi erreprodukzio-zerrendan",
"shuffleAll": "Erreprodukzio aleatorioa",
"download": "Deskargatu",
"playNext": "Hurrengoa",
- "info": "Lortu informazioa"
+ "info": "Erakutsi informazioa"
}
},
"album": {
@@ -56,12 +59,13 @@
"playCount": "Erreprodukzioak",
"size": "Fitxategiaren tamaina",
"name": "Izena",
+ "libraryName": "Liburutegia",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
"date": "Recording Date",
"originalDate": "Jatorrizkoa",
- "releaseDate": "Argitaratze-data:",
+ "releaseDate": "Argitaratze-data",
"releases": "Argitaratzea |||| Argitaratzeak",
"released": "Argitaratua",
"updatedAt": "Aktualizatze-data:",
@@ -73,21 +77,22 @@
"releaseType": "Mota",
"grouping": "Multzokatzea",
"media": "Multimedia",
- "mood": "Aldartea"
+ "mood": "Aldartea",
+ "missing": "Ez da aurkitu"
},
"actions": {
"playAll": "Erreproduzitu",
- "playNext": "Erreproduzitu segidan",
+ "playNext": "Erreproduzitu orain",
"addToQueue": "Erreproduzitu amaieran",
"shuffle": "Aletorioa",
"addToPlaylist": "Gehitu zerrendara",
"download": "Deskargatu",
- "info": "Lortu informazioa",
+ "info": "Erakutsi informazioa",
"share": "Partekatu"
},
"lists": {
"all": "Guztiak",
- "random": "Aleatorioki",
+ "random": "Aleatorioa",
"recentlyAdded": "Berriki gehitutakoak",
"recentlyPlayed": "Berriki entzundakoak",
"mostPlayed": "Gehien entzundakoak",
@@ -105,7 +110,8 @@
"playCount": "Erreprodukzio kopurua",
"rating": "Balorazioa",
"genre": "Generoa",
- "role": "Rola"
+ "role": "Rola",
+ "missing": "Ez da aurkitu"
},
"roles": {
"albumartist": "Albumeko egilea |||| Albumeko artistak",
@@ -120,7 +126,13 @@
"mixer": "Nahaslea |||| Nahasleak",
"remixer": "Remixerra |||| Remixerrak",
"djmixer": "DJ nahaslea |||| DJ nahasleak",
- "performer": "Interpretatzailea |||| Interpretatzaileak"
+ "performer": "Interpretatzailea |||| Interpretatzaileak",
+ "maincredit": "Albumeko egilea edo egilea |||| Albumeko egileak edo egileak"
+ },
+ "actions": {
+ "topSongs": "Abesti apartak",
+ "shuffle": "Aleatorioki",
+ "radio": "Irratia"
}
},
"user": {
@@ -137,19 +149,26 @@
"currentPassword": "Uneko pasahitza",
"newPassword": "Pasahitz berria",
"token": "Tokena",
- "lastAccessAt": "Azken sarbidea"
+ "lastAccessAt": "Azken sarbidea",
+ "libraries": "Liburutegiak"
},
"helperTexts": {
- "name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira"
+ "name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira",
+ "libraries": "Hautatu erabiltzaile honentzat liburutegi jakinak, edo utzi hutsik defektuzko liburutegiak erabiltzeko"
},
"notifications": {
"created": "Erabiltzailea sortu da",
"updated": "Erabiltzailea eguneratu da",
"deleted": "Erabiltzailea ezabatu da"
},
+ "validation": {
+ "librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat"
+ },
"message": {
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
- "clickHereForToken": "Egin klik hemen tokena lortzeko"
+ "clickHereForToken": "Egin klik hemen tokena lortzeko",
+ "selectAllLibraries": "Hautatu liburutegi guztiak",
+ "adminAutoLibraries": "Administratzaileek automatikoki dute liburutegi guztietara sarbidea"
}
},
"player": {
@@ -192,12 +211,18 @@
"selectPlaylist": "Hautatu zerrenda:",
"addNewPlaylist": "Sortu \"%{name}\"",
"export": "Esportatu",
+ "saveQueue": "Gorde ilaran daudek erreprodukzio-zerrendan",
"makePublic": "Egin publikoa",
- "makePrivate": "Egin pribatua"
+ "makePrivate": "Egin pribatua",
+ "searchOrCreate": "Bilatu erreprodukzio-zerrenda edo idatzi berria sortzeko…",
+ "pressEnterToCreate": "Sakatu Enter erreprodukzio-zerrenda berria sortzeko",
+ "removeFromSelection": "Kendu hautaketatik"
},
"message": {
"duplicate_song": "Hautatutako abesti batzuk lehendik ere daude zerrendan",
- "song_exist": "Bikoiztutakoak gehitzen ari dira erreprodukzio-zerrendara. Ziur gehitu nahi dituzula?"
+ "song_exist": "Bikoiztutakoak gehitzen ari dira erreprodukzio-zerrendara. Ziur gehitu nahi dituzula?",
+ "noPlaylistsFound": "Ez da erreprodukzio-zerrenda aurkitu",
+ "noPlaylists": "Ez dago erreprodukzio-zerrendarik eskuragarri"
}
},
"radio": {
@@ -233,19 +258,72 @@
"actions": {}
},
"missing": {
- "name": "Fitxategia falta da|||| Fitxategiak falta dira",
+ "name": "Aurkitu ez den fitxategia |||| Aurkitu ez diren fitxategiak",
"empty": "Ez da fitxategirik falta",
"fields": {
"path": "Bidea",
"size": "Tamaina",
+ "libraryName": "Liburutegia",
"updatedAt": "Desagertze-data:"
},
"actions": {
"remove": "Kendu",
- "remove_all": "Kendu guztia"
+ "remove_all": "Kendu guztiak"
},
"notifications": {
- "removed": "Faltan zeuden fitxategiak kendu dira"
+ "removed": "Aurkitzen ez ziren fitxategiak kendu dira"
+ }
+ },
+ "library": {
+ "name": "Liburutegia |||| Liburutegiak",
+ "fields": {
+ "name": "Izena",
+ "path": "Fitxategiaren bidea",
+ "remotePath": "Urruneko bidea",
+ "lastScanAt": "Azken araketa",
+ "songCount": "Abestiak",
+ "albumCount": "Albumak",
+ "artistCount": "Artistak",
+ "totalSongs": "Abestiak",
+ "totalAlbums": "Albumak",
+ "totalArtists": "Artistak",
+ "totalFolders": "Karpetak",
+ "totalFiles": "Fitxategiak",
+ "totalMissingFiles": "Fitxategiak faltan",
+ "totalSize": "Tamaina guztira",
+ "totalDuration": "Iraupena",
+ "defaultNewUsers": "Defektuz erabiltzaile berrientzat",
+ "createdAt": "Sortze-data",
+ "updatedAt": "Eguneratze-data"
+ },
+ "sections": {
+ "basic": "Oinarrizko informazioa",
+ "statistics": "Estatistikak"
+ },
+ "actions": {
+ "scan": "Arakatu liburutegia",
+ "manageUsers": "Kudeatu erabiltzaileen sarbidea",
+ "viewDetails": "Ikusi xehetasunak"
+ },
+ "notifications": {
+ "created": "Liburutegia ondo sortu da",
+ "updated": "Liburutegia ondo eguneratu da",
+ "deleted": "Liburutegia ondo ezabatu da",
+ "scanStarted": "Liburutegiaren araketa hasi da",
+ "scanCompleted": "Liburutegiaren araketa amaitu da"
+ },
+ "validation": {
+ "nameRequired": "Liburutegiaren izena beharrezkoa da",
+ "pathRequired": "Liburutegiaren bidea beharrezkoa da",
+ "pathNotDirectory": "Liburutegiaren bidea direktorio bat izan behar da",
+ "pathNotFound": "Ez da liburutegiaren bidea aurkitu",
+ "pathNotAccessible": "Liburutegiaren bidea ez dago eskuragai",
+ "pathInvalid": "Liburutegiaren bidea ez da baliozkoa"
+ },
+ "messages": {
+ "deleteConfirm": "Ziur liburutegia ezabatu nahi duzula? Erlazionatutako datu guztiak eta erabiltzaileen sarbidea kenduko ditu.",
+ "scanInProgress": "Araketa abian da…",
+ "noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat"
}
}
},
@@ -399,6 +477,8 @@
"transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.",
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
"songsAddedToPlaylist": "Abesti bat zerrendara gehitu da |||| %{smart_count} abesti zerrendara gehitu dira",
+ "noSimilarSongsFound": "Ez da antzeko abestirik aurkitu",
+ "noTopSongsFound": "Ez da aparteko abestirik aurkitu",
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",
"delete_user_content": "Ziur zaide erabiltzaile hau eta bere datu guztiak (zerrendak eta hobespenak barne) ezabatu nahi dituzula?",
@@ -432,6 +512,12 @@
},
"menu": {
"library": "Liburutegia",
+ "librarySelector": {
+ "allLibraries": "Liburutegi guztiak (%{count})",
+ "multipleLibraries": "%{total} liburutegitik %{selected} hautatuta",
+ "selectLibraries": "Hautatu liburutegiak",
+ "none": "Bat ere ez"
+ },
"settings": "Ezarpenak",
"version": "Bertsioa",
"theme": "Itxura",
@@ -480,8 +566,8 @@
"playModeText": {
"order": "Ordenean",
"orderLoop": "Errepikatu",
- "singleLoop": "Errepikatu bakarra",
- "shufflePlay": "Aleatorioa"
+ "singleLoop": "Errepikatu abesti hau",
+ "shufflePlay": "Aleatorioki"
}
},
"about": {
@@ -494,6 +580,21 @@
"disabled": "Ezgaituta",
"waiting": "Zain"
}
+ },
+ "tabs": {
+ "about": "Honi buruz",
+ "config": "Konfigurazioa"
+ },
+ "config": {
+ "configName": "Konfigurazioaren izena",
+ "environmentVariable": "Ingurune-aldagaia",
+ "currentValue": "Uneko balioa",
+ "configurationFile": "Konfigurazio-fitxategia",
+ "exportToml": "Esportatu konfigurazioa (TOML)",
+ "exportSuccess": "Konfigurazioa arbelera esportatu da TOML formatuan",
+ "exportFailed": "Konfigurazioa kopiatzeak huts egin du",
+ "devFlagsHeader": "Garapen-adierazleak (aldatu/kendu litezke)",
+ "devFlagsComment": "Ezarpen esperimentalak dira eta litekeena da etorkizunean desagertzea"
}
},
"activity": {
@@ -507,6 +608,11 @@
"status": "Errorea arakatzean",
"elapsedTime": "Igarotako denbora"
},
+ "nowPlaying": {
+ "title": "Une honetan erreproduzitzen",
+ "empty": "Ez dago erreproduzitzeko ezer",
+ "minutesAgo": "Duela minutu %{smart_count} |||| Duela %{smart_count} minutu"
+ },
"help": {
"title": "Navidromeren laster-teklak",
"hotkeys": {
diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json
index 92e43934f..e5ecea2ce 100644
--- a/resources/i18n/fi.json
+++ b/resources/i18n/fi.json
@@ -35,7 +35,8 @@
"rawTags": "Raakatunnisteet",
"bitDepth": "Bittisyvyys",
"sampleRate": "Näytteenottotaajuus",
- "missing": ""
+ "missing": "Puuttuva",
+ "libraryName": "Kirjasto"
},
"actions": {
"addToQueue": "Lisää jonoon",
@@ -44,7 +45,8 @@
"shuffleAll": "Sekoita kaikki",
"download": "Lataa",
"playNext": "Soita seuraavaksi",
- "info": "Info"
+ "info": "Info",
+ "showInPlaylist": "Näytä soittolistassa"
}
},
"album": {
@@ -75,7 +77,8 @@
"media": "Media",
"mood": "Tunnelma",
"date": "Tallennuspäivä",
- "missing": ""
+ "missing": "Puuttuva",
+ "libraryName": "Kirjasto"
},
"actions": {
"playAll": "Soita",
@@ -108,7 +111,7 @@
"genre": "Tyylilaji",
"size": "Koko",
"role": "Rooli",
- "missing": ""
+ "missing": "Puuttuva"
},
"roles": {
"albumartist": "Albumitaiteilija |||| Albumitaiteilijat",
@@ -123,7 +126,13 @@
"mixer": "Miksaaja |||| Miksaajat",
"remixer": "Remiksaaja |||| Remiksaajat",
"djmixer": "DJ-miksaaja |||| DJ-miksaajat",
- "performer": "Esiintyjä |||| Esiintyjät"
+ "performer": "Esiintyjä |||| Esiintyjät",
+ "maincredit": "Albumin artisti tai artisti |||| Albumin artistit tai artistit"
+ },
+ "actions": {
+ "shuffle": "Sekoita",
+ "radio": "Radio",
+ "topSongs": "Suosituimmat kappaleet"
}
},
"user": {
@@ -140,10 +149,12 @@
"currentPassword": "Nykyinen salasana",
"newPassword": "Uusi salasana",
"token": "Avain",
- "lastAccessAt": "Viimeisin käyttö"
+ "lastAccessAt": "Viimeisin käyttö",
+ "libraries": "Kirjastot"
},
"helperTexts": {
- "name": "Nimen muutos tulee voimaan kun seuraavan kerran kirjaudut sisään"
+ "name": "Nimen muutos tulee voimaan kun seuraavan kerran kirjaudut sisään",
+ "libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja"
},
"notifications": {
"created": "Käyttäjä luotu",
@@ -152,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Syötä ListenBrainz avain.",
- "clickHereForToken": "Paina tästä saadaksesi avaimen"
+ "clickHereForToken": "Paina tästä saadaksesi avaimen",
+ "selectAllLibraries": "Valitse kaikki kirjastot",
+ "adminAutoLibraries": "Admin-käyttäjillä on automaattisesti pääsy kaikkiin kirjastoihin"
+ },
+ "validation": {
+ "librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille"
}
},
"player": {
@@ -197,11 +213,16 @@
"export": "Vie",
"makePublic": "Tee julkinen",
"makePrivate": "Tee yksityinen",
- "saveQueue": ""
+ "saveQueue": "Tallenna jono soittolistaan",
+ "searchOrCreate": "Etsi soittolistoja tai kirjoita luodaksesi uuden...",
+ "pressEnterToCreate": "Paina Enter luodaksesi uuden soittolistan",
+ "removeFromSelection": "Poista valinnasta"
},
"message": {
"duplicate_song": "Lisää olemassa oleva kappale",
- "song_exist": "Olet lisäämässä soittolistalla jo olevaa kappaletta. Haluatko lisätä saman kappaleen vai ohittaa sen?"
+ "song_exist": "Olet lisäämässä soittolistalla jo olevaa kappaletta. Haluatko lisätä saman kappaleen vai ohittaa sen?",
+ "noPlaylistsFound": "Soittolistoja ei löytynyt",
+ "noPlaylists": "Soittolistoja ei ole saatavilla"
}
},
"radio": {
@@ -239,16 +260,69 @@
"fields": {
"path": "Polku",
"size": "Koko",
- "updatedAt": "Katosi"
+ "updatedAt": "Katosi",
+ "libraryName": "Kirjasto"
},
"actions": {
"remove": "Poista",
- "remove_all": ""
+ "remove_all": "Poista kaikki"
},
"notifications": {
"removed": "Puuttuvat tiedostot poistettu"
},
"empty": "Ei puuttuvia tiedostoja"
+ },
+ "library": {
+ "name": "Kirjasto |||| Kirjastot",
+ "fields": {
+ "name": "Nimi",
+ "path": "Polku",
+ "remotePath": "Etäpolku",
+ "lastScanAt": "Viimeisin skannaus",
+ "songCount": "Kappaleet",
+ "albumCount": "Albumit",
+ "artistCount": "Artistit",
+ "totalSongs": "Kappaleet",
+ "totalAlbums": "Albumit",
+ "totalArtists": "Artistit",
+ "totalFolders": "Kansiot",
+ "totalFiles": "Tiedostot",
+ "totalMissingFiles": "Puuttuvat tiedostot",
+ "totalSize": "Kokonaiskoko",
+ "totalDuration": "Kesto",
+ "defaultNewUsers": "Oletus uusille käyttäjille",
+ "createdAt": "Luotu",
+ "updatedAt": "Päivitetty"
+ },
+ "sections": {
+ "basic": "Perustiedot",
+ "statistics": "Tilastot"
+ },
+ "actions": {
+ "scan": "Skannaa kirjasto",
+ "manageUsers": "Hallitse käyttäjien pääsyä",
+ "viewDetails": "Näytä tiedot"
+ },
+ "notifications": {
+ "created": "Kirjasto luotu onnistuneesti",
+ "updated": "Kirjasto päivitetty onnistuneesti",
+ "deleted": "Kirjasto poistettu onnistuneesti",
+ "scanStarted": "Kirjaston skannaus aloitettu",
+ "scanCompleted": "Kirjaston skannaus valmistunut"
+ },
+ "validation": {
+ "nameRequired": "Kirjaston nimi vaaditaan",
+ "pathRequired": "Kirjaston polku vaaditaan",
+ "pathNotDirectory": "Kirjaston polun tulee olla hakemisto",
+ "pathNotFound": "Kirjaston polkua ei löytynyt",
+ "pathNotAccessible": "Kirjaston polku ei ole käytettävissä",
+ "pathInvalid": "Virheellinen kirjaston polku"
+ },
+ "messages": {
+ "deleteConfirm": "Oletko varma, että haluat poistaa tämän kirjaston? Tämä poistaa kaikki liittyvät tiedot ja käyttäjien pääsyn.",
+ "scanInProgress": "Skannaus käynnissä...",
+ "noLibrariesAssigned": "Tälle käyttäjälle ei ole määritetty kirjastoja"
+ }
}
},
"ra": {
@@ -429,8 +503,10 @@
"shareCopyToClipboard": "Kopio leikepöydälle: Ctrl+C, Enter",
"remove_missing_title": "Poista puuttuvat tiedostot",
"remove_missing_content": "Oletko varma, että haluat poistaa valitut puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien niiden soittojen määrät ja arvostelut.",
- "remove_all_missing_title": "",
- "remove_all_missing_content": ""
+ "remove_all_missing_title": "Poista kaikki puuttuvat tiedostot",
+ "remove_all_missing_content": "Haluatko varmasti poistaa kaikki puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien toistomäärät ja arvostelut.",
+ "noSimilarSongsFound": "Samankaltaisia kappaleita ei löytynyt",
+ "noTopSongsFound": "Suosituimpia kappaleita ei löytynyt"
},
"menu": {
"library": "Kirjasto",
@@ -459,7 +535,13 @@
"albumList": "Albumit",
"about": "Tietoa",
"playlists": "Soittolista",
- "sharedPlaylists": "Jaettu soittolista"
+ "sharedPlaylists": "Jaettu soittolista",
+ "librarySelector": {
+ "allLibraries": "Kaikki kirjastot (%{count})",
+ "multipleLibraries": "%{selected} / %{total} kirjastoa",
+ "selectLibraries": "Valitse kirjastot",
+ "none": "Ei mitään"
+ }
},
"player": {
"playListsText": "Jono",
@@ -496,6 +578,21 @@
"disabled": "Ei käytössä",
"waiting": "Odottaa"
}
+ },
+ "tabs": {
+ "about": "Tietoja",
+ "config": "Kokoonpano"
+ },
+ "config": {
+ "configName": "Konfiguraation nimi",
+ "environmentVariable": "Ympäristömuuttuja",
+ "currentValue": "Nykyinen arvo",
+ "configurationFile": "Konfiguraatiotiedosto",
+ "exportToml": "Vie konfiguraatio (TOML)",
+ "exportSuccess": "Konfiguraatio viety leikepöydälle TOML-muodossa",
+ "exportFailed": "Konfiguraation kopiointi epäonnistui",
+ "devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)",
+ "devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa"
}
},
"activity": {
@@ -505,9 +602,9 @@
"fullScan": "Täysi tarkistus",
"serverUptime": "Palvelun käyttöaika",
"serverDown": "SAMMUTETTU",
- "scanType": "",
- "status": "",
- "elapsedTime": ""
+ "scanType": "Tyyppi",
+ "status": "Skannausvirhe",
+ "elapsedTime": "Kulunut aika"
},
"help": {
"title": "Navidrome pikapainikkeet",
@@ -522,5 +619,10 @@
"toggle_love": "Lisää kappale suosikkeihin",
"current_song": "Siirry nykyiseen kappaleeseen"
}
+ },
+ "nowPlaying": {
+ "title": "Nyt soi",
+ "empty": "Ei soita mitään",
+ "minutesAgo": "%{smart_count} minuutti sitten |||| %{smart_count} minuuttia sitten"
}
}
\ No newline at end of file
diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json
index b85960918..070e63977 100644
--- a/resources/i18n/fr.json
+++ b/resources/i18n/fr.json
@@ -2,7 +2,7 @@
"languageName": "Français",
"resources": {
"song": {
- "name": "Piste |||| Pistes",
+ "name": "Titre |||| Titres",
"fields": {
"albumArtist": "Artiste",
"duration": "Durée",
@@ -11,7 +11,7 @@
"title": "Titre",
"artist": "Artiste",
"album": "Album",
- "path": "Chemin",
+ "path": "Chemin d'accès",
"genre": "Genre",
"compilation": "Compilation",
"year": "Année",
@@ -35,7 +35,8 @@
"rawTags": "Étiquettes brutes",
"bitDepth": "Profondeur de bits",
"sampleRate": "Fréquence d'échantillonnage",
- "missing": "Manquant"
+ "missing": "Manquant",
+ "libraryName": "Bibliothèque"
},
"actions": {
"addToQueue": "Ajouter à la file",
@@ -44,7 +45,8 @@
"shuffleAll": "Tout mélanger",
"download": "Télécharger",
"playNext": "Jouer ensuite",
- "info": "Plus d'informations"
+ "info": "Plus d'informations",
+ "showInPlaylist": "Montrer dans la playlist"
}
},
"album": {
@@ -53,7 +55,7 @@
"albumArtist": "Artiste",
"artist": "Artiste",
"duration": "Durée",
- "songCount": "Nombre de pistes",
+ "songCount": "Titres",
"playCount": "Nombre d'écoutes",
"name": "Nom",
"genre": "Genre",
@@ -75,7 +77,8 @@
"media": "Média",
"mood": "Humeur",
"date": "Date d'enregistrement",
- "missing": "Manquant"
+ "missing": "Manquant",
+ "libraryName": "Bibliothèque"
},
"actions": {
"playAll": "Lire",
@@ -102,7 +105,7 @@
"fields": {
"name": "Nom",
"albumCount": "Nombre d'albums",
- "songCount": "Nombre de pistes",
+ "songCount": "Nombre de titres",
"playCount": "Lectures",
"rating": "Classement",
"genre": "Genre",
@@ -123,7 +126,13 @@
"mixer": "Mixeur |||| Mixeurs",
"remixer": "Remixeur |||| Remixeurs",
"djmixer": "Mixeur DJ |||| Mixeurs DJ",
- "performer": "Interprète |||| Interprètes"
+ "performer": "Interprète |||| Interprètes",
+ "maincredit": "Artiste de l'album ou Artiste |||| Artistes de l'album ou Artistes"
+ },
+ "actions": {
+ "shuffle": "Lecture aléatoire",
+ "radio": "Radio",
+ "topSongs": "Meilleurs titres"
}
},
"user": {
@@ -140,10 +149,12 @@
"currentPassword": "Mot de passe actuel",
"newPassword": "Nouveau mot de passe",
"token": "Token",
- "lastAccessAt": "Dernier accès"
+ "lastAccessAt": "Dernier accès",
+ "libraries": "Bibliothèques"
},
"helperTexts": {
- "name": "Les changements liés à votre nom ne seront reflétés qu'à la prochaine connexion"
+ "name": "Les changements liés à votre nom ne seront reflétés qu'à la prochaine connexion",
+ "libraries": "Sélectionner une bibliothèque pour cet utilisateur ou laisser vide pour utiliser la bibliothèque par défaut"
},
"notifications": {
"created": "Utilisateur créé",
@@ -152,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Entrez votre token ListenBrainz.",
- "clickHereForToken": "Cliquez ici pour recevoir votre token"
+ "clickHereForToken": "Cliquez ici pour recevoir votre token",
+ "selectAllLibraries": "Sélectionner toutes les bibliothèques",
+ "adminAutoLibraries": "Les utilisateurs admin ont automatiquement accès à l'ensemble des bibliothèques"
+ },
+ "validation": {
+ "librariesRequired": "Au moins une bibliothèque doit être sélectionnée pour les utilisateurs non administrateurs"
}
},
"player": {
@@ -164,7 +180,7 @@
"client": "Client",
"userName": "Nom d'utilisateur",
"lastSeen": "Vu pour la dernière fois",
- "reportRealPath": "Rapporter le chemin absolu",
+ "reportRealPath": "Rapporter le chemin d'accès absolu",
"scrobbleEnabled": "Scrobbler vers des services externes"
}
},
@@ -192,16 +208,21 @@
"path": "Importer depuis"
},
"actions": {
- "selectPlaylist": "Ajouter les pistes à la playlist",
+ "selectPlaylist": "Sélectionner une playlist :",
"addNewPlaylist": "Créer \"%{name}\"",
"export": "Exporter",
"makePublic": "Rendre publique",
"makePrivate": "Rendre privée",
- "saveQueue": "Sauvegarder la file de lecture dans la playlist"
+ "saveQueue": "Sauvegarder la file de lecture dans la playlist",
+ "searchOrCreate": "Chercher ou créer une nouvelle playlist...",
+ "pressEnterToCreate": "Appuyer sur entrée pour créer une nouvelle playlist",
+ "removeFromSelection": "Supprimer de la sélection"
},
"message": {
- "duplicate_song": "Pistes déjà présentes dans la playlist",
- "song_exist": "Certaines des pistes sélectionnées font déjà partie de la playlist. Voulez-vous les ajouter ou les ignorer ?"
+ "duplicate_song": "Ajouter les titres déjà présents dans la playlist",
+ "song_exist": "Certains des titres sélectionnés font déjà partie de la playlist. Voulez-vous les ajouter ou les ignorer ?",
+ "noPlaylistsFound": "Aucune playlist trouvée",
+ "noPlaylists": "Aucune playlist disponible"
}
},
"radio": {
@@ -237,9 +258,10 @@
"missing": {
"name": "Fichier manquant|||| Fichiers manquants",
"fields": {
- "path": "Chemin",
+ "path": "Chemin d'accès",
"size": "Taille",
- "updatedAt": "A disparu le"
+ "updatedAt": "A disparu le",
+ "libraryName": "Bibliothèque"
},
"actions": {
"remove": "Supprimer",
@@ -249,6 +271,63 @@
"removed": "Fichier(s) manquant(s) supprimé(s)"
},
"empty": "Aucun fichier manquant"
+ },
+ "library": {
+ "name": "Bibliothèque |||| Bibliothèques",
+ "fields": {
+ "name": "Nom",
+ "path": "Chemin d'accès",
+ "remotePath": "Chemin d'accès distant",
+ "lastScanAt": "Dernier scan",
+ "songCount": "Titres",
+ "albumCount": "Albums",
+ "artistCount": "Artistes",
+ "totalSongs": "Titres",
+ "totalAlbums": "Albums",
+ "totalArtists": "Artistes",
+ "totalFolders": "Dossiers",
+ "totalFiles": "Fichiers",
+ "totalMissingFiles": "Fichiers manquants",
+ "totalSize": "Taille totale",
+ "totalDuration": "Durée",
+ "defaultNewUsers": "Défaut pour les nouveaux utilisateurs",
+ "createdAt": "Crée",
+ "updatedAt": "Mise à jour"
+ },
+ "sections": {
+ "basic": "Informations",
+ "statistics": "Statistiques"
+ },
+ "actions": {
+ "scan": "Scanner la bibliothèque",
+ "manageUsers": "Gérer les accès utilisateurs",
+ "viewDetails": "Voir les détails",
+ "quickScan": "Scan Rapide",
+ "fullScan": "Scan Complet"
+ },
+ "notifications": {
+ "created": "Bibliothèque créée avec succès",
+ "updated": "Bibliothèque mise à jour avec succès",
+ "deleted": "Bibliothèque supprimée avec succès",
+ "scanStarted": "Le scan de la bibliothèque a commencé",
+ "scanCompleted": "Le scan de la bibliothèque est terminé",
+ "quickScanStarted": "Scan rapide démarré",
+ "fullScanStarted": "Scan complet démarré",
+ "scanError": "Une erreur est survenue en démarrant le scan. Veuillez regarder les logs"
+ },
+ "validation": {
+ "nameRequired": "La bibliothèque doit obligatoirement avoir un nom",
+ "pathRequired": "La bibliothèque doit obligatoirement avoir un chemin d'accès",
+ "pathNotDirectory": "Le chemin d'accès de la bibliothèque doit pointer sur un dossier",
+ "pathNotFound": "Impossible de trouver ce chemin d'accès",
+ "pathNotAccessible": "Impossible d'accéder à ce chemin d'accès",
+ "pathInvalid": "Ce chemin d'accès n'est pas valide"
+ },
+ "messages": {
+ "deleteConfirm": "Êtes-vous sûr(e) de vouloir supprimer cette bibliothèque ? Cela supprimera toutes les données associées ainsi que les accès utilisateurs.",
+ "scanInProgress": "Scan en cours...",
+ "noLibrariesAssigned": "Aucune bibliothèque pour cet utilisateur"
+ }
}
},
"ra": {
@@ -400,7 +479,7 @@
"note": "NOTE",
"transcodingDisabled": "Le changement de paramètres depuis l'interface web est désactivé pour des raisons de sécurité. Pour changer (éditer ou supprimer) les options de transcodage, relancer le serveur avec l'option %{config} activée.",
"transcodingEnabled": "Navidrome fonctionne actuellement avec %{config}, rendant possible l’exécution de commandes arbitraires depuis l'interface web. Il est recommandé d'activer cette fonctionnalité uniquement lors de la configuration du transcodage.",
- "songsAddedToPlaylist": "Une piste a été ajoutée à la playlist |||| %{smart_count} pistes ont été ajoutées à la playlist",
+ "songsAddedToPlaylist": "1 titre a été ajouté à la playlist |||| %{smart_count} titres ont été ajoutés à la playlist",
"noPlaylistsAvailable": "Aucune playlist",
"delete_user_title": "Supprimer l'utilisateur '%{name}'",
"delete_user_content": "Êtes-vous sûr(e) de vouloir supprimer cet utilisateur et ses données associées (y compris ses playlists et préférences) ?",
@@ -430,7 +509,9 @@
"remove_missing_title": "Supprimer les fichiers manquants",
"remove_missing_content": "Êtes-vous sûr(e) de vouloir supprimer les fichiers manquants sélectionnés de la base de données ? Ceci supprimera définitivement toute référence à ceux-ci, y compris leurs nombres d'écoutes et leurs notations",
"remove_all_missing_title": "Supprimer tous les fichiers manquants",
- "remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence."
+ "remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence.",
+ "noSimilarSongsFound": "Aucun titre similaire n'a été trouvé",
+ "noTopSongsFound": "Aucun meilleur titre n'a été trouvé"
},
"menu": {
"library": "Bibliothèque",
@@ -451,7 +532,7 @@
"gain": {
"none": "Désactivé",
"album": "Utiliser le gain de l'album",
- "track": "Utiliser le gain des pistes"
+ "track": "Utiliser le gain des titres"
},
"lastfmNotConfigured": "La clef API de Last.fm n'est pas configurée"
}
@@ -459,7 +540,13 @@
"albumList": "Albums",
"about": "À propos",
"playlists": "Playlists",
- "sharedPlaylists": "Playlists partagées"
+ "sharedPlaylists": "Playlists partagées",
+ "librarySelector": {
+ "allLibraries": "Toutes les bibliothèques (%{count})",
+ "multipleLibraries": "%{selected} bibliothèque(s) sélectionnée(s) sur %{total}",
+ "selectLibraries": "Sélectionner les bibliothèques",
+ "none": "Aucune"
+ }
},
"player": {
"playListsText": "File de lecture",
@@ -496,6 +583,21 @@
"disabled": "Désactivée",
"waiting": "En attente"
}
+ },
+ "tabs": {
+ "about": "À propos",
+ "config": "Paramètres"
+ },
+ "config": {
+ "configName": "Nom de la configuration",
+ "environmentVariable": "Variable d'environnement",
+ "currentValue": "Valeur actuelle",
+ "configurationFile": "Fichier de configuration",
+ "exportToml": "Exporter la configuration (TOML)",
+ "exportSuccess": "La configuration a été copiée vers le presse-papier au format TOML",
+ "exportFailed": "Une erreur est survenue en copiant la configuration",
+ "devFlagsHeader": "Options de développement (peuvent être amenés à changer / être supprimés)",
+ "devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur"
}
},
"activity": {
@@ -507,7 +609,8 @@
"serverDown": "HORS LIGNE",
"scanType": "Type",
"status": "Erreur de scan",
- "elapsedTime": "Temps écoulé"
+ "elapsedTime": "Temps écoulé",
+ "selectiveScan": "Sélectif"
},
"help": {
"title": "Raccourcis Navidrome",
@@ -520,7 +623,12 @@
"vol_up": "Augmenter le volume",
"vol_down": "Baisser le volume",
"toggle_love": "Ajouter/Enlever le morceau des favoris",
- "current_song": "Aller à la chanson en cours"
+ "current_song": "Aller au titre en cours"
}
+ },
+ "nowPlaying": {
+ "title": "En cours de lecture",
+ "empty": "Aucun titre en cours de lecture",
+ "minutesAgo": "Il y a %{smart_count} minute |||| Il y a %{smart_count} minutes"
}
}
\ No newline at end of file
diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json
index 4d9a1a9a0..a6c3beb05 100644
--- a/resources/i18n/gl.json
+++ b/resources/i18n/gl.json
@@ -31,8 +31,12 @@
"mood": "Estado",
"participants": "Participantes adicionais",
"tags": "Etiquetas adicionais",
- "mappedTags": "",
- "rawTags": "Etiquetas en cru"
+ "mappedTags": "Etiquetas mapeadas",
+ "rawTags": "Etiquetas en cru",
+ "bitDepth": "Calidade de Bit",
+ "sampleRate": "Taxa de mostra",
+ "missing": "Falta",
+ "libraryName": "Biblioteca"
},
"actions": {
"addToQueue": "Ao final da cola",
@@ -41,7 +45,8 @@
"shuffleAll": "Remexer todo",
"download": "Descargar",
"playNext": "A continuación",
- "info": "Obter info"
+ "info": "Obter info",
+ "showInPlaylist": "Mostrar en Lista de reprodución"
}
},
"album": {
@@ -63,14 +68,17 @@
"size": "Tamaño",
"originalDate": "Orixinal",
"releaseDate": "Publicado",
- "releases": "Publicación ||| Publicacións",
+ "releases": "Publicación |||| Publicacións",
"released": "Publicado",
"recordLabel": "Editorial",
"catalogNum": "Número de catálogo",
"releaseType": "Tipo",
"grouping": "Grupos",
"media": "Multimedia",
- "mood": "Estado"
+ "mood": "Estado",
+ "date": "Data de gravación",
+ "missing": "Falta",
+ "libraryName": "Biblioteca"
},
"actions": {
"playAll": "Reproducir",
@@ -102,7 +110,8 @@
"rating": "Valoración",
"genre": "Xénero",
"size": "Tamaño",
- "role": "Rol"
+ "role": "Rol",
+ "missing": "Falta"
},
"roles": {
"albumartist": "Artista do álbum |||| Artistas do álbum",
@@ -117,7 +126,13 @@
"mixer": "Mistura |||| Mistura",
"remixer": "Remezcla |||| Remezcla",
"djmixer": "Mezcla DJs |||| Mezcla DJs",
- "performer": "Intérprete |||| Intérpretes"
+ "performer": "Intérprete |||| Intérpretes",
+ "maincredit": "Artista do álbum ou Artista |||| Artistas do álbum ou Artistas"
+ },
+ "actions": {
+ "shuffle": "Barallar",
+ "radio": "Radio",
+ "topSongs": "Cancións destacadas"
}
},
"user": {
@@ -134,10 +149,12 @@
"currentPassword": "Contrasinal actual",
"newPassword": "Novo contrasinal",
"token": "Token",
- "lastAccessAt": "Último acceso"
+ "lastAccessAt": "Último acceso",
+ "libraries": "Bibliotecas"
},
"helperTexts": {
- "name": "Os cambios no nome aplicaranse a próxima vez que accedas"
+ "name": "Os cambios no nome aplicaranse a próxima vez que accedas",
+ "libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto"
},
"notifications": {
"created": "Creouse a usuaria",
@@ -146,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Escribe o token de usuaria de ListenBrainz",
- "clickHereForToken": "Preme aquí para obter o token"
+ "clickHereForToken": "Preme aquí para obter o token",
+ "selectAllLibraries": "Seleccionar todas as bibliotecas",
+ "adminAutoLibraries": "As usuarias Admin teñen acceso por defecto a todas as bibliotecas"
+ },
+ "validation": {
+ "librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins"
}
},
"player": {
@@ -190,11 +212,17 @@
"addNewPlaylist": "Crear \"%{name}\"",
"export": "Exportar",
"makePublic": "Facela Pública",
- "makePrivate": "Facela Privada"
+ "makePrivate": "Facela Privada",
+ "saveQueue": "Salvar a Cola como Lista de reprodución",
+ "searchOrCreate": "Buscar listas ou escribe para crear nova…",
+ "pressEnterToCreate": "Preme Enter para crear nova lista",
+ "removeFromSelection": "Retirar da selección"
},
"message": {
"duplicate_song": "Engadir cancións duplicadas",
- "song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?"
+ "song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?",
+ "noPlaylistsFound": "Sen listas de reprodución",
+ "noPlaylists": "Sen listas dispoñibles"
}
},
"radio": {
@@ -232,13 +260,68 @@
"fields": {
"path": "Ruta",
"size": "Tamaño",
- "updatedAt": "Desapareceu o"
+ "updatedAt": "Desapareceu o",
+ "libraryName": "Biblioteca"
},
"actions": {
- "remove": "Retirar"
+ "remove": "Retirar",
+ "remove_all": "Retirar todo"
},
"notifications": {
"removed": "Ficheiro(s) faltantes retirados"
+ },
+ "empty": "Sen ficheiros faltantes"
+ },
+ "library": {
+ "name": "Biblioteca |||| Bibliotecas",
+ "fields": {
+ "name": "Nome",
+ "path": "Ruta",
+ "remotePath": "Ruta remota",
+ "lastScanAt": "Último escaneado",
+ "songCount": "Cancións",
+ "albumCount": "Álbums",
+ "artistCount": "Artistas",
+ "totalSongs": "Cancións",
+ "totalAlbums": "Álbums",
+ "totalArtists": "Artistas",
+ "totalFolders": "Cartafoles",
+ "totalFiles": "Ficheiros",
+ "totalMissingFiles": "Ficheiros que faltan",
+ "totalSize": "Tamaño total",
+ "totalDuration": "Duración",
+ "defaultNewUsers": "Por defecto para novas usuarias",
+ "createdAt": "Creada",
+ "updatedAt": "Actualizada"
+ },
+ "sections": {
+ "basic": "Información básica",
+ "statistics": "Estatísticas"
+ },
+ "actions": {
+ "scan": "Escanear Biblioteca",
+ "manageUsers": "Xestionar acceso das usuarias",
+ "viewDetails": "Ver detalles"
+ },
+ "notifications": {
+ "created": "Biblioteca creada correctamente",
+ "updated": "Biblioteca actualizada correctamente",
+ "deleted": "Biblioteca eliminada correctamente",
+ "scanStarted": "Comezou o escaneo da biblioteca",
+ "scanCompleted": "Completouse o escaneado da biblioteca"
+ },
+ "validation": {
+ "nameRequired": "Requírese un nome para a biblioteca",
+ "pathRequired": "Requírese unha ruta para a biblioteca",
+ "pathNotDirectory": "A ruta á biblioteca ten que ser un directorio",
+ "pathNotFound": "Non se atopa a ruta á biblioteca",
+ "pathNotAccessible": "A ruta á biblioteca non é accesible",
+ "pathInvalid": "Ruta non válida á biblioteca"
+ },
+ "messages": {
+ "deleteConfirm": "Tes certeza de querer eliminar esta biblioteca? Isto eliminará todos os datos asociados e accesos de usuarias.",
+ "scanInProgress": "Escaneo en progreso…",
+ "noLibrariesAssigned": "Sen bibliotecas asignadas a esta usuaria"
}
}
},
@@ -419,7 +502,11 @@
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copiar ao portapapeis: Ctrl+C, Enter",
"remove_missing_title": "Retirar ficheiros que faltan",
- "remove_missing_content": "Tes certeza de querer retirar da base de datos os ficheiros que faltan? Isto retirará de xeito permanente todas a referencias a eles, incluíndo a conta de reproducións e valoracións."
+ "remove_missing_content": "Tes certeza de querer retirar da base de datos os ficheiros que faltan? Isto retirará de xeito permanente todas a referencias a eles, incluíndo a conta de reproducións e valoracións.",
+ "remove_all_missing_title": "Retirar todos os ficheiros que faltan",
+ "remove_all_missing_content": "Tes certeza de querer retirar da base de datos todos os ficheiros que faltan? Isto eliminará todas as referencias a eles, incluíndo o número de reproducións e valoracións.",
+ "noSimilarSongsFound": "Sen cancións parecidas",
+ "noTopSongsFound": "Sen cancións destacadas"
},
"menu": {
"library": "Biblioteca",
@@ -448,7 +535,13 @@
"albumList": "Álbums",
"about": "Acerca de",
"playlists": "Listas de reprodución",
- "sharedPlaylists": "Listas compartidas"
+ "sharedPlaylists": "Listas compartidas",
+ "librarySelector": {
+ "allLibraries": "Todas as bibliotecas (%{count})",
+ "multipleLibraries": "%{selected} de %{total} Bibliotecas",
+ "selectLibraries": "Seleccionar Bibliotecas",
+ "none": "Ningunha"
+ }
},
"player": {
"playListsText": "Reproducir cola",
@@ -485,6 +578,21 @@
"disabled": "Desactivado",
"waiting": "Agardando"
}
+ },
+ "tabs": {
+ "about": "Sobre",
+ "config": "Configuración"
+ },
+ "config": {
+ "configName": "Nome",
+ "environmentVariable": "Variable de entorno",
+ "currentValue": "Valor actual",
+ "configurationFile": "Ficheiro de configuración",
+ "exportToml": "Exportar configuración (TOML)",
+ "exportSuccess": "Configuración exportada ao portapapeis no formato TOML",
+ "exportFailed": "Fallou a copia da configuración",
+ "devFlagsHeader": "Configuracións de Desenvolvemento (suxeitas a cambio/retirada)",
+ "devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións"
}
},
"activity": {
@@ -493,7 +601,10 @@
"quickScan": "Escaneo rápido",
"fullScan": "Escaneo completo",
"serverUptime": "Servidor a funcionar",
- "serverDown": "SEN CONEXIÓN"
+ "serverDown": "SEN CONEXIÓN",
+ "scanType": "Tipo",
+ "status": "Erro de escaneado",
+ "elapsedTime": "Tempo transcurrido"
},
"help": {
"title": "Atallos de Navidrome",
@@ -508,5 +619,10 @@
"toggle_love": "Engadir canción a favoritas",
"current_song": "Ir á Canción actual "
}
+ },
+ "nowPlaying": {
+ "title": "En reprodución",
+ "empty": "Sen reprodución",
+ "minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos"
}
}
\ No newline at end of file
diff --git a/resources/i18n/hi.json b/resources/i18n/hi.json
new file mode 100644
index 000000000..5b9ece530
--- /dev/null
+++ b/resources/i18n/hi.json
@@ -0,0 +1,630 @@
+{
+ "languageName": "हिंदी",
+ "resources": {
+ "song": {
+ "name": "गाना |||| गाने",
+ "fields": {
+ "albumArtist": "एल्बम कलाकार",
+ "duration": "समय",
+ "trackNumber": "#",
+ "playCount": "प्ले संख्या",
+ "title": "शीर्षक",
+ "artist": "कलाकार",
+ "album": "एल्बम",
+ "path": "फ़ाइल पथ",
+ "libraryName": "लाइब्रेरी",
+ "genre": "शैली",
+ "compilation": "संकलन",
+ "year": "वर्ष",
+ "size": "फ़ाइल का आकार",
+ "updatedAt": "अपडेट किया गया",
+ "bitRate": "बिट रेट",
+ "bitDepth": "बिट गहराई",
+ "sampleRate": "सैंपल रेट",
+ "channels": "चैनल",
+ "discSubtitle": "डिस्क उपशीर्षक",
+ "starred": "पसंदीदा",
+ "comment": "टिप्पणी",
+ "rating": "रेटिंग",
+ "quality": "गुणवत्ता",
+ "bpm": "BPM",
+ "playDate": "अंतिम बार चलाया गया",
+ "createdAt": "जोड़ने की तारीख",
+ "grouping": "समूहीकरण",
+ "mood": "मूड",
+ "participants": "अतिरिक्त प्रतिभागी",
+ "tags": "अतिरिक्त टैग",
+ "mappedTags": "मैप किए गए टैग",
+ "rawTags": "रॉ टैग",
+ "missing": "गुम"
+ },
+ "actions": {
+ "addToQueue": "बाद में चलाएं",
+ "playNow": "अभी चलाएं",
+ "addToPlaylist": "प्लेलिस्ट में जोड़ें",
+ "showInPlaylist": "प्लेलिस्ट में दिखाएं",
+ "shuffleAll": "सभी को शफल करें",
+ "download": "डाउनलोड",
+ "playNext": "अगला चलाएं",
+ "info": "जानकारी प्राप्त करें"
+ }
+ },
+ "album": {
+ "name": "एल्बम |||| एल्बम",
+ "fields": {
+ "albumArtist": "एल्बम कलाकार",
+ "artist": "कलाकार",
+ "duration": "समय",
+ "songCount": "गाने",
+ "playCount": "प्ले संख्या",
+ "size": "आकार",
+ "name": "नाम",
+ "libraryName": "लाइब्रेरी",
+ "genre": "शैली",
+ "compilation": "संकलन",
+ "year": "वर्ष",
+ "date": "रिकॉर्डिंग की तारीख",
+ "originalDate": "मूल",
+ "releaseDate": "रिलीज़",
+ "releases": "रिलीज़ |||| रिलीज़",
+ "released": "रिलीज़ किया गया",
+ "updatedAt": "अपडेट किया गया",
+ "comment": "टिप्पणी",
+ "rating": "रेटिंग",
+ "createdAt": "जोड़ने की तारीख",
+ "recordLabel": "लेबल",
+ "catalogNum": "कैटलॉग नंबर",
+ "releaseType": "प्रकार",
+ "grouping": "समूहीकरण",
+ "media": "मीडिया",
+ "mood": "मूड",
+ "missing": "गुम"
+ },
+ "actions": {
+ "playAll": "चलाएं",
+ "playNext": "अगला चलाएं",
+ "addToQueue": "बाद में चलाएं",
+ "share": "साझा करें",
+ "shuffle": "शफल",
+ "addToPlaylist": "प्लेलिस्ट में जोड़ें",
+ "download": "डाउनलोड",
+ "info": "जानकारी प्राप्त करें"
+ },
+ "lists": {
+ "all": "सभी",
+ "random": "रैंडम",
+ "recentlyAdded": "हाल ही में जोड़े गए",
+ "recentlyPlayed": "हाल ही में चलाए गए",
+ "mostPlayed": "सबसे ज्यादा चलाए गए",
+ "starred": "पसंदीदा",
+ "topRated": "टॉप रेटेड"
+ }
+ },
+ "artist": {
+ "name": "कलाकार |||| कलाकार",
+ "fields": {
+ "name": "नाम",
+ "albumCount": "एल्बम की संख्या",
+ "songCount": "गानों की संख्या",
+ "size": "आकार",
+ "playCount": "प्ले संख्या",
+ "rating": "रेटिंग",
+ "genre": "शैली",
+ "role": "भूमिका",
+ "missing": "गुम"
+ },
+ "roles": {
+ "albumartist": "एल्बम कलाकार |||| एल्बम कलाकार",
+ "artist": "कलाकार |||| कलाकार",
+ "composer": "संगीतकार |||| संगीतकार",
+ "conductor": "संचालक |||| संचालक",
+ "lyricist": "गीतकार |||| गीतकार",
+ "arranger": "संयोजक |||| संयोजक",
+ "producer": "निर्माता |||| निर्माता",
+ "director": "निदेशक |||| निदेशक",
+ "engineer": "इंजीनियर |||| इंजीनियर",
+ "mixer": "मिक्सर |||| मिक्सर",
+ "remixer": "रीमिक्सर |||| रीमिक्सर",
+ "djmixer": "डीजे मिक्सर |||| डीजे मिक्सर",
+ "performer": "कलाकार |||| कलाकार",
+ "maincredit": "एल्बम कलाकार या कलाकार |||| एल्बम कलाकार या कलाकार"
+ },
+ "actions": {
+ "topSongs": "टॉप गाने",
+ "shuffle": "शफल",
+ "radio": "रेडियो"
+ }
+ },
+ "user": {
+ "name": "उपयोगकर्ता |||| उपयोगकर्ता",
+ "fields": {
+ "userName": "उपयोगकर्ता नाम",
+ "isAdmin": "एडमिन है",
+ "lastLoginAt": "अंतिम लॉगिन",
+ "lastAccessAt": "अंतिम पहुंच",
+ "updatedAt": "अपडेट किया गया",
+ "name": "नाम",
+ "password": "पासवर्ड",
+ "createdAt": "बनाया गया",
+ "changePassword": "पासवर्ड बदलें?",
+ "currentPassword": "वर्तमान पासवर्ड",
+ "newPassword": "नया पासवर्ड",
+ "token": "टोकन",
+ "libraries": "लाइब्रेरी"
+ },
+ "helperTexts": {
+ "name": "आपके नाम में परिवर्तन केवल अगली लॉगिन पर प्रभावी होगा",
+ "libraries": "इस उपयोगकर्ता के लिए विशिष्ट लाइब्रेरी चुनें, या डिफ़ॉल्ट लाइब्रेरी का उपयोग करने के लिए खाली छोड़ें"
+ },
+ "notifications": {
+ "created": "उपयोगकर्ता बनाया गया",
+ "updated": "उपयोगकर्ता अपडेट किया गया",
+ "deleted": "उपयोगकर्ता हटाया गया"
+ },
+ "validation": {
+ "librariesRequired": "गैर-एडमिन उपयोगकर्ताओं के लिए कम से कम एक लाइब्रेरी चुननी होगी"
+ },
+ "message": {
+ "listenBrainzToken": "अपना ListenBrainz उपयोगकर्ता टोकन दर्ज करें।",
+ "clickHereForToken": "अपना टोकन प्राप्त करने के लिए यहां क्लिक करें",
+ "selectAllLibraries": "सभी लाइब्रेरी चुनें",
+ "adminAutoLibraries": "एडमिन उपयोगकर्ताओं की सभी लाइब्रेरी तक स्वचालित पहुंच है"
+ }
+ },
+ "player": {
+ "name": "प्लेयर |||| प्लेयर",
+ "fields": {
+ "name": "नाम",
+ "transcodingId": "ट्रांसकोडिंग",
+ "maxBitRate": "अधिकतम बिट रेट",
+ "client": "क्लाइंट",
+ "userName": "उपयोगकर्ता नाम",
+ "lastSeen": "अंतिम बार देखा गया",
+ "reportRealPath": "वास्तविक पथ रिपोर्ट करें",
+ "scrobbleEnabled": "बाहरी सेवाओं को स्क्रॉबल भेजें"
+ }
+ },
+ "transcoding": {
+ "name": "ट्रांसकोडिंग |||| ट्रांसकोडिंग",
+ "fields": {
+ "name": "नाम",
+ "targetFormat": "लक्ष्य प्रारूप",
+ "defaultBitRate": "डिफ़ॉल्ट बिट रेट",
+ "command": "कमांड"
+ }
+ },
+ "playlist": {
+ "name": "प्लेलिस्ट |||| प्लेलिस्ट",
+ "fields": {
+ "name": "नाम",
+ "duration": "अवधि",
+ "ownerName": "मालिक",
+ "public": "सार्वजनिक",
+ "updatedAt": "अपडेट किया गया",
+ "createdAt": "बनाया गया",
+ "songCount": "गाने",
+ "comment": "टिप्पणी",
+ "sync": "ऑटो-इंपोर्ट",
+ "path": "से इंपोर्ट करें"
+ },
+ "actions": {
+ "selectPlaylist": "एक प्लेलिस्ट चुनें:",
+ "addNewPlaylist": "\"%{name}\" बनाएं",
+ "export": "निर्यात",
+ "saveQueue": "क्यू को प्लेलिस्ट में सेव करें",
+ "makePublic": "सार्वजनिक बनाएं",
+ "makePrivate": "निजी बनाएं",
+ "searchOrCreate": "प्लेलिस्ट खोजें या नई बनाने के लिए टाइप करें...",
+ "pressEnterToCreate": "नई प्लेलिस्ट बनाने के लिए Enter दबाएं",
+ "removeFromSelection": "चयन से हटाएं"
+ },
+ "message": {
+ "duplicate_song": "डुप्लिकेट गाने जोड़ें",
+ "song_exist": "प्लेलिस्ट में डुप्लिकेट जोड़े जा रहे हैं। क्या आप डुप्लिकेट जोड़ना चाहते हैं या उन्हें छोड़ना चाहते हैं?",
+ "noPlaylistsFound": "कोई प्लेलिस्ट नहीं मिली",
+ "noPlaylists": "कोई प्लेलिस्ट उपलब्ध नहीं"
+ }
+ },
+ "radio": {
+ "name": "रेडियो |||| रेडियो",
+ "fields": {
+ "name": "नाम",
+ "streamUrl": "स्ट्रीम URL",
+ "homePageUrl": "होम पेज URL",
+ "updatedAt": "अपडेट किया गया",
+ "createdAt": "बनाया गया"
+ },
+ "actions": {
+ "playNow": "अभी चलाएं"
+ }
+ },
+ "share": {
+ "name": "साझा |||| साझा",
+ "fields": {
+ "username": "द्वारा साझा किया गया",
+ "url": "URL",
+ "description": "विवरण",
+ "downloadable": "डाउनलोड की अनुमति दें?",
+ "contents": "सामग्री",
+ "expiresAt": "समाप्त होता है",
+ "lastVisitedAt": "अंतिम बार देखा गया",
+ "visitCount": "विज़िट",
+ "format": "प्रारूप",
+ "maxBitRate": "अधिकतम बिट रेट",
+ "updatedAt": "अपडेट किया गया",
+ "createdAt": "बनाया गया"
+ },
+ "notifications": {},
+ "actions": {}
+ },
+ "missing": {
+ "name": "गुम फ़ाइल |||| गुम फ़ाइलें",
+ "empty": "कोई गुम फ़ाइल नहीं",
+ "fields": {
+ "path": "पथ",
+ "size": "आकार",
+ "libraryName": "लाइब्रेरी",
+ "updatedAt": "गायब हुई"
+ },
+ "actions": {
+ "remove": "हटाएं",
+ "remove_all": "सभी हटाएं"
+ },
+ "notifications": {
+ "removed": "गुम फ़ाइल(एं) हटा दी गईं"
+ }
+ },
+ "library": {
+ "name": "लाइब्रेरी |||| लाइब्रेरी",
+ "fields": {
+ "name": "नाम",
+ "path": "पथ",
+ "remotePath": "रिमोट पथ",
+ "lastScanAt": "अंतिम स्कैन",
+ "songCount": "गाने",
+ "albumCount": "एल्बम",
+ "artistCount": "कलाकार",
+ "totalSongs": "गाने",
+ "totalAlbums": "एल्बम",
+ "totalArtists": "कलाकार",
+ "totalFolders": "फ़ोल्डर",
+ "totalFiles": "फ़ाइलें",
+ "totalMissingFiles": "गुम फ़ाइलें",
+ "totalSize": "कुल आकार",
+ "totalDuration": "अवधि",
+ "defaultNewUsers": "नए उपयोगकर्ताओं के लिए डिफ़ॉल्ट",
+ "createdAt": "बनाया गया",
+ "updatedAt": "अपडेट किया गया"
+ },
+ "sections": {
+ "basic": "बुनियादी जानकारी",
+ "statistics": "आंकड़े"
+ },
+ "actions": {
+ "scan": "लाइब्रेरी स्कैन करें",
+ "manageUsers": "उपयोगकर्ता पहुंच प्रबंधित करें",
+ "viewDetails": "विवरण देखें"
+ },
+ "notifications": {
+ "created": "लाइब्रेरी सफलतापूर्वक बनाई गई",
+ "updated": "लाइब्रेरी सफलतापूर्वक अपडेट की गई",
+ "deleted": "लाइब्रेरी सफलतापूर्वक हटाई गई",
+ "scanStarted": "लाइब्रेरी स्कैन शुरू किया गया",
+ "scanCompleted": "लाइब्रेरी स्कैन पूरा हुआ"
+ },
+ "validation": {
+ "nameRequired": "लाइब्रेरी का नाम आवश्यक है",
+ "pathRequired": "लाइब्रेरी पथ आवश्यक है",
+ "pathNotDirectory": "लाइब्रेरी पथ एक डायरेक्टरी होना चाहिए",
+ "pathNotFound": "लाइब्रेरी पथ नहीं मिला",
+ "pathNotAccessible": "लाइब्रेरी पथ पहुंच योग्य नहीं है",
+ "pathInvalid": "अमान्य लाइब्रेरी पथ"
+ },
+ "messages": {
+ "deleteConfirm": "क्या आप वाकई इस लाइब्रेरी को हटाना चाहते हैं? इससे सभी संबंधित डेटा और उपयोगकर्ता पहुंच हट जाएगी।",
+ "scanInProgress": "स्कैन चल रहा है...",
+ "noLibrariesAssigned": "इस उपयोगकर्ता को कोई लाइब्रेरी असाइन नहीं की गई"
+ }
+ }
+ },
+ "ra": {
+ "auth": {
+ "welcome1": "Navidrome इंस्टॉल करने के लिए धन्यवाद!",
+ "welcome2": "शुरू करने के लिए, एक एडमिन उपयोगकर्ता बनाएं",
+ "confirmPassword": "पासवर्ड की पुष्टि करें",
+ "buttonCreateAdmin": "एडमिन बनाएं",
+ "auth_check_error": "जारी रखने के लिए कृपया लॉगिन करें",
+ "user_menu": "प्रोफ़ाइल",
+ "username": "उपयोगकर्ता नाम",
+ "password": "पासवर्ड",
+ "sign_in": "साइन इन",
+ "sign_in_error": "प्रमाणीकरण विफल, कृपया पुनः प्रयास करें",
+ "logout": "लॉगआउट",
+ "insightsCollectionNote": "Navidrome परियोजना को बेहतर बनाने में मदद के लिए\nअज्ञात उपयोग डेटा एकत्र करता है। अधिक जानने\nऔर चाहें तो ऑप्ट-आउट करने के लिए [यहां] क्लिक करें"
+ },
+ "validation": {
+ "invalidChars": "कृपया केवल अक्षर और संख्याओं का उपयोग करें।",
+ "passwordDoesNotMatch": "पासवर्ड मेल नहीं खाता।",
+ "required": "आवश्यक",
+ "minLength": "कम से कम %{min} अक्षर होने चाहिए",
+ "maxLength": "%{max} अक्षर या उससे कम होने चाहिए",
+ "minValue": "कम से कम %{min} होना चाहिए",
+ "maxValue": "%{max} या उससे कम होना चाहिए",
+ "number": "एक संख्या होनी चाहिए",
+ "email": "एक वैध ईमेल होना चाहिए",
+ "oneOf": "इनमें से एक होना चाहिए: %{options}",
+ "regex": "एक विशिष्ट प्रारूप से मेल खाना चाहिए (regexp): %{pattern}",
+ "unique": "अद्वितीय होना चाहिए",
+ "url": "एक वैध URL होना चाहिए"
+ },
+ "action": {
+ "add_filter": "फ़िल्टर जोड़ें",
+ "add": "जोड़ें",
+ "back": "वापस जाएं",
+ "bulk_actions": "1 आइटम चुना गया |||| %{smart_count} आइटम चुने गए",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
+ "cancel": "रद्द करें",
+ "clear_input_value": "मान साफ़ करें",
+ "clone": "क्लोन",
+ "confirm": "पुष्टि करें",
+ "create": "बनाएं",
+ "delete": "हटाएं",
+ "edit": "संपादित करें",
+ "export": "निर्यात",
+ "list": "सूची",
+ "refresh": "रीफ्रेश",
+ "remove_filter": "इस फ़िल्टर को हटाएं",
+ "remove": "हटाएं",
+ "save": "सेव करें",
+ "search": "खोजें",
+ "show": "दिखाएं",
+ "sort": "क्रमबद्ध करें",
+ "undo": "पूर्ववत करें",
+ "expand": "विस्तार करें",
+ "close": "बंद करें",
+ "open_menu": "मेनू खोलें",
+ "close_menu": "मेनू बंद करें",
+ "unselect": "चयन हटाएं",
+ "skip": "छोड़ें",
+ "share": "साझा करें",
+ "download": "डाउनलोड"
+ },
+ "boolean": {
+ "true": "हां",
+ "false": "नहीं"
+ },
+ "page": {
+ "create": "%{name} बनाएं",
+ "dashboard": "डैशबोर्ड",
+ "edit": "%{name} #%{id}",
+ "error": "कुछ गलत हुआ",
+ "list": "%{name}",
+ "loading": "लोड हो रहा है",
+ "not_found": "नहीं मिला",
+ "show": "%{name} #%{id}",
+ "empty": "अभी तक कोई %{name} नहीं।",
+ "invite": "क्या आप एक जोड़ना चाहते हैं?"
+ },
+ "input": {
+ "file": {
+ "upload_several": "अपलोड करने के लिए कुछ फ़ाइलें छोड़ें, या चुनने के लिए क्लिक करें।",
+ "upload_single": "अपलोड करने के लिए एक फ़ाइल छोड़ें, या इसे चुनने के लिए क्लिक करें।"
+ },
+ "image": {
+ "upload_several": "अपलोड करने के लिए कुछ तस्वीरें छोड़ें, या चुनने के लिए क्लिक करें।",
+ "upload_single": "अपलोड करने के लिए एक तस्वीर छोड़ें, या इसे चुनने के लिए क्लिक करें।"
+ },
+ "references": {
+ "all_missing": "संदर्भ डेटा खोजने में असमर्थ।",
+ "many_missing": "संबंधित संदर्भों में से कम से कम एक अब उपलब्ध नहीं लगता।",
+ "single_missing": "संबंधित संदर्भ अब उपलब्ध नहीं लगता।"
+ },
+ "password": {
+ "toggle_visible": "पासवर्ड छुपाएं",
+ "toggle_hidden": "पासवर्ड दिखाएं"
+ }
+ },
+ "message": {
+ "about": "के बारे में",
+ "are_you_sure": "क्या आप सुनिश्चित हैं?",
+ "bulk_delete_content": "क्या आप वाकई इस %{name} को हटाना चाहते हैं? |||| क्या आप वाकई इन %{smart_count} आइटमों को हटाना चाहते हैं?",
+ "bulk_delete_title": "%{name} हटाएं |||| %{smart_count} %{name} हटाएं",
+ "delete_content": "क्या आप वाकई इस आइटम को हटाना चाहते हैं?",
+ "delete_title": "%{name} #%{id} हटाएं",
+ "details": "विवरण",
+ "error": "एक क्लाइंट त्रुटि हुई और आपका अनुरोध पूरा नहीं हो सका।",
+ "invalid_form": "फॉर्म मान्य नहीं है। कृपया त्रुटियों की जांच करें।",
+ "loading": "पेज लोड हो रहा है, कृपया एक क्षण प्रतीक्षा करें",
+ "no": "नहीं",
+ "not_found": "या तो आपने गलत URL टाइप किया है, या आपने गलत लिंक फॉलो किया है।",
+ "yes": "हां",
+ "unsaved_changes": "आपके कुछ बदलाव सेव नहीं हुए। क्या आप वाकई उन्हें नज़रअंदाज़ करना चाहते हैं?"
+ },
+ "navigation": {
+ "no_results": "कोई परिणाम नहीं मिला",
+ "no_more_results": "पेज नंबर %{page} सीमा से बाहर है। पिछले पेज को आज़माएं।",
+ "page_out_of_boundaries": "पेज नंबर %{page} सीमा से बाहर",
+ "page_out_from_end": "अंतिम पेज के बाद नहीं जा सकते",
+ "page_out_from_begin": "पेज 1 से पहले नहीं जा सकते",
+ "page_range_info": "%{total} में से %{offsetBegin}-%{offsetEnd}",
+ "page_rows_per_page": "प्रति पेज आइटम:",
+ "next": "अगला",
+ "prev": "पिछला",
+ "skip_nav": "सामग्री पर जाएं"
+ },
+ "notification": {
+ "updated": "एलिमेंट अपडेट किया गया |||| %{smart_count} एलिमेंट अपडेट किए गए",
+ "created": "एलिमेंट बनाया गया",
+ "deleted": "एलिमेंट हटाया गया |||| %{smart_count} एलिमेंट हटाए गए",
+ "bad_item": "गलत एलिमेंट",
+ "item_doesnt_exist": "एलिमेंट मौजूद नहीं है",
+ "http_error": "सर्वर संचार त्रुटि",
+ "data_provider_error": "dataProvider त्रुटि। विवरण के लिए कंसोल जांचें।",
+ "i18n_error": "निर्दिष्ट भाषा के लिए अनुवाद लोड नहीं हो सकते",
+ "canceled": "कार्रवाई रद्द की गई",
+ "logged_out": "आपका सत्र समाप्त हो गया है, कृपया फिर से कनेक्ट करें।",
+ "new_version": "नया संस्करण उपलब्ध! कृपया इस विंडो को रीफ्रेश करें।"
+ },
+ "toggleFieldsMenu": {
+ "columnsToDisplay": "प्रदर्शित करने वाले कॉलम",
+ "layout": "लेआउट",
+ "grid": "ग्रिड",
+ "table": "टेबल"
+ }
+ },
+ "message": {
+ "note": "नोट",
+ "transcodingDisabled": "सुरक्षा कारणों से वेब इंटरफेस के माध्यम से ट्रांसकोडिंग कॉन्फ़िगरेशन बदलना अक्षम है। यदि आप ट्रांसकोडिंग विकल्प बदलना (संपादित या जोड़ना) चाहते हैं, तो %{config} कॉन्फ़िगरेशन विकल्प के साथ सर्वर को पुनः आरंभ करें।",
+ "transcodingEnabled": "Navidrome वर्तमान में %{config} के साथ चल रहा है, जो वेब इंटरफेस का उपयोग करके ट्रांसकोडिंग सेटिंग्स से सिस्टम कमांड चलाना संभव बनाता है। हम सुरक्षा कारणों से इसे अक्षम करने और केवल ट्रांसकोडिंग विकल्प कॉन्फ़िगर करते समय इसे सक्षम करने की सलाह देते हैं।",
+ "songsAddedToPlaylist": "प्लेलिस्ट में 1 गाना जोड़ा गया |||| प्लेलिस्ट में %{smart_count} गाने जोड़े गए",
+ "noSimilarSongsFound": "कोई समान गाने नहीं मिले",
+ "noTopSongsFound": "कोई टॉप गाने नहीं मिले",
+ "noPlaylistsAvailable": "कोई उपलब्ध नहीं",
+ "delete_user_title": "उपयोगकर्ता '%{name}' को हटाएं",
+ "delete_user_content": "क्या आप वाकई इस उपयोगकर्ता और उनके सभी डेटा (प्लेलिस्ट और प्राथमिकताओं सहित) को हटाना चाहते हैं?",
+ "remove_missing_title": "गुम फ़ाइलें हटाएं",
+ "remove_missing_content": "क्या आप वाकई चयनित गुम फ़ाइलों को डेटाबेस से हटाना चाहते हैं? इससे उनके सभी संदर्भ स्थायी रूप से हट जाएंगे, जिसमें उनकी प्ले काउंट और रेटिंग शामिल है।",
+ "remove_all_missing_title": "सभी गुम फ़ाइलें हटाएं",
+ "remove_all_missing_content": "क्या आप वाकई सभी गुम फ़ाइलों को डेटाबेस से हटाना चाहते हैं? इससे उनके सभी संदर्भ स्थायी रूप से हट जाएंगे, जिसमें उनकी प्ले काउंट और रेटिंग शामिल है।",
+ "notifications_blocked": "आपने अपने ब्राउज़र की सेटिंग्स में इस साइट के लिए सूचनाएं ब्लॉक की हैं।",
+ "notifications_not_available": "यह ब्राउज़र डेस्कटॉप सूचनाओं का समर्थन नहीं करता या आप https पर Navidrome का उपयोग नहीं कर रहे।",
+ "lastfmLinkSuccess": "Last.fm सफलतापूर्वक लिंक किया गया और स्क्रॉबलिंग सक्षम की गई",
+ "lastfmLinkFailure": "Last.fm लिंक नहीं हो सका",
+ "lastfmUnlinkSuccess": "Last.fm अनलिंक किया गया और स्क्रॉबलिंग अक्षम की गई",
+ "lastfmUnlinkFailure": "Last.fm अनलिंक नहीं हो सका",
+ "listenBrainzLinkSuccess": "ListenBrainz सफलतापूर्वक लिंक किया गया और उपयोगकर्ता के रूप में स्क्रॉबलिंग सक्षम की गई: %{user}",
+ "listenBrainzLinkFailure": "ListenBrainz लिंक नहीं हो सका: %{error}",
+ "listenBrainzUnlinkSuccess": "ListenBrainz अनलिंक किया गया और स्क्रॉबलिंग अक्षम की गई",
+ "listenBrainzUnlinkFailure": "ListenBrainz अनलिंक नहीं हो सका",
+ "openIn": {
+ "lastfm": "Last.fm में खोलें",
+ "musicbrainz": "MusicBrainz में खोलें"
+ },
+ "lastfmLink": "और पढ़ें...",
+ "shareOriginalFormat": "मूल प्रारूप में साझा करें",
+ "shareDialogTitle": "%{resource} '%{name}' साझा करें",
+ "shareBatchDialogTitle": "1 %{resource} साझा करें |||| %{smart_count} %{resource} साझा करें",
+ "shareCopyToClipboard": "क्लिपबोर्ड में कॉपी करें: Ctrl+C, Enter",
+ "shareSuccess": "URL क्लिपबोर्ड में कॉपी किया गया: %{url}",
+ "shareFailure": "URL %{url} को क्लिपबोर्ड में कॉपी करने में त्रुटि",
+ "downloadDialogTitle": "%{resource} '%{name}' (%{size}) डाउनलोड करें",
+ "downloadOriginalFormat": "मूल प्रारूप में डाउनलोड करें"
+ },
+ "menu": {
+ "library": "लाइब्रेरी",
+ "librarySelector": {
+ "allLibraries": "सभी लाइब्रेरी (%{count})",
+ "multipleLibraries": "%{total} में से %{selected} लाइब्रेरी",
+ "selectLibraries": "लाइब्रेरी चुनें",
+ "none": "कोई नहीं"
+ },
+ "settings": "सेटिंग्स",
+ "version": "संस्करण",
+ "theme": "थीम",
+ "personal": {
+ "name": "व्यक्तिगत",
+ "options": {
+ "theme": "थीम",
+ "language": "भाषा",
+ "defaultView": "डिफ़ॉल्ट दृश्य",
+ "desktop_notifications": "डेस्कटॉप सूचनाएं",
+ "lastfmNotConfigured": "Last.fm API-Key कॉन्फ़िगर नहीं है",
+ "lastfmScrobbling": "Last.fm में स्क्रॉबल करें",
+ "listenBrainzScrobbling": "ListenBrainz में स्क्रॉबल करें",
+ "replaygain": "ReplayGain मोड",
+ "preAmp": "ReplayGain PreAmp (dB)",
+ "gain": {
+ "none": "अक्षम",
+ "album": "एल्बम गेन का उपयोग करें",
+ "track": "ट्रैक गेन का उपयोग करें"
+ }
+ }
+ },
+ "albumList": "एल्बम",
+ "playlists": "प्लेलिस्ट",
+ "sharedPlaylists": "साझा की गई प्लेलिस्ट",
+ "about": "के बारे में"
+ },
+ "player": {
+ "playListsText": "प्ले क्यू",
+ "openText": "खोलें",
+ "closeText": "बंद करें",
+ "notContentText": "कोई संगीत नहीं",
+ "clickToPlayText": "चलाने के लिए क्लिक करें",
+ "clickToPauseText": "रोकने के लिए क्लिक करें",
+ "nextTrackText": "अगला ट्रैक",
+ "previousTrackText": "पिछला ट्रैक",
+ "reloadText": "रीलोड",
+ "volumeText": "वॉल्यूम",
+ "toggleLyricText": "गीत टॉगल करें",
+ "toggleMiniModeText": "मिनिमाइज़ करें",
+ "destroyText": "नष्ट करें",
+ "downloadText": "डाउनलोड",
+ "removeAudioListsText": "ऑडियो सूची हटाएं",
+ "clickToDeleteText": "%{name} को हटाने के लिए क्लिक करें",
+ "emptyLyricText": "कोई गीत नहीं",
+ "playModeText": {
+ "order": "क्रम में",
+ "orderLoop": "दोहराएं",
+ "singleLoop": "एक दोहराएं",
+ "shufflePlay": "शफल"
+ }
+ },
+ "about": {
+ "links": {
+ "homepage": "होम पेज",
+ "source": "सोर्स कोड",
+ "featureRequests": "फीचर अनुरोध",
+ "lastInsightsCollection": "अंतिम अंतर्दृष्टि संग्रह",
+ "insights": {
+ "disabled": "अक्षम",
+ "waiting": "प्रतीक्षा में"
+ }
+ },
+ "tabs": {
+ "about": "के बारे में",
+ "config": "कॉन्फ़िगरेशन"
+ },
+ "config": {
+ "configName": "कॉन्फ़िग नाम",
+ "environmentVariable": "पर्यावरण चर",
+ "currentValue": "वर्तमान मान",
+ "configurationFile": "कॉन्फ़िगरेशन फ़ाइल",
+ "exportToml": "कॉन्फ़िगरेशन निर्यात करें (TOML)",
+ "exportSuccess": "कॉन्फ़िगरेशन TOML प्रारूप में क्लिपबोर्ड में निर्यात किया गया",
+ "exportFailed": "कॉन्फ़िगरेशन कॉपी करने में विफल",
+ "devFlagsHeader": "विकास फ्लैग (परिवर्तन/हटाने के अधीन)",
+ "devFlagsComment": "ये प्रयोगात्मक सेटिंग्स हैं और भविष्य के संस्करणों में हटाई जा सकती हैं"
+ }
+ },
+ "activity": {
+ "title": "गतिविधि",
+ "totalScanned": "कुल स्कैन किए गए फ़ोल्डर",
+ "quickScan": "त्वरित स्कैन",
+ "fullScan": "पूर्ण स्कैन",
+ "serverUptime": "सर्वर अपटाइम",
+ "serverDown": "ऑफलाइन",
+ "scanType": "प्रकार",
+ "status": "स्कैन त्रुटि",
+ "elapsedTime": "बीता समय"
+ },
+ "nowPlaying": {
+ "title": "अभी चल रहा है",
+ "empty": "कुछ नहीं चल रहा",
+ "minutesAgo": "%{smart_count} मिनट पहले |||| %{smart_count} मिनट पहले"
+ },
+ "help": {
+ "title": "Navidrome हॉटकीज़",
+ "hotkeys": {
+ "show_help": "यह सहायता दिखाएं",
+ "toggle_menu": "मेनू साइड बार टॉगल करें",
+ "toggle_play": "चलाएं / रोकें",
+ "prev_song": "पिछला गाना",
+ "next_song": "अगला गाना",
+ "current_song": "वर्तमान गाने पर जाएं",
+ "vol_up": "वॉल्यूम बढ़ाएं",
+ "vol_down": "वॉल्यूम कम करें",
+ "toggle_love": "इस ट्रैक को पसंदीदा में जोड़ें"
+ }
+ }
+}
diff --git a/resources/i18n/hu.json b/resources/i18n/hu.json
index 8eb1a04f1..cbdd57109 100644
--- a/resources/i18n/hu.json
+++ b/resources/i18n/hu.json
@@ -12,6 +12,7 @@
"artist": "Előadó",
"album": "Album",
"path": "Elérési út",
+ "libraryName": "Könyvtár",
"genre": "Műfaj",
"compilation": "Válogatásalbum",
"year": "Év",
@@ -41,6 +42,7 @@
"addToQueue": "Lejátszás útolsóként",
"playNow": "Lejátszás",
"addToPlaylist": "Lejátszási listához adás",
+ "showInPlaylist": "Megjelenítés a lejátszási listában",
"shuffleAll": "Keverés",
"download": "Letöltés",
"playNext": "Lejátszás következőként",
@@ -56,6 +58,7 @@
"songCount": "Számok",
"playCount": "Lejátszások",
"name": "Név",
+ "libraryName": "Könyvtár",
"genre": "Stílus",
"compilation": "Válogatásalbum",
"year": "Év",
@@ -123,7 +126,13 @@
"mixer": "Keverő |||| Keverők",
"remixer": "Átdolgozó |||| Átdolgozók",
"djmixer": "DJ keverő |||| DJ keverők",
- "performer": "Előadóművész |||| Előadóművészek"
+ "performer": "Előadóművész |||| Előadóművészek",
+ "maincredit": "Album előadó vagy előadó |||| Album előadók vagy előadók"
+ },
+ "actions": {
+ "topSongs": "Top számok",
+ "shuffle": "Keverés",
+ "radio": "Rádió"
}
},
"user": {
@@ -140,19 +149,26 @@
"currentPassword": "Jelenlegi jelszó",
"newPassword": "Új jelszó",
"token": "Token",
- "lastAccessAt": "Utolsó elérés"
+ "lastAccessAt": "Utolsó elérés",
+ "libraries": "Könyvtárak"
},
"helperTexts": {
- "name": "A névváltoztatások csak a következő bejelentkezéskor jelennek meg"
+ "name": "A névváltoztatások csak a következő bejelentkezéskor jelennek meg",
+ "libraries": "Válassz könyvtárakat ehhez a felhasználóhoz vagy ne jelölj be egyet sem, az alapértelmezett könyvtárak használatához"
},
"notifications": {
"created": "Felhasználó létrehozva",
"updated": "Felhasználó frissítve",
"deleted": "Felhasználó törölve"
},
+ "validation": {
+ "librariesRequired": "Legalább egy könyvtárat ki kell választani nem admin felhasználókhoz"
+ },
"message": {
"listenBrainzToken": "Add meg a ListenBrainz felhasználó tokened.",
- "clickHereForToken": "Kattints ide, hogy megszerezd a tokened"
+ "clickHereForToken": "Kattints ide, hogy megszerezd a tokened",
+ "selectAllLibraries": "Minden könyvtár kiválasztása",
+ "adminAutoLibraries": "Minden admin felhasználó hozzáfér bármely könyvtárhoz"
}
},
"player": {
@@ -197,11 +213,16 @@
"export": "Exportálás",
"saveQueue": "Műsorlista elmentése lejátszási listaként",
"makePublic": "Publikussá tétel",
- "makePrivate": "Priváttá tétel"
+ "makePrivate": "Priváttá tétel",
+ "searchOrCreate": "Keress lejátszási listák között vagy hozz létre egyet...",
+ "pressEnterToCreate": "Nyomj Entert, hogy létrehozz egy lejátszási listát",
+ "removeFromSelection": "Eltávolítás a kiválasztásból"
},
"message": {
"duplicate_song": "Duplikált számok hozzáadása",
- "song_exist": "Egyes számok már hozzá vannak adva a listához. Még egyszer hozzá akarod adni?"
+ "song_exist": "Egyes számok már hozzá vannak adva a listához. Még egyszer hozzá akarod adni?",
+ "noPlaylistsFound": "Nem található lejátszási lista",
+ "noPlaylists": "Nincsenek lejátszási listák"
}
},
"radio": {
@@ -240,6 +261,7 @@
"fields": {
"path": "Útvonal",
"size": "Méret",
+ "libraryName": "Könyvtár",
"updatedAt": "Eltűnt ekkor:"
},
"actions": {
@@ -249,6 +271,60 @@
"notifications": {
"removed": "Hiányzó fájl(ok) eltávolítva"
}
+ },
+ "library": {
+ "name": "Könyvtár |||| Könyvtárak",
+ "fields": {
+ "name": "Név",
+ "path": "Elérési út",
+ "remotePath": "Távoli elérési út",
+ "lastScanAt": "Legutóbbi szkennelés",
+ "songCount": "Számok",
+ "albumCount": "Albumok",
+ "artistCount": "Előadók",
+ "totalSongs": "Számok",
+ "totalAlbums": "Albumok",
+ "totalArtists": "Előadók",
+ "totalFolders": "Mappák",
+ "totalFiles": "Fájlok",
+ "totalMissingFiles": "Hiányzó fájlok",
+ "totalSize": "Teljes méret",
+ "totalDuration": "Hossz",
+ "defaultNewUsers": "Alapértelmezett könyvtár új felhasználóknak",
+ "createdAt": "Létrehozva",
+ "updatedAt": "Frissítve"
+ },
+ "sections": {
+ "basic": "Alapinformációk",
+ "statistics": "Statisztikák"
+ },
+ "actions": {
+ "scan": "Könyvtár szkennelése",
+ "quickScan": "Gyors szkennelés",
+ "fullScan": "Teljes szkennelés",
+ "manageUsers": "Hozzáférés kezelése",
+ "viewDetails": "Részletek"
+ },
+ "notifications": {
+ "created": "Könyvtár létrehozva",
+ "updated": "Könyvtár frissítve",
+ "deleted": "Könyvtár törölve",
+ "scanStarted": "Szkennelés folyamatban",
+ "scanCompleted": "Könyvtár szkennelés befelyezve"
+ },
+ "validation": {
+ "nameRequired": "Adj meg egy könyvtárnevet",
+ "pathRequired": "Adj meg egy útvonalat",
+ "pathNotDirectory": "A könyvtárútvonalnak egy mappának kell lennie",
+ "pathNotFound": "A könyvtár útvonala nem található",
+ "pathNotAccessible": "A könyvtár útvonala nem elérhető",
+ "pathInvalid": "Helytelen könyvtár útvonal"
+ },
+ "messages": {
+ "deleteConfirm": "Biztosan törlöd ezt a könyvtárt? Minden adata törlődni fog és elérhetetlenné válik.",
+ "scanInProgress": "Szkennelés folyamatban...",
+ "noLibrariesAssigned": "Ehhez a felhasználóhoz nincsenek könyvtárak adva"
+ }
}
},
"ra": {
@@ -401,6 +477,8 @@
"transcodingDisabled": "Az átkódolási konfiguráció módosítása a webes felületen keresztül biztonsági okokból nem lehetséges. Ha módosítani szeretnéd az átkódolási beállításokat, indítsd újra a kiszolgálót a %{config} konfigurációs opcióval.",
"transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.",
"songsAddedToPlaylist": "1 szám hozzáadva a lejátszási listához |||| %{smart_count} szám hozzáadva a lejátszási listához",
+ "noSimilarSongsFound": "Nem találhatóak hasonló számok",
+ "noTopSongsFound": "Nincsenek top számok",
"noPlaylistsAvailable": "Nem áll rendelkezésre",
"delete_user_title": "Felhasználó törlése '%{name}'",
"delete_user_content": "Biztos, hogy törölni akarod ezt a felhasználót az adataival (beállítások és lejátszási listák) együtt?",
@@ -434,6 +512,12 @@
},
"menu": {
"library": "Könyvtár",
+ "librarySelector": {
+ "allLibraries": "Minden %{count} könyvtár",
+ "multipleLibraries": "%{selected} kiválasztva %{total} könyvtárból",
+ "selectLibraries": "Kiválasztott kőnyvtárak",
+ "none": "Semmi"
+ },
"settings": "Beállítások",
"version": "Verzió",
"theme": "Téma",
@@ -496,19 +580,40 @@
"disabled": "Kikapcsolva",
"waiting": "Várakozás"
}
+ },
+ "tabs": {
+ "about": "Rólunk",
+ "config": "Konfiguráció"
+ },
+ "config": {
+ "configName": "Beállítás neve",
+ "environmentVariable": "Környezeti változó",
+ "currentValue": "Jelenlegi érték",
+ "configurationFile": "Konfigurációs fájl",
+ "exportToml": "Konfiguráció exportálása (TOML)",
+ "exportSuccess": "Konfiguráció kiexportálva a vágólapra, TOML formában",
+ "exportFailed": "Nem sikerült kimásolni a konfigurációt",
+ "devFlagsHeader": "Fejlesztői beállítások (változások/eltávolítás jogát fenntartjuk)",
+ "devFlagsComment": "Ezek kísérleti beállítások, és a jövőbeli verziókban eltávolíthatók"
}
},
"activity": {
"title": "Aktivitás",
"totalScanned": "Összes beolvasott mappa:",
- "quickScan": "Gyors beolvasás",
- "fullScan": "Teljes beolvasás",
+ "quickScan": "Gyors",
+ "fullScan": "Teljes",
+ "selectiveScan": "Szelektív",
"serverUptime": "Szerver üzemidő",
"serverDown": "OFFLINE",
- "scanType": "Típus",
+ "scanType": "Legutóbbi szkennelés",
"status": "Szkennelési hiba",
"elapsedTime": "Eltelt idő"
},
+ "nowPlaying": {
+ "title": "Most megy",
+ "empty": "Nem hallgatsz semmit",
+ "minutesAgo": "%{smart_count} perce |||| %{smart_count} perce"
+ },
"help": {
"title": "Navidrome Gyorsbillentyűk",
"hotkeys": {
diff --git a/resources/i18n/id.json b/resources/i18n/id.json
index 0ce5d5d9a..38ee2fff9 100644
--- a/resources/i18n/id.json
+++ b/resources/i18n/id.json
@@ -35,7 +35,8 @@
"rawTags": "Tag raw",
"bitDepth": "Bit depth",
"sampleRate": "Sample rate",
- "missing": "Hilang"
+ "missing": "Hilang",
+ "libraryName": "Pustaka"
},
"actions": {
"addToQueue": "Tambah ke antrean",
@@ -44,7 +45,8 @@
"shuffleAll": "Acak Semua",
"download": "Unduh",
"playNext": "Putar Berikutnya",
- "info": "Lihat Info"
+ "info": "Lihat Info",
+ "showInPlaylist": "Tampilkan di Playlist"
}
},
"album": {
@@ -75,7 +77,8 @@
"media": "Media",
"mood": "Mood",
"date": "Tanggal Perekaman",
- "missing": "Hilang"
+ "missing": "Hilang",
+ "libraryName": "Pustaka"
},
"actions": {
"playAll": "Putar",
@@ -123,7 +126,13 @@
"mixer": "Mixer |||| Mixer",
"remixer": "Remixer |||| Remixer",
"djmixer": "DJ Mixer |||| Dj Mixer",
- "performer": "Performer |||| Performer"
+ "performer": "Performer |||| Performer",
+ "maincredit": "Artis Album atau Artis |||| Artis Album or Artis"
+ },
+ "actions": {
+ "shuffle": "Acak",
+ "radio": "Radio",
+ "topSongs": "Lagu Teratas"
}
},
"user": {
@@ -140,10 +149,12 @@
"currentPassword": "Kata Sandi Sebelumnya",
"newPassword": "Kata Sandi Baru",
"token": "Token",
- "lastAccessAt": "Terakhir Diakses"
+ "lastAccessAt": "Terakhir Diakses",
+ "libraries": "Perpustakaan"
},
"helperTexts": {
- "name": "Perubahan pada nama Kamu akan terlihat pada login berikutnya"
+ "name": "Perubahan pada nama Kamu akan terlihat pada login berikutnya",
+ "libraries": "Pilih pustaka yang ditentukan untuk pengguna ini, atau biarkan kosong untuk menggunakan pustaka default"
},
"notifications": {
"created": "Pengguna dibuat",
@@ -152,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Masukkan token pengguna ListenBrainz Kamu.",
- "clickHereForToken": "Klik di sini untuk mendapatkan token baru anda"
+ "clickHereForToken": "Klik di sini untuk mendapatkan token baru anda",
+ "selectAllLibraries": "Pilih semua pustaka",
+ "adminAutoLibraries": "Pengguna admin otomatis langsung memiliki akses ke semua perpustakaan"
+ },
+ "validation": {
+ "librariesRequired": "Setidaknya satu pustaka harus dipilih untuk pengguna non-admin"
}
},
"player": {
@@ -197,11 +213,16 @@
"export": "Ekspor",
"makePublic": "Jadikan Publik",
"makePrivate": "Jadikan Pribadi",
- "saveQueue": "Simpan Antrean ke Playlist"
+ "saveQueue": "Simpan Antrean ke Playlist",
+ "searchOrCreate": "Cari playlist atau ketik untuk buat baru..",
+ "pressEnterToCreate": "Tekan Enter untuk membuat playlist baru",
+ "removeFromSelection": "Hapus yang dipilih"
},
"message": {
"duplicate_song": "Tambahkan lagu duplikat",
- "song_exist": "Ada lagu duplikat yang ditambahkan ke daftar putar. Apakah Kamu ingin menambahkan lagu duplikat atau melewatkannya?"
+ "song_exist": "Ada lagu duplikat yang ditambahkan ke daftar putar. Apakah Kamu ingin menambahkan lagu duplikat atau melewatkannya?",
+ "noPlaylistsFound": "Playlist tidak ditemukan",
+ "noPlaylists": "Playlist tidak tersedia"
}
},
"radio": {
@@ -239,7 +260,8 @@
"fields": {
"path": "Jalur",
"size": "Ukuran",
- "updatedAt": "Tidak muncul di"
+ "updatedAt": "Tidak muncul di",
+ "libraryName": "Pustaka"
},
"actions": {
"remove": "Hapus",
@@ -249,6 +271,58 @@
"removed": "File yang hilang dihapus"
},
"empty": "Tidak ada File yang Hilang"
+ },
+ "library": {
+ "name": "Pustaka |||| Perpustakaan",
+ "fields": {
+ "name": "Nama",
+ "path": "Jalur",
+ "remotePath": "Jalur Remote",
+ "lastScanAt": "Terakhir Dipindai",
+ "songCount": "Lagu",
+ "albumCount": "Album",
+ "artistCount": "Artis",
+ "totalSongs": "Lagu",
+ "totalAlbums": "Album",
+ "totalArtists": "Artis",
+ "totalFolders": "Folder",
+ "totalFiles": "File",
+ "totalMissingFiles": "File hilang",
+ "totalSize": "Ukuran Total",
+ "totalDuration": "Durasi",
+ "defaultNewUsers": "Default untuk Pengguna Baru",
+ "createdAt": "Dibuat",
+ "updatedAt": "Diperbarui"
+ },
+ "sections": {
+ "basic": "Informasi Dasar",
+ "statistics": "Statistik"
+ },
+ "actions": {
+ "scan": "Pindai Pustaka",
+ "manageUsers": "Kelola Akses Pengguna",
+ "viewDetails": "Lihat Detail"
+ },
+ "notifications": {
+ "created": "Pustaka berhasil dibuat",
+ "updated": "Pustaka berhasil dibuat",
+ "deleted": "Berhasil menghapus pustaka",
+ "scanStarted": "Memindai pustaka dimulai",
+ "scanCompleted": "Memindai pustaka selesai"
+ },
+ "validation": {
+ "nameRequired": "Nama pustaka diperlukan",
+ "pathRequired": "Lokasi pustaka diperlukan",
+ "pathNotDirectory": "Lokasi pustaka harus ada di direktori",
+ "pathNotFound": "Lokasi pustaka tidak ditemukan",
+ "pathNotAccessible": "Lokasi pustaka tidak dapat diakses",
+ "pathInvalid": "Lokasi pustaka tidak valid"
+ },
+ "messages": {
+ "deleteConfirm": "Kamu yakin ingin menghapus pustaka ini? Ini akan menghapus semua data yang terkait dan akses pengguna.",
+ "scanInProgress": "Pemindaian sedang berlangsung...",
+ "noLibrariesAssigned": "Tidak ada pustaka yang ditugaskan ke pengguna ini"
+ }
}
},
"ra": {
@@ -430,7 +504,9 @@
"remove_missing_title": "Hapus file yang hilang",
"remove_missing_content": "Apakah Anda yakin ingin menghapus file-file yang hilang dari basis data? Tindakan ini akan menghapus secara permanen semua referensi ke file-file tersebut, termasuk jumlah pemutaran dan peringkatnya.",
"remove_all_missing_title": "Hapus semua file yang hilang",
- "remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka."
+ "remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka.",
+ "noSimilarSongsFound": "Tidak ada lagu yang serupa ditemukan",
+ "noTopSongsFound": "Tidak ada lagu teratas ditemukan"
},
"menu": {
"library": "Pustaka",
@@ -459,7 +535,13 @@
"albumList": "Album",
"about": "Tentang",
"playlists": "Playlist",
- "sharedPlaylists": "Playlist yang Dibagikan"
+ "sharedPlaylists": "Playlist yang Dibagikan",
+ "librarySelector": {
+ "allLibraries": "Semua Pustaka (%{count})",
+ "multipleLibraries": "Pustaka %{selected} dari %{total}",
+ "selectLibraries": "Pilih Perpustakaan",
+ "none": "Tidak ada"
+ }
},
"player": {
"playListsText": "Putar Antrean",
@@ -496,6 +578,21 @@
"disabled": "Nonaktifkan",
"waiting": "Menunggu"
}
+ },
+ "tabs": {
+ "about": "Tentang",
+ "config": "Konfigurasi"
+ },
+ "config": {
+ "configName": "Nama Konfigurasi",
+ "environmentVariable": "Variabel Environment",
+ "currentValue": "Value Saat Ini",
+ "configurationFile": "File Konfigurasi",
+ "exportToml": "Ekspor Konfigurasi (TOML)",
+ "exportSuccess": "Konfigurasi sudah diekspor ke papan klip dalam bentuk format TOML",
+ "exportFailed": "Gagal menyalin konfigurasi",
+ "devFlagsHeader": "Flag Pengembangan (subyek untuk perubahan/pemindahan)",
+ "devFlagsComment": "Ini adalan pengaturan eksperimen dan mungkin akan dihapus di versi mendatang"
}
},
"activity": {
@@ -522,5 +619,10 @@
"toggle_love": "Tambahkan lagu ini ke favorit",
"current_song": "Buka Lagu Saat Ini"
}
+ },
+ "nowPlaying": {
+ "title": "Sedang Diputar",
+ "empty": "Tidak ada yang diputar",
+ "minutesAgo": "%{smart_count} menit yang lalu |||| %{smart_count} menit yang lalu"
}
}
\ No newline at end of file
diff --git a/resources/i18n/it.json b/resources/i18n/it.json
index aaaa2f8c2..11fadb46b 100644
--- a/resources/i18n/it.json
+++ b/resources/i18n/it.json
@@ -232,7 +232,7 @@
"add_filter": "Aggiungi un filtro",
"add": "Aggiungi",
"back": "Indietro",
- "bulk_actions": "Un elemento selezionato ||| %{smart_count} elementi selezionati",
+ "bulk_actions": "Un elemento selezionato |||| %{smart_count} elementi selezionati",
"cancel": "Annulla",
"clear_input_value": "Cancella",
"clone": "Duplica",
@@ -400,8 +400,8 @@
},
"albumList": "Album",
"about": "Info",
- "playlists": "Scalette",
- "sharedPlaylists": "Scalette Condivise"
+ "playlists": "Playlist",
+ "sharedPlaylists": "Playlist Condivise"
},
"player": {
"playListsText": "Coda",
@@ -457,4 +457,4 @@
"current_song": ""
}
}
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/ja.json b/resources/i18n/ja.json
index fbf8cefd2..29975b92b 100644
--- a/resources/i18n/ja.json
+++ b/resources/i18n/ja.json
@@ -27,12 +27,16 @@
"playDate": "最後の再生",
"channels": "チャンネル",
"createdAt": "追加日",
- "grouping": "",
- "mood": "",
- "participants": "",
- "tags": "",
- "mappedTags": "",
- "rawTags": ""
+ "grouping": "グループ分け",
+ "mood": "ムード",
+ "participants": "追加参加者",
+ "tags": "追加タグ",
+ "mappedTags": "マッピング済みタグ",
+ "rawTags": "未処理タグ",
+ "bitDepth": "ビット深度",
+ "sampleRate": "サンプリングレート",
+ "missing": "不明",
+ "libraryName": "ライブラリ"
},
"actions": {
"addToQueue": "最後に再生",
@@ -41,7 +45,8 @@
"shuffleAll": "全曲シャッフル",
"download": "ダウンロード",
"playNext": "次に再生",
- "info": "詳細"
+ "info": "詳細",
+ "showInPlaylist": "含まれるプレイリスト"
}
},
"album": {
@@ -65,12 +70,15 @@
"releaseDate": "リリース日",
"releases": "リリース",
"released": "リリース",
- "recordLabel": "",
- "catalogNum": "",
- "releaseType": "",
- "grouping": "",
- "media": "",
- "mood": ""
+ "recordLabel": "ラベル",
+ "catalogNum": "カタログ番号",
+ "releaseType": "タイプ",
+ "grouping": "グループ分け",
+ "media": "メディア",
+ "mood": "ムード",
+ "date": "録音日",
+ "missing": "不明",
+ "libraryName": "ライブラリ"
},
"actions": {
"playAll": "再生",
@@ -102,22 +110,29 @@
"rating": "レート",
"genre": "ジャンル",
"size": "サイズ",
- "role": ""
+ "role": "役割",
+ "missing": "不明"
},
"roles": {
- "albumartist": "",
- "artist": "",
- "composer": "",
- "conductor": "",
- "lyricist": "",
- "arranger": "",
- "producer": "",
- "director": "",
- "engineer": "",
- "mixer": "",
- "remixer": "",
- "djmixer": "",
- "performer": ""
+ "albumartist": "アルバムアーティスト",
+ "artist": "アーティスト",
+ "composer": "作曲家",
+ "conductor": "指揮者",
+ "lyricist": "作詞家",
+ "arranger": "編曲者",
+ "producer": "プロデューサー",
+ "director": "ディレクター",
+ "engineer": "エンジニア",
+ "mixer": "ミキサー",
+ "remixer": "リミキサー",
+ "djmixer": "DJ ミキサー",
+ "performer": "演奏者",
+ "maincredit": "アルバムアーティストもしくはアーティスト"
+ },
+ "actions": {
+ "shuffle": "シャッフル",
+ "radio": "ラジオ",
+ "topSongs": "トップソング"
}
},
"user": {
@@ -134,10 +149,12 @@
"currentPassword": "現在のパスワード",
"newPassword": "新しいパスワード",
"token": "トークン",
- "lastAccessAt": "最終アクセス"
+ "lastAccessAt": "最終アクセス",
+ "libraries": "ライブラリ"
},
"helperTexts": {
- "name": "名前の変更は次回ログイン以降反映されます"
+ "name": "名前の変更は次回ログイン以降反映されます",
+ "libraries": "このユーザーに対して特定ライブラリを選択するか、デフォルトのライブラリを使用する場合は空欄のままにします"
},
"notifications": {
"created": "ユーザーが作成されました",
@@ -146,7 +163,12 @@
},
"message": {
"listenBrainzToken": "ListenBrainzユーザートークンを入力",
- "clickHereForToken": "ここをクリックしトークンを入手"
+ "clickHereForToken": "ここをクリックしトークンを入手",
+ "selectAllLibraries": "全てのライブラリを選択",
+ "adminAutoLibraries": "管理者ユーザーは自動的にすべてのライブラリにアクセスできます"
+ },
+ "validation": {
+ "librariesRequired": "管理者以外のユーザーには少なくとも1つのライブラリを選択する必要があります"
}
},
"player": {
@@ -190,11 +212,17 @@
"addNewPlaylist": "'%{name}' を作成",
"export": "エクスポート",
"makePublic": "公開する",
- "makePrivate": "非公開にする"
+ "makePrivate": "非公開にする",
+ "saveQueue": "キューをプレイリストに保存",
+ "searchOrCreate": "プレイリストを検索または入力して新規作成...",
+ "pressEnterToCreate": "Enterキーを押して新しいプレイリストを作成",
+ "removeFromSelection": "選択から削除"
},
"message": {
"duplicate_song": "重複する曲を追加",
- "song_exist": "既にプレイリストに存在する曲です。追加しますか?"
+ "song_exist": "既にプレイリストに存在する曲です。追加しますか?",
+ "noPlaylistsFound": "プレイリストが見つかりません",
+ "noPlaylists": "利用可能なプレイリストはありません"
}
},
"radio": {
@@ -228,17 +256,77 @@
}
},
"missing": {
- "name": "",
+ "name": "欠落したファイル",
"fields": {
- "path": "",
- "size": "",
- "updatedAt": ""
+ "path": "パス",
+ "size": "サイズ",
+ "updatedAt": "欠落日",
+ "libraryName": "ライブラリ"
},
"actions": {
- "remove": ""
+ "remove": "削除",
+ "remove_all": "全て削除"
},
"notifications": {
- "removed": ""
+ "removed": "欠落ファイルが削除されました"
+ },
+ "empty": "ファイルの欠落はありません"
+ },
+ "library": {
+ "name": "ライブラリ",
+ "fields": {
+ "name": "名前",
+ "path": "パス",
+ "remotePath": "リモートパス",
+ "lastScanAt": "最終スキャン",
+ "songCount": "曲数",
+ "albumCount": "アルバム数",
+ "artistCount": "アーティスト数",
+ "totalSongs": "曲数",
+ "totalAlbums": "アルバム数",
+ "totalArtists": "アーティスト数",
+ "totalFolders": "フォルダー数",
+ "totalFiles": "ファイル数",
+ "totalMissingFiles": "欠落したファイル",
+ "totalSize": "合計サイズ",
+ "totalDuration": "合計時間",
+ "defaultNewUsers": "新規ユーザーに対するデフォルト",
+ "createdAt": "作成日",
+ "updatedAt": "更新日"
+ },
+ "sections": {
+ "basic": "基本情報",
+ "statistics": "統計"
+ },
+ "actions": {
+ "scan": "ライブラリをスキャン",
+ "manageUsers": "ユーザーアクセス管理",
+ "viewDetails": "詳細を表示",
+ "quickScan": "クイックスキャン",
+ "fullScan": "フルスキャン"
+ },
+ "notifications": {
+ "created": "ライブラリが正常に作成されました",
+ "updated": "ライブラリが正常に更新されました",
+ "deleted": "ライブラリが正常に削除されました",
+ "scanStarted": "スキャンを開始しました",
+ "scanCompleted": "スキャンが完了しました",
+ "quickScanStarted": "クイックスキャンを開始しました",
+ "fullScanStarted": "フルスキャンを開始しました",
+ "scanError": "スキャン開始中にエラーが発生。ログを確認してください"
+ },
+ "validation": {
+ "nameRequired": "ライブラリの名前が必要です",
+ "pathRequired": "ライブラリのパスが必要です",
+ "pathNotDirectory": "ライブラリパスはディレクトリである必要があります",
+ "pathNotFound": "ライブラリのパスが見つかりません",
+ "pathNotAccessible": "ライブラリパスへアクセスできません",
+ "pathInvalid": "無効なライブラリパス"
+ },
+ "messages": {
+ "deleteConfirm": "このライブラリを削除しますか?関連する全てのデータとユーザーアクセスが削除されます。",
+ "scanInProgress": "スキャン中...",
+ "noLibrariesAssigned": "このユーザーに割り当てられているライブラリはありません"
}
}
},
@@ -418,8 +506,12 @@
"shareFailure": "コピーに失敗しました %{url}",
"downloadDialogTitle": "ダウンロード %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "クリップボードへコピー: Ctrl+C, Enter",
- "remove_missing_title": "",
- "remove_missing_content": ""
+ "remove_missing_title": "欠落ファイルを削除",
+ "remove_missing_content": "選択した欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が完全に削除されます。",
+ "remove_all_missing_title": "全ての欠落ファイルを削除",
+ "remove_all_missing_content": "データベースから欠落ファイルをすべて削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が永久に削除されます。",
+ "noSimilarSongsFound": "類似の曲が見つかりませんでした",
+ "noTopSongsFound": "トップソングが見つかりません"
},
"menu": {
"library": "ライブラリ",
@@ -448,7 +540,13 @@
"albumList": "アルバム",
"about": "詳細",
"playlists": "プレイリスト",
- "sharedPlaylists": "共有プレイリスト"
+ "sharedPlaylists": "共有プレイリスト",
+ "librarySelector": {
+ "allLibraries": "全てのライブラリ( %{count} )",
+ "multipleLibraries": "%{selected} 個 / %{total} 個のライブラリ",
+ "selectLibraries": "ライブラリを選択",
+ "none": "無し"
+ }
},
"player": {
"playListsText": "再生リスト",
@@ -485,15 +583,34 @@
"disabled": "無効",
"waiting": "待機中"
}
+ },
+ "tabs": {
+ "about": "詳細",
+ "config": "設定"
+ },
+ "config": {
+ "configName": "設定名",
+ "environmentVariable": "環境変数",
+ "currentValue": "現在値",
+ "configurationFile": "設定ファイル",
+ "exportToml": "設定をエクスポート(TOML)",
+ "exportSuccess": "設定をTOML形式でクリップボードへエクスポートしました",
+ "exportFailed": "設定のコピーに失敗しました",
+ "devFlagsHeader": "開発フラグ(変更・削除の可能性あり)",
+ "devFlagsComment": "これらは実験的な設定であり、将来のバージョンで削除される可能性があります"
}
},
"activity": {
"title": "活動",
"totalScanned": "スキャン済みフォルダー",
- "quickScan": "クイックスキャン",
- "fullScan": "フルスキャン",
+ "quickScan": "クイック",
+ "fullScan": "フル",
"serverUptime": "サーバー稼働時間",
- "serverDown": "サーバーオフライン"
+ "serverDown": "サーバーオフライン",
+ "scanType": "最終スキャン",
+ "status": "スキャンエラー",
+ "elapsedTime": "経過時間",
+ "selectiveScan": "選択的スキャン"
},
"help": {
"title": "ホットキー",
@@ -508,5 +625,10 @@
"toggle_love": "星の付け外し",
"current_song": "現在の曲へ移動"
}
+ },
+ "nowPlaying": {
+ "title": "再生中",
+ "empty": "何も再生されていません",
+ "minutesAgo": "%{smart_count} 分前 |||| %{smart_count} 分前"
}
}
\ No newline at end of file
diff --git a/resources/i18n/ko.json b/resources/i18n/ko.json
index a8b26df6d..6b81e02d8 100644
--- a/resources/i18n/ko.json
+++ b/resources/i18n/ko.json
@@ -12,6 +12,7 @@
"artist": "아티스트",
"album": "앨범",
"path": "파일 경로",
+ "libraryName": "라이브러리",
"genre": "장르",
"compilation": "컴필레이션",
"year": "년",
@@ -34,7 +35,8 @@
"participants": "추가 참가자",
"tags": "추가 태그",
"mappedTags": "매핑된 태그",
- "rawTags": "원시 태그"
+ "rawTags": "원시 태그",
+ "missing": "누락"
},
"actions": {
"addToQueue": "나중에 재생",
@@ -56,6 +58,7 @@
"playCount": "재생 횟수",
"size": "크기",
"name": "이름",
+ "libraryName": "라이브러리",
"genre": "장르",
"compilation": "컴필레이션",
"year": "년",
@@ -73,7 +76,8 @@
"releaseType": "유형",
"grouping": "그룹",
"media": "미디어",
- "mood": "분위기"
+ "mood": "분위기",
+ "missing": "누락"
},
"actions": {
"playAll": "재생",
@@ -105,7 +109,8 @@
"playCount": "재생 횟수",
"rating": "평가",
"genre": "장르",
- "role": "역할"
+ "role": "역할",
+ "missing": "누락"
},
"roles": {
"albumartist": "앨범 아티스트 |||| 앨범 아티스트들",
@@ -120,7 +125,13 @@
"mixer": "믹서 |||| 믹서들",
"remixer": "리믹서 |||| 리믹서들",
"djmixer": "DJ 믹서 |||| DJ 믹서들",
- "performer": "공연자 |||| 공연자들"
+ "performer": "공연자 |||| 공연자들",
+ "maincredit": "앨범 아티스트 또는 아티스트 |||| 앨범 아티스트들 또는 아티스트들"
+ },
+ "actions": {
+ "topSongs": "인기곡",
+ "shuffle": "셔플",
+ "radio": "라디오"
}
},
"user": {
@@ -137,19 +148,26 @@
"changePassword": "비밀번호를 변경할까요?",
"currentPassword": "현재 비밀번호",
"newPassword": "새 비밀번호",
- "token": "토큰"
+ "token": "토큰",
+ "libraries": "라이브러리"
},
"helperTexts": {
- "name": "이름 변경 사항은 다음 로그인 시에만 반영됨"
+ "name": "이름 변경 사항은 다음 로그인 시에만 반영됨",
+ "libraries": "이 사용자에 대한 특정 라이브러리를 선택하거나 기본 라이브러리를 사용하려면 비움"
},
"notifications": {
"created": "사용자 생성됨",
"updated": "사용자 업데이트됨",
"deleted": "사용자 삭제됨"
},
+ "validation": {
+ "librariesRequired": "관리자가 아닌 사용자의 경우 최소한 하나의 라이브러리를 선택해야 함"
+ },
"message": {
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.",
- "clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
+ "clickHereForToken": "여기를 클릭하여 토큰을 얻으세요",
+ "selectAllLibraries": "모든 라이브러리 선택",
+ "adminAutoLibraries": "관리자 사용자는 자동으로 모든 라이브러리에 접속할 수 있음"
}
},
"player": {
@@ -192,12 +210,18 @@
"selectPlaylist": "재생목록 선택:",
"addNewPlaylist": "\"%{name}\" 만들기",
"export": "내보내기",
+ "saveQueue": "재생목록에 대기열 저장",
"makePublic": "공개 만들기",
- "makePrivate": "비공개 만들기"
+ "makePrivate": "비공개 만들기",
+ "searchOrCreate": "재생목록을 검색하거나 입력하여 새 재생목록을 만드세요...",
+ "pressEnterToCreate": "새 재생목록을 만드려면 Enter 키를 누름",
+ "removeFromSelection": "선택에서 제거"
},
"message": {
"duplicate_song": "중복된 노래 추가",
- "song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?"
+ "song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?",
+ "noPlaylistsFound": "재생목록을 찾을 수 없음",
+ "noPlaylists": "사용 가능한 재생 목록이 없음"
}
},
"radio": {
@@ -238,14 +262,68 @@
"fields": {
"path": "경로",
"size": "크기",
+ "libraryName": "라이브러리",
"updatedAt": "사라짐"
},
"actions": {
- "remove": "제거"
+ "remove": "제거",
+ "remove_all": "모두 제거"
},
"notifications": {
"removed": "누락된 파일이 제거되었음"
}
+ },
+ "library": {
+ "name": "라이브러리 |||| 라이브러리들",
+ "fields": {
+ "name": "이름",
+ "path": "경로",
+ "remotePath": "원격 경로",
+ "lastScanAt": "최근 스캔",
+ "songCount": "노래",
+ "albumCount": "앨범",
+ "artistCount": "아티스트",
+ "totalSongs": "노래",
+ "totalAlbums": "앨범",
+ "totalArtists": "아티스트",
+ "totalFolders": "폴더",
+ "totalFiles": "파일",
+ "totalMissingFiles": "누락된 파일",
+ "totalSize": "총 크기",
+ "totalDuration": "기간",
+ "defaultNewUsers": "신규 사용자 기본값",
+ "createdAt": "생성됨",
+ "updatedAt": "업데이트됨"
+ },
+ "sections": {
+ "basic": "기본 정보",
+ "statistics": "통계"
+ },
+ "actions": {
+ "scan": "라이브러리 스캔",
+ "manageUsers": "자용자 접속 관리",
+ "viewDetails": "상세 보기"
+ },
+ "notifications": {
+ "created": "라이브러리가 성공적으로 생성됨",
+ "updated": "라이브러리가 성공적으로 업데이트됨",
+ "deleted": "라이브러리가 성공적으로 삭제됨",
+ "scanStarted": "라이브러리 스캔 스작됨",
+ "scanCompleted": "라이브러리 스캔 완료됨"
+ },
+ "validation": {
+ "nameRequired": "라이브러리 이름이 필요함",
+ "pathRequired": "라이브러리 경로가 필요함",
+ "pathNotDirectory": "라이브러리 경로는 디렉터리여야 함",
+ "pathNotFound": "라이브러리 경로를 찾을 수 없음",
+ "pathNotAccessible": "라이브러리 경로에 접근할 수 없음",
+ "pathInvalid": "잘못된 라이브러리 경로"
+ },
+ "messages": {
+ "deleteConfirm": "이 라이브러리를 삭제할까요? 삭제하면 연결된 모든 데이터와 사용자 접속 권한이 제거됩니다.",
+ "scanInProgress": "스캔 진행 중...",
+ "noLibrariesAssigned": "이 사용자에게 할당된 라이브러리가 없음"
+ }
}
},
"ra": {
@@ -398,11 +476,15 @@
"transcodingDisabled": "웹 인터페이스를 통한 트랜스코딩 구성 변경은 보안상의 이유로 비활성화되어 있습니다. 트랜스코딩 옵션을 변경(편집 또는 추가)하려면, %{config} 구성 옵션으로 서버를 다시 시작하세요.",
"transcodingEnabled": "Navidrome은 현재 %{config}로 실행 중이므로 웹 인터페이스를 사용하여 트랜스코딩 설정에서 시스템 명령을 실행할 수 있습니다. 보안상의 이유로 비활성화하고 트랜스코딩 옵션을 구성할 때만 활성화하는 것이 좋습니다.",
"songsAddedToPlaylist": "1 개의 노래를 재생목록에 추가하였음 |||| %{smart_count} 개의 노래를 재생 목록에 추가하였음",
+ "noSimilarSongsFound": "비슷한 노래를 찾을 수 없음",
+ "noTopSongsFound": "인기곡을 찾을 수 없음",
"noPlaylistsAvailable": "사용 가능한 노래 없음",
"delete_user_title": "사용자 '%{name}' 삭제",
"delete_user_content": "이 사용자와 해당 사용자의 모든 데이터(재생 목록 및 환경 설정 포함)를 삭제할까요?",
"remove_missing_title": "누락된 파일들 제거",
"remove_missing_content": "선택한 누락된 파일을 데이터베이스에서 삭제할까요? 삭제하면 재생 횟수 및 평점을 포함하여 해당 파일에 대한 모든 참조가 영구적으로 삭제됩니다.",
+ "remove_all_missing_title": "누락된 모든 파일 제거",
+ "remove_all_missing_content": "데이터베이스에서 누락된 모든 파일을 제거할까요? 이렇게 하면 해당 게임의 플레이 횟수와 평점을 포함한 모든 참조 내용이 영구적으로 삭제됩니다.",
"notifications_blocked": "브라우저 설정에서 이 사이트의 알림을 차단하였음",
"notifications_not_available": "이 브라우저는 데스크톱 알림을 지원하지 않거나 https를 통해 Navidrome에 접속하고 있지 않음",
"lastfmLinkSuccess": "Last.fm이 성공적으로 연결되었고 스크로블링이 활성화되었음",
@@ -429,6 +511,12 @@
},
"menu": {
"library": "라이브러리",
+ "librarySelector": {
+ "allLibraries": "모든 라이브러리 (%{count})",
+ "multipleLibraries": "%{selected} / %{total} 라이브러리",
+ "selectLibraries": "라이브러리 선택",
+ "none": "없음"
+ },
"settings": "설정",
"version": "버전",
"theme": "테마",
@@ -491,6 +579,21 @@
"disabled": "비활성화",
"waiting": "대기중"
}
+ },
+ "tabs": {
+ "about": "정보",
+ "config": "구성"
+ },
+ "config": {
+ "configName": "구성 이름",
+ "environmentVariable": "환경 변수",
+ "currentValue": "현재 값",
+ "configurationFile": "구성 파일",
+ "exportToml": "구성 내보내기 (TOML)",
+ "exportSuccess": "TOML 형식으로 클립보드로 내보낸 구성",
+ "exportFailed": "구성 복사 실패",
+ "devFlagsHeader": "개발 플래그 (변경/삭제 가능)",
+ "devFlagsComment": "이는 실험적 설정이므로 향후 버전에서 제거될 수 있음"
}
},
"activity": {
@@ -499,7 +602,15 @@
"quickScan": "빠른 스캔",
"fullScan": "전체 스캔",
"serverUptime": "서버 가동 시간",
- "serverDown": "오프라인"
+ "serverDown": "오프라인",
+ "scanType": "유형",
+ "status": "스캔 오류",
+ "elapsedTime": "경과 시간"
+ },
+ "nowPlaying": {
+ "title": "현재 재생 중",
+ "empty": "재생 중인 콘텐츠 없음",
+ "minutesAgo": "%{smart_count} 분 전"
},
"help": {
"title": "Navidrome 단축키",
diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json
index 4737cb33a..b6da47380 100644
--- a/resources/i18n/nl.json
+++ b/resources/i18n/nl.json
@@ -5,7 +5,7 @@
"name": "Nummer |||| Nummers",
"fields": {
"albumArtist": "Album Artiest",
- "duration": "Lengte",
+ "duration": "Afspeelduur",
"trackNumber": "Nummer #",
"playCount": "Aantal keren afgespeeld",
"title": "Titel",
@@ -35,7 +35,8 @@
"rawTags": "Onbewerkte tags",
"bitDepth": "Bit diepte",
"sampleRate": "Sample waarde",
- "missing": "Ontbrekend"
+ "missing": "Ontbrekend",
+ "libraryName": "Bibliotheek"
},
"actions": {
"addToQueue": "Voeg toe aan wachtrij",
@@ -44,7 +45,8 @@
"shuffleAll": "Shuffle alles",
"download": "Downloaden",
"playNext": "Volgende",
- "info": "Meer info"
+ "info": "Meer info",
+ "showInPlaylist": "Toon in afspeellijst"
}
},
"album": {
@@ -55,7 +57,7 @@
"duration": "Afspeelduur",
"songCount": "Nummers",
"playCount": "Aantal keren afgespeeld",
- "name": "Naam",
+ "name": "Titel",
"genre": "Genre",
"compilation": "Compilatie",
"year": "Jaar",
@@ -65,9 +67,9 @@
"createdAt": "Datum toegevoegd",
"size": "Grootte",
"originalDate": "Origineel",
- "releaseDate": "Uitgegeven",
+ "releaseDate": "Uitgave",
"releases": "Uitgave |||| Uitgaven",
- "released": "Uitgegeven",
+ "released": "Uitgave",
"recordLabel": "Label",
"catalogNum": "Catalogus nummer",
"releaseType": "Type",
@@ -75,7 +77,8 @@
"media": "Media",
"mood": "Sfeer",
"date": "Opnamedatum",
- "missing": "Ontbrekend"
+ "missing": "Ontbrekend",
+ "libraryName": "Bibliotheek"
},
"actions": {
"playAll": "Afspelen",
@@ -123,7 +126,13 @@
"mixer": "Mixer |||| Mixers",
"remixer": "Remixer |||| Remixers",
"djmixer": "DJ Mixer |||| DJ Mixers",
- "performer": "Performer |||| Performers"
+ "performer": "Performer |||| Performers",
+ "maincredit": "Album Artiest of Artiest |||| Album Artiesten or Artiesten"
+ },
+ "actions": {
+ "shuffle": "Shuffle",
+ "radio": "Radio",
+ "topSongs": "Beste nummers"
}
},
"user": {
@@ -132,7 +141,7 @@
"userName": "Gebruikersnaam",
"isAdmin": "Is beheerder",
"lastLoginAt": "Laatst ingelogd op",
- "updatedAt": "Laatst gewijzigd op",
+ "updatedAt": "Laatst bijgewerkt op",
"name": "Naam",
"password": "Wachtwoord",
"createdAt": "Aangemaakt op",
@@ -140,19 +149,26 @@
"currentPassword": "Huidig wachtwoord",
"newPassword": "Nieuw wachtwoord",
"token": "Token",
- "lastAccessAt": "Meest recente toegang"
+ "lastAccessAt": "Meest recente toegang",
+ "libraries": "Bibliotheken"
},
"helperTexts": {
- "name": "Naamswijziging wordt pas zichtbaar bij de volgende login"
+ "name": "Naamswijziging wordt pas zichtbaar bij de volgende login",
+ "libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken"
},
"notifications": {
"created": "Aangemaakt door gebruiker",
- "updated": "Gewijzigd door gebruiker",
- "deleted": "Gewist door gebruiker"
+ "updated": "Bijgewerkt door gebruiker",
+ "deleted": "Gebruiker verwijderd"
},
"message": {
"listenBrainzToken": "Vul je ListenBrainz gebruikers-token in.",
- "clickHereForToken": "Klik hier voor je token"
+ "clickHereForToken": "Klik hier voor je token",
+ "selectAllLibraries": "Selecteer alle bibliotheken",
+ "adminAutoLibraries": "Admin gebruikers hebben automatisch toegang tot alle bibliotheken"
+ },
+ "validation": {
+ "librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers"
}
},
"player": {
@@ -181,10 +197,10 @@
"name": "Afspeellijst |||| Afspeellijsten",
"fields": {
"name": "Titel",
- "duration": "Lengte",
+ "duration": "Afspeelduur",
"ownerName": "Eigenaar",
"public": "Publiek",
- "updatedAt": "Laatst gewijzigd op",
+ "updatedAt": "Laatst bijgewerkt op",
"createdAt": "Aangemaakt op",
"songCount": "Nummers",
"comment": "Commentaar",
@@ -197,11 +213,16 @@
"export": "Exporteer",
"makePublic": "Openbaar maken",
"makePrivate": "Privé maken",
- "saveQueue": "Bewaar wachtrij als playlist"
+ "saveQueue": "Bewaar wachtrij als playlist",
+ "searchOrCreate": "Zoek afspeellijsten of typ om een nieuwe te starten...",
+ "pressEnterToCreate": "Druk Enter om nieuwe afspeellijst te maken",
+ "removeFromSelection": "Verwijder van selectie"
},
"message": {
"duplicate_song": "Dubbele nummers toevoegen",
- "song_exist": "Er komen nummers dubbel in de afspeellijst. Wil je de dubbele nummers toevoegen of overslaan?"
+ "song_exist": "Er komen nummers dubbel in de afspeellijst. Wil je de dubbele nummers toevoegen of overslaan?",
+ "noPlaylistsFound": "Geen playlists gevonden",
+ "noPlaylists": "Geen playlists beschikbaar"
}
},
"radio": {
@@ -210,8 +231,8 @@
"name": "Naam",
"streamUrl": "Stream URL",
"homePageUrl": "Hoofdpagina URL",
- "updatedAt": "Geüpdate op",
- "createdAt": "Gecreëerd op"
+ "updatedAt": "Bijgewerkt op",
+ "createdAt": "Aangemaakt op"
},
"actions": {
"playNow": "Speel nu"
@@ -229,8 +250,8 @@
"visitCount": "Bezocht",
"format": "Formaat",
"maxBitRate": "Max. bitrate",
- "updatedAt": "Geüpdatet op",
- "createdAt": "Gecreëerd op",
+ "updatedAt": "Bijgewerkt op",
+ "createdAt": "Aangemaakt op",
"downloadable": "Downloads toestaan?"
}
},
@@ -239,7 +260,8 @@
"fields": {
"path": "Pad",
"size": "Grootte",
- "updatedAt": "Verdwenen op"
+ "updatedAt": "Verdwenen op",
+ "libraryName": "Bibliotheek"
},
"actions": {
"remove": "Verwijder",
@@ -249,6 +271,58 @@
"removed": "Ontbrekende bestanden verwijderd"
},
"empty": "Geen ontbrekende bestanden"
+ },
+ "library": {
+ "name": "Bibliotheek |||| Bibliotheken",
+ "fields": {
+ "name": "Naam",
+ "path": "Pad",
+ "remotePath": "Extern pad",
+ "lastScanAt": "Laatste scan",
+ "songCount": "Nummers",
+ "albumCount": "Albums",
+ "artistCount": "Artiesten",
+ "totalSongs": "Nummers",
+ "totalAlbums": "Albums",
+ "totalArtists": "Artiesten",
+ "totalFolders": "Mappen",
+ "totalFiles": "Bestanden",
+ "totalMissingFiles": "Ontbrekende bestanden",
+ "totalSize": "Totale bestandsgrootte",
+ "totalDuration": "Afspeelduur",
+ "defaultNewUsers": "Standaard voor nieuwe gebruikers",
+ "createdAt": "Aangemaakt",
+ "updatedAt": "Bijgewerkt"
+ },
+ "sections": {
+ "basic": "Basisinformatie",
+ "statistics": "Statistieken"
+ },
+ "actions": {
+ "scan": "Scan bibliotheek",
+ "manageUsers": "Beheer gebruikerstoegang",
+ "viewDetails": "Bekijk details"
+ },
+ "notifications": {
+ "created": "Bibliotheek succesvol aangemaakt",
+ "updated": "Bibliotheek succesvol bijgewerkt",
+ "deleted": "Bibliotheek succesvol verwijderd",
+ "scanStarted": "Bibliotheekscan is gestart",
+ "scanCompleted": "Bibliotheekscan is voltooid"
+ },
+ "validation": {
+ "nameRequired": "Bibliotheek naam is vereist",
+ "pathRequired": "Pad naar bibliotheek is vereist",
+ "pathNotDirectory": "Pad naar bibliotheek moet een map zijn",
+ "pathNotFound": "Pad naar bibliotheek niet gevonden",
+ "pathNotAccessible": "Pad naar bibliotheek is niet toegankelijk",
+ "pathInvalid": "Ongeldig pad naar bibliotheek"
+ },
+ "messages": {
+ "deleteConfirm": "Weet je zeker dat je deze bibliotheek wil verwijderen? Dit verwijdert ook alle gerelateerde data en gebruikerstoegang.",
+ "scanInProgress": "Scan is bezig...",
+ "noLibrariesAssigned": "Geen bibliotheken aan deze gebruiker toegewezen"
+ }
}
},
"ra": {
@@ -430,7 +504,9 @@
"remove_missing_title": "Verwijder ontbrekende bestanden",
"remove_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
"remove_all_missing_title": "Verwijder alle ontbrekende bestanden",
- "remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen."
+ "remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
+ "noSimilarSongsFound": "Geen vergelijkbare nummers gevonden",
+ "noTopSongsFound": "Geen beste nummers gevonden"
},
"menu": {
"library": "Bibliotheek",
@@ -459,7 +535,13 @@
"albumList": "Albums",
"about": "Over",
"playlists": "Afspeellijsten",
- "sharedPlaylists": "Gedeelde afspeellijsten"
+ "sharedPlaylists": "Gedeelde afspeellijsten",
+ "librarySelector": {
+ "allLibraries": "Alle bibliotheken (%{count})",
+ "multipleLibraries": "%{selected} van %{total} bibliotheken",
+ "selectLibraries": "Selecteer bibliotheken",
+ "none": "Geen"
+ }
},
"player": {
"playListsText": "Wachtrij",
@@ -468,7 +550,7 @@
"notContentText": "Geen muziek",
"clickToPlayText": "Klik om af te spelen",
"clickToPauseText": "Klik om te pauzeren",
- "nextTrackText": "Volgende",
+ "nextTrackText": "Volgend nummer",
"previousTrackText": "Vorige",
"reloadText": "Herladen",
"volumeText": "Volume",
@@ -496,11 +578,26 @@
"disabled": "Uitgeschakeld",
"waiting": "Wachten"
}
+ },
+ "tabs": {
+ "about": "Over",
+ "config": "Configuratie"
+ },
+ "config": {
+ "configName": "Config Naam",
+ "environmentVariable": "Omgevingsvariabele",
+ "currentValue": "Huidige waarde",
+ "configurationFile": "Configuratiebestand",
+ "exportToml": "Exporteer configuratie (TOML)",
+ "exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat",
+ "exportFailed": "Kopiëren van configuratie mislukt",
+ "devFlagsHeader": "Ontwikkelaarsinstellingen (onder voorbehoud)",
+ "devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd"
}
},
"activity": {
"title": "Activiteit",
- "totalScanned": "Totaal gescande folders",
+ "totalScanned": "Totaal gescande mappen",
"quickScan": "Snelle scan",
"fullScan": "Volledige scan",
"serverUptime": "Server uptime",
@@ -522,5 +619,10 @@
"toggle_love": "Voeg toe aan favorieten",
"current_song": "Ga naar huidig nummer"
}
+ },
+ "nowPlaying": {
+ "title": "Speelt nu",
+ "empty": "Er wordt niets afgespeed",
+ "minutesAgo": "%{smart_count} minuut geleden |||| %{smart_count} minuten geleden"
}
}
\ No newline at end of file
diff --git a/resources/i18n/pl.json b/resources/i18n/pl.json
index e75a9f404..a9d6db88f 100644
--- a/resources/i18n/pl.json
+++ b/resources/i18n/pl.json
@@ -33,7 +33,10 @@
"tags": "Dodatkowe Tagi",
"mappedTags": "Zmapowane tagi",
"rawTags": "Surowe tagi",
- "bitDepth": "Głębokość próbkowania"
+ "bitDepth": "Głębokość próbkowania",
+ "sampleRate": "Częstotliwość próbkowania",
+ "missing": "Brak",
+ "libraryName": "Biblioteka"
},
"actions": {
"addToQueue": "Odtwarzaj Później",
@@ -42,7 +45,8 @@
"shuffleAll": "Losuj Wszystkie",
"download": "Pobierz",
"playNext": "Odtwarzaj Następny",
- "info": "Zdobądź Informacje"
+ "info": "Zdobądź Informacje",
+ "showInPlaylist": "Pokaż w Liście Odtwarzania"
}
},
"album": {
@@ -72,7 +76,9 @@
"grouping": "Grupowanie",
"media": "Media",
"mood": "Nastrój",
- "date": ""
+ "date": "Data Nagrania",
+ "missing": "Brak",
+ "libraryName": "Biblioteka"
},
"actions": {
"playAll": "Odtwarzaj",
@@ -104,7 +110,8 @@
"rating": "Ocena",
"genre": "Gatunek",
"size": "Rozmiar",
- "role": "Rola"
+ "role": "Rola",
+ "missing": "Brak"
},
"roles": {
"albumartist": "Wykonawca Albumu |||| Wykonawcy Albumu",
@@ -119,7 +126,13 @@
"mixer": "Mikser |||| Mikserzy",
"remixer": "Remixer |||| Remixerzy",
"djmixer": "Didżej |||| Didżerzy",
- "performer": "Wykonawca |||| Wykonawcy"
+ "performer": "Wykonawca |||| Wykonawcy",
+ "maincredit": "Artysta albumu lub Artysta |||| Artyści albumu lub Artyści"
+ },
+ "actions": {
+ "shuffle": "Losuj",
+ "radio": "Radio",
+ "topSongs": "Najlepsze Utwory"
}
},
"user": {
@@ -136,10 +149,12 @@
"currentPassword": "Obecne hasło",
"newPassword": "Nowe hasło",
"token": "Token",
- "lastAccessAt": "Ostatnia Aktywność"
+ "lastAccessAt": "Ostatnia Aktywność",
+ "libraries": "Biblioteki"
},
"helperTexts": {
- "name": "Zmiana nazwy będzie widoczna przy następnym logowaniu"
+ "name": "Zmiana nazwy będzie widoczna przy następnym logowaniu",
+ "libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki"
},
"notifications": {
"created": "Dodano użytkownika",
@@ -148,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Wprowadź swój token ListenBrainz.",
- "clickHereForToken": "Kliknij tutaj, aby uzyskać token"
+ "clickHereForToken": "Kliknij tutaj, aby uzyskać token",
+ "selectAllLibraries": "Wybierz wszystkie biblioteki",
+ "adminAutoLibraries": "Administratorzy automatycznie mają dostęp do wszystkich bibliotek"
+ },
+ "validation": {
+ "librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika"
}
},
"player": {
@@ -192,11 +212,17 @@
"addNewPlaylist": "Stwórz \"%{name}\"",
"export": "Wyeksportuj",
"makePublic": "Zmień na Publiczną",
- "makePrivate": "Zmień na Prywatną"
+ "makePrivate": "Zmień na Prywatną",
+ "saveQueue": "Zapisz Kolejkę do Playlisty",
+ "searchOrCreate": "Szukaj list odtwarzania lub zacznij pisać, aby stworzyć nową...",
+ "pressEnterToCreate": "Wciśnij Enter, aby stworzyć nową listę odtwarzania",
+ "removeFromSelection": "Usuń z zaznaczenia"
},
"message": {
"duplicate_song": "Dodaj zduplikowane utwory",
- "song_exist": "Do playlisty dodawane są duplikaty. Czy chcesz je dodać czy pominąć?"
+ "song_exist": "Do playlisty dodawane są duplikaty. Czy chcesz je dodać czy pominąć?",
+ "noPlaylistsFound": "Brak list odtwarzania",
+ "noPlaylists": "Brak dostępnych list odtwarzania"
}
},
"radio": {
@@ -234,15 +260,74 @@
"fields": {
"path": "Ścieżka",
"size": "Rozmiar",
- "updatedAt": "Zniknął na"
+ "updatedAt": "Zniknął na",
+ "libraryName": "Biblioteka"
},
"actions": {
- "remove": "Usuń"
+ "remove": "Usuń",
+ "remove_all": "Usuń Wszystko"
},
"notifications": {
"removed": "Usunięto brakujące pliki"
},
- "empty": "Bez Brakujących Plików"
+ "empty": "Brak Brakujących Plików"
+ },
+ "library": {
+ "name": "Biblioteka |||| Biblioteki",
+ "fields": {
+ "name": "Nazwa",
+ "path": "Ścieżka",
+ "remotePath": "Zdalna Ścieżka",
+ "lastScanAt": "Ostatni Skan",
+ "songCount": "Utwory",
+ "albumCount": "Albumy",
+ "artistCount": "Artyści",
+ "totalSongs": "Utwory",
+ "totalAlbums": "Albumy",
+ "totalArtists": "Artyści",
+ "totalFolders": "Foldery",
+ "totalFiles": "Pliki",
+ "totalMissingFiles": "Brakujące Pliki",
+ "totalSize": "Całkowity Rozmiar",
+ "totalDuration": "Czas Trwania",
+ "defaultNewUsers": "Domyślne dla Nowych Użytkowników",
+ "createdAt": "Stworzona",
+ "updatedAt": "Zaktualizowana"
+ },
+ "sections": {
+ "basic": "Podstawowe Informacje",
+ "statistics": "Statystyki"
+ },
+ "actions": {
+ "scan": "Skanuj Bibliotekę",
+ "manageUsers": "Zarządzaj Dostępami Użytkownika",
+ "viewDetails": "Zobacz Szczegóły",
+ "quickScan": "Szybkie Skanowanie",
+ "fullScan": "Pełne Skanowanie"
+ },
+ "notifications": {
+ "created": "Biblioteka utworzona prawidłowo",
+ "updated": "Biblioteka zaktualizowana prawidłowo",
+ "deleted": "Biblioteka usunięta prawidłowo",
+ "scanStarted": "Rozpoczęto skan biblioteki",
+ "scanCompleted": "Zakończono skan biblioteki",
+ "quickScanStarted": "Szybkie skanowanie rozpoczęte",
+ "fullScanStarted": "Pełne skanowanie rozpoczęte",
+ "scanError": "Błąd podczas startu skanowania. Sprawdź logi"
+ },
+ "validation": {
+ "nameRequired": "Nazwa biblioteki jest wymagana",
+ "pathRequired": "Ścieżka biblioteki jest wymagana",
+ "pathNotDirectory": "Ścieżka biblioteki musi być katalogiem",
+ "pathNotFound": "Brak ścieżki biblioteki",
+ "pathNotAccessible": "Ścieżka biblioteki niedostępna",
+ "pathInvalid": "Niepoprawna ścieżka biblioteki"
+ },
+ "messages": {
+ "deleteConfirm": "Czy chcesz usunąć tę bibliotekę? Spowoduje to usunięcie wszystkich powiązanych danych i dostępów użytkowników.",
+ "scanInProgress": "Skanowanie w trakcie...",
+ "noLibrariesAssigned": "Brak bibliotek przypisanych do tego użytkownika"
+ }
}
},
"ra": {
@@ -422,7 +507,11 @@
"downloadDialogTitle": "Pobierz %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Skopiuj do schowka: Ctrl+C, Enter",
"remove_missing_title": "Usuń brakujące dane",
- "remove_missing_content": "Czy na pewno chcesz usunąć wybrane brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszystkich powiązań, takich jak liczba odtworzeń i oceny."
+ "remove_missing_content": "Czy na pewno chcesz usunąć wybrane brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszystkich powiązań, takich jak liczba odtworzeń i oceny.",
+ "remove_all_missing_title": "Usuń wszystkie brakujące pliki",
+ "remove_all_missing_content": "Czy chcesz usunąć wszystkie brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszelkich odniesień do tych plików, takich jak liczba odtworzeń, czy oceny.",
+ "noSimilarSongsFound": "Brak podobnych utworów",
+ "noTopSongsFound": "Brak najlepszych utworów"
},
"menu": {
"library": "Biblioteka",
@@ -451,7 +540,13 @@
"albumList": "Albumy",
"about": "O aplikacji",
"playlists": "Playlisty",
- "sharedPlaylists": "Udostępnione Playlisty"
+ "sharedPlaylists": "Udostępnione Playlisty",
+ "librarySelector": {
+ "allLibraries": "Wszystkie Biblioteki (%{count})",
+ "multipleLibraries": "%{selected} z %{total} Bibliotek",
+ "selectLibraries": "Wybierz Biblioteki",
+ "none": "Żadna"
+ }
},
"player": {
"playListsText": "Kolejka Odtwarzania",
@@ -488,6 +583,21 @@
"disabled": "Wyłączone",
"waiting": "Oczekujące"
}
+ },
+ "tabs": {
+ "about": "O",
+ "config": "Konfiguracja"
+ },
+ "config": {
+ "configName": "Nazwa Konfiguracji",
+ "environmentVariable": "Zmienna Środowiskowa",
+ "currentValue": "Obecna Wartość",
+ "configurationFile": "Plik Konfiguracyjny",
+ "exportToml": "Eksportuj Konfigurację (TOML)",
+ "exportSuccess": "Konfiguracja wyeksportowana do schowka w formacie TOML",
+ "exportFailed": "Błąd kopiowania konfiguracji",
+ "devFlagsHeader": "Flagi Rozwojowe (mogą ulec zmianie/usunięciu)",
+ "devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach"
}
},
"activity": {
@@ -496,7 +606,11 @@
"quickScan": "Szybkie Skanowanie",
"fullScan": "Pełne Skanowanie",
"serverUptime": "Czas Działania Serwera",
- "serverDown": "NIEDOSTĘPNY"
+ "serverDown": "NIEDOSTĘPNY",
+ "scanType": "Typ",
+ "status": "Błąd Skanowania",
+ "elapsedTime": "Upłynięty Czas",
+ "selectiveScan": "Selektywne"
},
"help": {
"title": "Skróty Klawiszowe Navidrome",
@@ -511,5 +625,10 @@
"toggle_love": "Dodaj ten utwór do ulubionych",
"current_song": "Przejdź do Bieżącego Utworu"
}
+ },
+ "nowPlaying": {
+ "title": "Teraz Odtwarzane",
+ "empty": "Nic nie jest odtwarzane",
+ "minutesAgo": "%{smart_count} minutę temu |||| %{smart_count} minut temu"
}
}
\ No newline at end of file
diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json
index 3fa5b16eb..3f095b025 100644
--- a/resources/i18n/pt-br.json
+++ b/resources/i18n/pt-br.json
@@ -12,6 +12,7 @@
"artist": "Artista",
"album": "Álbum",
"path": "Arquivo",
+ "libraryName": "Biblioteca",
"genre": "Gênero",
"compilation": "Coletânea",
"year": "Ano",
@@ -57,6 +58,7 @@
"songCount": "Músicas",
"playCount": "Execuções",
"name": "Nome",
+ "libraryName": "Biblioteca",
"genre": "Gênero",
"compilation": "Coletânea",
"year": "Ano",
@@ -147,19 +149,26 @@
"currentPassword": "Senha Atual",
"newPassword": "Nova Senha",
"token": "Token",
- "lastAccessAt": "Últ. Acesso"
+ "lastAccessAt": "Últ. Acesso",
+ "libraries": "Bibliotecas"
},
"helperTexts": {
- "name": "Alterações no seu nome só serão refletidas no próximo login"
+ "name": "Alterações no seu nome só serão refletidas no próximo login",
+ "libraries": "Selecione bibliotecas específicas para este usuário, ou deixe vazio para usar bibliotecas padrão"
},
"notifications": {
"created": "Novo usuário criado",
"updated": "Usuário atualizado com sucesso",
"deleted": "Usuário deletado com sucesso"
},
+ "validation": {
+ "librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores"
+ },
"message": {
"listenBrainzToken": "Entre seu token do ListenBrainz",
- "clickHereForToken": "Clique aqui para obter seu token"
+ "clickHereForToken": "Clique aqui para obter seu token",
+ "selectAllLibraries": "Selecionar todas as bibliotecas",
+ "adminAutoLibraries": "Usuários administradores têm acesso automático a todas as bibliotecas"
}
},
"player": {
@@ -207,8 +216,7 @@
"saveQueue": "Salvar fila em nova Playlist",
"searchOrCreate": "Buscar playlists ou criar nova...",
"pressEnterToCreate": "Pressione Enter para criar nova playlist",
- "removeFromSelection": "Remover da seleção",
- "removeSymbol": "×"
+ "removeFromSelection": "Remover da seleção"
},
"message": {
"duplicate_song": "Adicionar músicas duplicadas",
@@ -252,6 +260,7 @@
"fields": {
"path": "Caminho",
"size": "Tamanho",
+ "libraryName": "Biblioteca",
"updatedAt": "Desaparecido em"
},
"actions": {
@@ -262,6 +271,63 @@
"removed": "Arquivo(s) ausente(s) removido(s)"
},
"empty": "Nenhum arquivo ausente"
+ },
+ "library": {
+ "name": "Biblioteca |||| Bibliotecas",
+ "fields": {
+ "name": "Nome",
+ "path": "Caminho",
+ "remotePath": "Caminho Remoto",
+ "lastScanAt": "Último Scan",
+ "songCount": "Músicas",
+ "albumCount": "Álbuns",
+ "artistCount": "Artistas",
+ "totalSongs": "Músicas",
+ "totalAlbums": "Álbuns",
+ "totalArtists": "Artistas",
+ "totalFolders": "Pastas",
+ "totalFiles": "Arquivos",
+ "totalMissingFiles": "Arquivos Ausentes",
+ "totalSize": "Tamanho Total",
+ "totalDuration": "Duração",
+ "defaultNewUsers": "Padrão para Novos Usuários",
+ "createdAt": "Data de Criação",
+ "updatedAt": "Últ. Atualização"
+ },
+ "sections": {
+ "basic": "Informações Básicas",
+ "statistics": "Estatísticas"
+ },
+ "actions": {
+ "scan": "Scanear Biblioteca",
+ "quickScan": "Scan Rápido",
+ "fullScan": "Scan Completo",
+ "manageUsers": "Gerenciar Acesso do Usuário",
+ "viewDetails": "Ver Detalhes"
+ },
+ "notifications": {
+ "created": "Biblioteca criada com sucesso",
+ "updated": "Biblioteca atualizada com sucesso",
+ "deleted": "Biblioteca excluída com sucesso",
+ "scanStarted": "Scan da biblioteca iniciada",
+ "quickScanStarted": "Scan rápido iniciado",
+ "fullScanStarted": "Scan completo iniciado",
+ "scanError": "Erro ao iniciar o scan. Verifique os logs",
+ "scanCompleted": "Scan da biblioteca concluída"
+ },
+ "validation": {
+ "nameRequired": "Nome da biblioteca é obrigatório",
+ "pathRequired": "Caminho da biblioteca é obrigatório",
+ "pathNotDirectory": "Caminho da biblioteca deve ser um diretório",
+ "pathNotFound": "Caminho da biblioteca não encontrado",
+ "pathNotAccessible": "Caminho da biblioteca não está acessível",
+ "pathInvalid": "Caminho da biblioteca inválido"
+ },
+ "messages": {
+ "deleteConfirm": "Tem certeza que deseja excluir esta biblioteca? Isso removerá todos os dados associados.",
+ "scanInProgress": "Scan em progresso...",
+ "noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário"
+ }
}
},
"ra": {
@@ -449,6 +515,12 @@
},
"menu": {
"library": "Biblioteca",
+ "librarySelector": {
+ "allLibraries": "Todas as Bibliotecas (%{count})",
+ "multipleLibraries": "%{selected} de %{total} Bibliotecas",
+ "selectLibraries": "Selecionar Bibliotecas",
+ "none": "Nenhuma"
+ },
"settings": "Configurações",
"version": "Versão",
"theme": "Tema",
@@ -530,12 +602,13 @@
},
"activity": {
"title": "Atividade",
- "totalScanned": "Total de pastas analisadas",
- "quickScan": "Scan rápido",
- "fullScan": "Scan completo",
+ "totalScanned": "Total de pastas scaneadas",
+ "quickScan": "Rápido",
+ "fullScan": "Completo",
+ "selectiveScan": "Seletivo",
"serverUptime": "Uptime do servidor",
"serverDown": "DESCONECTADO",
- "scanType": "Tipo",
+ "scanType": "Último Scan",
"status": "Erro",
"elapsedTime": "Duração"
},
diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json
index 99e37b7d3..2d7ffd249 100644
--- a/resources/i18n/ru.json
+++ b/resources/i18n/ru.json
@@ -8,7 +8,7 @@
"duration": "Длительность",
"trackNumber": "#",
"playCount": "Проигрывания",
- "title": "Название",
+ "title": "Название трека",
"artist": "Исполнитель",
"album": "Альбом",
"path": "Путь",
@@ -23,7 +23,7 @@
"comment": "Комментарий",
"rating": "Рейтинг",
"quality": "Качество",
- "bpm": "Кол-во ударов в минуту",
+ "bpm": "BPM",
"playDate": "Последнее воспроизведение",
"channels": "Каналы",
"createdAt": "Дата добавления",
@@ -35,7 +35,8 @@
"rawTags": "Исходные теги",
"bitDepth": "Битовая глубина (Bit)",
"sampleRate": "Частота дискретизации (Hz)",
- "missing": "Поле отсутствует"
+ "missing": "Поле отсутствует",
+ "libraryName": "Библиотека"
},
"actions": {
"addToQueue": "В очередь",
@@ -44,7 +45,8 @@
"shuffleAll": "Перемешать",
"download": "Скачать",
"playNext": "Следующий",
- "info": "Информация"
+ "info": "Информация",
+ "showInPlaylist": "Показать в плейлисте"
}
},
"album": {
@@ -55,7 +57,7 @@
"duration": "Длительность",
"songCount": "Треков",
"playCount": "Проигрывания",
- "name": "Название",
+ "name": "Название альбома",
"genre": "Жанр",
"compilation": "Сборник",
"year": "Год",
@@ -75,7 +77,8 @@
"media": "Медиа",
"mood": "Настроение",
"date": "Дата записи",
- "missing": "Поле отсутствует"
+ "missing": "Поле отсутствует",
+ "libraryName": "Библиотека"
},
"actions": {
"playAll": "Играть",
@@ -100,7 +103,7 @@
"artist": {
"name": "Исполнитель |||| Исполнители",
"fields": {
- "name": "Название",
+ "name": "Название исполнителя",
"albumCount": "Количество альбомов",
"songCount": "Количество треков",
"playCount": "Проигрывания",
@@ -123,7 +126,13 @@
"mixer": "Звукоинженер |||| Звукоинженеры",
"remixer": "Ремиксер |||| Ремиксеры",
"djmixer": "DJ-миксер |||| DJ-миксеры",
- "performer": "Исполнитель |||| Исполнители"
+ "performer": "Исполнитель |||| Исполнители",
+ "maincredit": "Исполнитель альбома или Исполнитель |||| Исполнители альбома или Исполнители"
+ },
+ "actions": {
+ "shuffle": "Смешать",
+ "radio": "Радио",
+ "topSongs": "Топовые треки"
}
},
"user": {
@@ -135,15 +144,17 @@
"updatedAt": "Обновлено",
"name": "Имя",
"password": "Пароль",
- "createdAt": "Создан",
+ "createdAt": "Аккаунт создан",
"changePassword": "Сменить пароль?",
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"token": "Токен",
- "lastAccessAt": "Последний доступ"
+ "lastAccessAt": "Последний доступ",
+ "libraries": "Библиотеки"
},
"helperTexts": {
- "name": "Изменение вступит в силу после следующего входа в систему"
+ "name": "Изменение вступит в силу после следующего входа в систему",
+ "libraries": "Выберите конкретные библиотеки для этого пользователя или оставьте поле пустым, чтобы использовать библиотеки по умолчанию"
},
"notifications": {
"created": "Пользователь создан",
@@ -152,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Введите свой токен пользователя ListenBrainz.",
- "clickHereForToken": "Нажмите здесь, чтобы получить токен"
+ "clickHereForToken": "Нажмите здесь, чтобы получить токен",
+ "selectAllLibraries": "Выбрать все библиотеки",
+ "adminAutoLibraries": "Пользователи-администраторы автоматически получают доступ ко всем библиотекам"
+ },
+ "validation": {
+ "librariesRequired": "Для пользователей, не являющихся администраторами, должна быть выбрана хотя бы одна библиотека"
}
},
"player": {
@@ -180,7 +196,7 @@
"playlist": {
"name": "Плейлист |||| Плейлисты",
"fields": {
- "name": "Название",
+ "name": "Название трека",
"duration": "Длительность",
"ownerName": "Владелец",
"public": "Публичный",
@@ -197,11 +213,16 @@
"export": "Экспорт",
"makePublic": "Опубликовать",
"makePrivate": "Сделать личным",
- "saveQueue": "Сохранить очередь в плейлист"
+ "saveQueue": "Сохранить очередь в плейлист",
+ "searchOrCreate": "Поиск плейлистов или введите текст для создания новых...",
+ "pressEnterToCreate": "Нажмите Enter, чтобы создать новый список воспроизведения",
+ "removeFromSelection": "Удалить из списка выделенных"
},
"message": {
"duplicate_song": "Повторяющиеся треки",
- "song_exist": "Некоторые треки уже есть в плейлисте. Вы хотите добавить их или пропустить?"
+ "song_exist": "Некоторые треки уже есть в плейлисте. Вы хотите добавить их или пропустить?",
+ "noPlaylistsFound": "Плейлисты не найдены",
+ "noPlaylists": "Нет доступных плейлистов"
}
},
"radio": {
@@ -226,7 +247,7 @@
"contents": "Содержание",
"expiresAt": "Ссылка истекает",
"lastVisitedAt": "Последнее посещение",
- "visitCount": "Посещения",
+ "visitCount": "Количество посещений",
"format": "Формат",
"maxBitRate": "Макс. битрейт",
"updatedAt": "Обновлено в",
@@ -239,7 +260,8 @@
"fields": {
"path": "Место расположения",
"size": "Размер",
- "updatedAt": "Исчез"
+ "updatedAt": "Исчез",
+ "libraryName": "Библиотека"
},
"actions": {
"remove": "Удалить",
@@ -249,6 +271,63 @@
"removed": "Отсутствующие файлы удалены"
},
"empty": "Нет отсутствующих файлов"
+ },
+ "library": {
+ "name": "Библиотека |||| Библиотеки",
+ "fields": {
+ "name": "Имя",
+ "path": "Путь",
+ "remotePath": "Удаленный путь",
+ "lastScanAt": "Последнее сканирование",
+ "songCount": "Треки",
+ "albumCount": "Альбомы",
+ "artistCount": "Исполнители",
+ "totalSongs": "Треки",
+ "totalAlbums": "Альбомы",
+ "totalArtists": "Исполнители",
+ "totalFolders": "Папки",
+ "totalFiles": "Файлов",
+ "totalMissingFiles": "Пропавших файлов",
+ "totalSize": "Общий размер",
+ "totalDuration": "Длительность",
+ "defaultNewUsers": "По умолчанию для новых пользователей",
+ "createdAt": "Создано",
+ "updatedAt": "Обновлено"
+ },
+ "sections": {
+ "basic": "Основная информация",
+ "statistics": "Статистика"
+ },
+ "actions": {
+ "scan": "Сканировать библиотеку",
+ "manageUsers": "Управление доступом пользователей",
+ "viewDetails": "Просмотреть подробности",
+ "quickScan": "Быстрое сканирование",
+ "fullScan": "Полное сканирование"
+ },
+ "notifications": {
+ "created": "Библиотека успешно создана",
+ "updated": "Библиотека успешно обновлена",
+ "deleted": "Библиотека успешно удалена",
+ "scanStarted": "Сканирование библиотеки начато",
+ "scanCompleted": "Сканирование библиотеки закончено",
+ "quickScanStarted": "Быстрое сканирование началось",
+ "fullScanStarted": "Началось полное сканирование",
+ "scanError": "Ошибка при запуске сканирования. Проверьте логи"
+ },
+ "validation": {
+ "nameRequired": "Имя библиотеки обязательно",
+ "pathRequired": "Путь к библиотеке обязателен",
+ "pathNotDirectory": "Путь к библиотеке должен быть директорией",
+ "pathNotFound": "Путь к библиотеке не найден",
+ "pathNotAccessible": "Путь к библиотеке недоступен",
+ "pathInvalid": "Неверный путь к библиотеке"
+ },
+ "messages": {
+ "deleteConfirm": "Вы уверены, что хотите удалить эту библиотеку? Это приведет к удалению всех связанных с ней данных и доступа пользователей.",
+ "scanInProgress": "Сканирование продолжается...",
+ "noLibrariesAssigned": "Нет библиотек, назначенных этому пользователю"
+ }
}
},
"ra": {
@@ -427,10 +506,12 @@
"shareFailure": "Ошибка копирования URL-адреса %{url} в буфер обмена",
"downloadDialogTitle": "Скачать %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Копировать в буфер обмена: Ctrl+C, Enter",
- "remove_missing_title": "Удалить отсутствующие файлы",
+ "remove_missing_title": "Удалить отсутствующие файлы?",
"remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах.",
"remove_all_missing_title": "Удалите все отсутствующие файлы",
- "remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг."
+ "remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг.",
+ "noSimilarSongsFound": "Похожих треков не найдено",
+ "noTopSongsFound": "Лучших треков не найдено"
},
"menu": {
"library": "Библиотека",
@@ -459,7 +540,13 @@
"albumList": "Альбомы",
"about": "О нас",
"playlists": "Плейлисты",
- "sharedPlaylists": "Поделиться плейлистом"
+ "sharedPlaylists": "Поделиться плейлистом",
+ "librarySelector": {
+ "allLibraries": "Все библиотеки (%{count})",
+ "multipleLibraries": "%{selected} из %{total} Библиотеки",
+ "selectLibraries": "Выбор библиотек",
+ "none": "Отсутствует"
+ }
},
"player": {
"playListsText": "Очередь Воспроизведения",
@@ -496,6 +583,21 @@
"disabled": "Выключено",
"waiting": "Ожидание"
}
+ },
+ "tabs": {
+ "about": "О нас",
+ "config": "Конфигурация"
+ },
+ "config": {
+ "configName": "Имя конфигурации",
+ "environmentVariable": "Переменная среды",
+ "currentValue": "Текущее значение",
+ "configurationFile": "Файл конфигурации",
+ "exportToml": "Экспорт конфигурации (TOML)",
+ "exportSuccess": "Конфигурация экспортирована в буфер обмена в формате TOML",
+ "exportFailed": "Не удалось скопировать конфигурацию",
+ "devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)",
+ "devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях."
}
},
"activity": {
@@ -507,7 +609,8 @@
"serverDown": "Оффлайн",
"scanType": "Тип",
"status": "Ошибка сканирования",
- "elapsedTime": "Прошедшее время"
+ "elapsedTime": "Прошедшее время",
+ "selectiveScan": "Избирательный"
},
"help": {
"title": "Горячие клавиши Navidrome",
@@ -522,5 +625,10 @@
"toggle_love": "Добавить / удалить песню из избранного",
"current_song": "Перейти к текущему треку"
}
+ },
+ "nowPlaying": {
+ "title": "Сейчас играет",
+ "empty": "Ничего не играет",
+ "minutesAgo": "%{smart_count} минут назад |||| %{smart_count} минут назад"
}
}
\ No newline at end of file
diff --git a/resources/i18n/sl.json b/resources/i18n/sl.json
index f860245e8..80bd8e4a3 100644
--- a/resources/i18n/sl.json
+++ b/resources/i18n/sl.json
@@ -1,460 +1,628 @@
{
- "languageName": "Slovenščina",
- "resources": {
- "song": {
- "name": "Pesem |||| Pesmi",
- "fields": {
- "albumArtist": "Avtor albuma",
- "duration": "Dolžina",
- "trackNumber": "#",
- "playCount": "Predvajano",
- "title": "Naslov",
- "artist": "Avtor",
- "album": "Album",
- "path": "Pot datoteke",
- "genre": "Žanr",
- "compilation": "Kompilacija",
- "year": "Leto",
- "size": "Velikost datoteke",
- "updatedAt": "Posodobljeno",
- "bitRate": "Bitna hitrost",
- "discSubtitle": "Podnapisi",
- "starred": "Priljubljen",
- "comment": "Opomba",
- "rating": "Ocena",
- "quality": "Kakovost",
- "bpm": "BPM",
- "playDate": "Zadnja predvajana",
- "channels": "Kanali",
- "createdAt": "Datum dodano"
- },
- "actions": {
- "addToQueue": "Predvajaj kasneje",
- "playNow": "Predvajaj",
- "addToPlaylist": "Dodaj na seznam predvajanj",
- "shuffleAll": "Premešaj vse",
- "download": "Naloži",
- "playNext": "Naslednji",
- "info": "Več informacij"
- }
- },
- "album": {
- "name": "Album |||| Albumi",
- "fields": {
- "albumArtist": "Avtor albuma",
- "artist": "Izvajalec",
- "duration": "Dolžina",
- "songCount": "Pesmi",
- "playCount": "Predvajano",
- "name": "Naslov",
- "genre": "Žanr",
- "compilation": "Kompilacija",
- "year": "Leto",
- "updatedAt": "Posodobljeno",
- "comment": "Opomba",
- "rating": "Ocena",
- "createdAt": "Datum dodano",
- "size": "Velikost",
- "originalDate": "Original",
- "releaseDate": "Izdano",
- "releases": "Izdaja |||| Izdaje",
- "released": "Izdano"
- },
- "actions": {
- "playAll": "Predvajaj vse",
- "playNext": "Naslednji",
- "addToQueue": "Predvajaj kasneje",
- "shuffle": "Premešaj",
- "addToPlaylist": "Dodaj v seznam predvajanja",
- "download": "Naloži",
- "info": "Več informacij",
- "share": "Deli"
- },
- "lists": {
- "all": "Vse",
- "random": "Naključno",
- "recentlyAdded": "Dodan nedavno",
- "recentlyPlayed": "Predvajan nedavno",
- "mostPlayed": "Največ predvajano",
- "starred": "Priljubljeni",
- "topRated": "Najvišje ocenjeno"
- }
- },
- "artist": {
- "name": "Izvajalec |||| Izvajalci",
- "fields": {
- "name": "Ime",
- "albumCount": "# albumov",
- "songCount": "# pesmi",
- "playCount": "# predvajanj",
- "rating": "Ocena",
- "genre": "Žanr",
- "size": "Velikost"
- }
- },
- "user": {
- "name": "Uporabnik |||| Uporabniki",
- "fields": {
- "userName": "Uporabnik",
- "isAdmin": "Upravitelj",
- "lastLoginAt": "Zadnji vpis",
- "updatedAt": "Posodobljeno",
- "name": "Ime",
- "password": "Geslo",
- "createdAt": "Ustvarjeno",
- "changePassword": "Spremeni geslo?",
- "currentPassword": "Trenutno geslo",
- "newPassword": "Novo geslo",
- "token": "Žeton"
- },
- "helperTexts": {
- "name": "Sprememba imena bo vidna pri naslednjem vpisu"
- },
- "notifications": {
- "created": "Uporabnik ustvarjen",
- "updated": "Uporabnik posodobljen",
- "deleted": "Uporabnik izbrisan"
- },
- "message": {
- "listenBrainzToken": "Vnesi žeton uporabnika ListenBrainz.",
- "clickHereForToken": "Klikni za žeton"
- }
- },
- "player": {
- "name": "Predvajalnik |||| Predvajalniki",
- "fields": {
- "name": "Naziv",
- "transcodingId": "Transkodiranje",
- "maxBitRate": "Maks. bitrate",
- "client": "Klijent",
- "userName": "Uporabnik",
- "lastSeen": "Zadnjič viden",
- "reportRealPath": "Zabeleži pravo pot",
- "scrobbleEnabled": "Pošlji Scrobbles zunanjim storitvam"
- }
- },
- "transcoding": {
- "name": "Transkodiranje |||| Transkodiranje",
- "fields": {
- "name": "Ime",
- "targetFormat": "Ciljni format",
- "defaultBitRate": "Privzet bitrate",
- "command": "Ukaz"
- }
- },
- "playlist": {
- "name": "Seznam predvajanj |||| Seznami predvajanj",
- "fields": {
- "name": "Ime",
- "duration": "Dolžina",
- "ownerName": "Lastnik",
- "public": "Javno",
- "updatedAt": "Posodobljen",
- "createdAt": "Ustvarjen",
- "songCount": "# pesmi",
- "comment": "Opomba",
- "sync": "Avtomatski uvoz",
- "path": "Uvozi iz"
- },
- "actions": {
- "selectPlaylist": "Izberi seznam",
- "addNewPlaylist": "Ustvari \"%{name}\"",
- "export": "Izvozi",
- "makePublic": "Naredi javno",
- "makePrivate": "Naredi zasebno"
- },
- "message": {
- "duplicate_song": "Dodaj podvojene pesmi",
- "song_exist": "Seznamu predvajanja boste dodali duplikate. Jih želite dodati ali izpustiti?"
- }
- },
- "radio": {
- "name": "Radio |||| Radiji",
- "fields": {
- "name": "Ime",
- "streamUrl": "URL toka",
- "homePageUrl": "URL domače strani",
- "updatedAt": "Posodobljeno ob",
- "createdAt": "Ustvarjeno ob"
- },
- "actions": {
- "playNow": "Predvajaj"
- }
- },
- "share": {
- "name": "Deli |||| Delitev",
- "fields": {
- "username": "Delil z",
- "url": "URL",
- "description": "Opis",
- "contents": "Vsebine",
- "expiresAt": "Poteče",
- "lastVisitedAt": "Nazadnje obiskano",
- "visitCount": "Obiski",
- "format": "Oblika",
- "maxBitRate": "Maks. bitna hitrost",
- "updatedAt": "Posodobljeno ob",
- "createdAt": "Ustvarjeno ob",
- "downloadable": "Dovoli prenose?"
- }
- }
+ "languageName": "Slovenščina",
+ "resources": {
+ "song": {
+ "name": "Pesem |||| Pesmi",
+ "fields": {
+ "albumArtist": "Avtor albuma",
+ "duration": "Dolžina",
+ "trackNumber": "#",
+ "playCount": "Predvajano",
+ "title": "Naslov",
+ "artist": "Avtor",
+ "album": "Album",
+ "path": "Pot datoteke",
+ "genre": "Žanr",
+ "compilation": "Kompilacija",
+ "year": "Leto",
+ "size": "Velikost datoteke",
+ "updatedAt": "Posodobljeno",
+ "bitRate": "Bitna hitrost",
+ "discSubtitle": "Podnapisi",
+ "starred": "Priljubljen",
+ "comment": "Opomba",
+ "rating": "Ocena",
+ "quality": "Kakovost",
+ "bpm": "BPM",
+ "playDate": "Zadnja predvajana",
+ "channels": "Kanali",
+ "createdAt": "Datum dodano",
+ "grouping": "Grupiranje",
+ "mood": "Razpoloženje",
+ "participants": "Dodatni udeleženci",
+ "tags": "Dodatne oznake",
+ "mappedTags": "Preslikane oznake",
+ "rawTags": "Nespremenjene oznake",
+ "bitDepth": "Bitna globina",
+ "sampleRate": "Frekvenca vzorčenja",
+ "missing": "Manjka",
+ "libraryName": "Knjižnica"
+ },
+ "actions": {
+ "addToQueue": "Predvajaj kasneje",
+ "playNow": "Predvajaj",
+ "addToPlaylist": "Dodaj na seznam predvajanj",
+ "shuffleAll": "Premešaj vse",
+ "download": "Naloži",
+ "playNext": "Naslednji",
+ "info": "Več informacij",
+ "showInPlaylist": "Prikaži na seznamu predvajanja"
+ }
},
- "ra": {
- "auth": {
- "welcome1": "Hvala, da ste naložili Navidrome!",
- "welcome2": "Za začetek, ustvarite upraviteljski račun",
- "confirmPassword": "Potrdi Geslo",
- "buttonCreateAdmin": "Ustvari upravitelja",
- "auth_check_error": "Vpišite se za nadaljevanje",
- "user_menu": "Profil",
- "username": "Uporabnik",
- "password": "Geslo",
- "sign_in": "Vpis",
- "sign_in_error": "Avtentikacija neuspešna, poskusite ponovno",
- "logout": "Izpis"
- },
- "validation": {
- "invalidChars": "Uporabi samo alfanumerične znake",
- "passwordDoesNotMatch": "Geslo se ne ujema",
- "required": "Potreben",
- "minLength": "Potrebnih je vsaj %{min} znakov",
- "maxLength": "Potrebnih je največ %{max}",
- "minValue": "Potrebnih je vsaj %{min}",
- "maxValue": "Potrebnih je največ %{max}",
- "number": "Mora biti številka",
- "email": "Veljaven e-poštni naslov",
- "oneOf": "Mora biti ena izmed %{options}",
- "regex": "Mora se ujemati z določeno obliko (regexp): %{pattern}",
- "unique": "Mora biti edinstven",
- "url": "Biti mora veljaven URL"
- },
- "action": {
- "add_filter": "Dodaj filter",
- "add": "Dodaj",
- "back": "Nazaj",
- "bulk_actions": "Izbran 1 element |||| Izbranih %{smart_count} elementov",
- "cancel": "Prekliči",
- "clear_input_value": "Pobriši",
- "clone": "Podvoji",
- "confirm": "Potrdi",
- "create": "Ustvari",
- "delete": "Izbriši",
- "edit": "Uredi",
- "export": "Izvozi",
- "list": "Seznam",
- "refresh": "Osveži",
- "remove_filter": "Odstrani filter",
- "remove": "Odstrani",
- "save": "Shrani",
- "search": "Išči",
- "show": "Prikaži",
- "sort": "Razvrsti",
- "undo": "Razveljavi",
- "expand": "Razširi",
- "close": "Zapri",
- "open_menu": "Odpri meni",
- "close_menu": "Zapri meni",
- "unselect": "Prekliči izbiro",
- "skip": "Izpusti",
- "bulk_actions_mobile": "1 |||| %{smart_count}",
- "share": "Deli",
- "download": "Prenesi"
- },
- "boolean": {
- "true": "Da",
- "false": "Ne"
- },
- "page": {
- "create": "Ustvari %{name}",
- "dashboard": "Nadzorna plošča",
- "edit": "%{name} #%{id}",
- "error": "Nedoločena napaka",
- "list": "%{name}",
- "loading": "Nalagam",
- "not_found": "Ni zadetka",
- "show": "%{name} #%{id}",
- "empty": "Še brez %{name}.",
- "invite": "Ga želite dodati?"
- },
- "input": {
- "file": {
- "upload_several": "Povlecite datoteke ali pa kliknite in izberite.",
- "upload_single": "Povlecite datoteko ali pa kliknite in izberite."
- },
- "image": {
- "upload_several": "Povlecite slike, ali pa kliknite in izberite.",
- "upload_single": "Povlecite sliko, ali pa kliknite in izberite."
- },
- "references": {
- "all_missing": "Ne najdem referenciranih podatkov.",
- "many_missing": "Zdi se, da vsaj ena asociirana referenca ni več na voljo.",
- "single_missing": "Zdi se, da asociirana referenca ni več na voljo."
- },
- "password": {
- "toggle_visible": "Skrij geslo",
- "toggle_hidden": "Prikaži geslo"
- }
- },
- "message": {
- "about": "O programu",
- "are_you_sure": "Ste prepričani?",
- "bulk_delete_content": "Ste prepričani, da želite izbrisati %{name}? |||| Ste prepričani, da želite izbrisati %{smart_count} elementov?",
- "bulk_delete_title": "Izbriši %{name} |||| Izbriši %{smart_count} %{name}",
- "delete_content": "Ste prepričani, da želite izbrisati ta element?",
- "delete_title": "Izbriši %{name} #%{id}",
- "details": "Podrobnosti",
- "error": "Napak klijenta. Vaš zahtevek se je zaključil neuspešno.",
- "invalid_form": "Oblika ni veljavna. Prosim preverite napake",
- "loading": "Stran se nalaga, trenutek",
- "no": "Ne",
- "not_found": "Ali ste vtipkali napačen naslov (URL), ali pa sledili neobstoječi povezavi.",
- "yes": "Da",
- "unsaved_changes": "Nekate spremembe se niso shranile. Ste prepričani, da jih želite ignorirati?"
- },
- "navigation": {
- "no_results": "Ni zadetkov",
- "no_more_results": "Številka strani %{page} je zunaj meja. Preizkusite prejšnjo stran.",
- "page_out_of_boundaries": "Številka strani %{page} je zunaj meja",
- "page_out_from_end": "Ne gre dalje od zadnje strani",
- "page_out_from_begin": "Ne gre pred prvo stran",
- "page_range_info": "%{offsetBegin}-%{offsetEnd} od %{total}",
- "page_rows_per_page": "Elementov na stran:",
- "next": "Naslednji",
- "prev": "Prejšnji",
- "skip_nav": "Preskoči k vsebini"
- },
- "notification": {
- "updated": "Element posodobljen |||| Posodobljenih %{smart_count} elementov",
- "created": "Element dodan",
- "deleted": "Element izbrisan |||| %{smart_count} elementov izbrisanih",
- "bad_item": "Nepravilen element",
- "item_doesnt_exist": "Element ne obstaja",
- "http_error": "Strežnika napaka v komunikaciji",
- "data_provider_error": "Napaka dataProvider error. Preverite konzolo za podrobnosti.",
- "i18n_error": "Ne uspem naložiti prevode za izbran jezik",
- "canceled": "Akcija preklicana",
- "logged_out": "Seja je potekla, prosim povežite se ponovno.",
- "new_version": "Na voljo je nova verzija! Prosim osvežite okno."
- },
- "toggleFieldsMenu": {
- "columnsToDisplay": "Prikaži stolpce",
- "layout": "Razporeditev",
- "grid": "Mreža",
- "table": "Tabela"
- }
+ "album": {
+ "name": "Album |||| Albumi",
+ "fields": {
+ "albumArtist": "Avtor albuma",
+ "artist": "Izvajalec",
+ "duration": "Dolžina",
+ "songCount": "Pesmi",
+ "playCount": "Predvajano",
+ "name": "Naslov",
+ "genre": "Žanr",
+ "compilation": "Kompilacija",
+ "year": "Leto",
+ "updatedAt": "Posodobljeno",
+ "comment": "Opomba",
+ "rating": "Ocena",
+ "createdAt": "Datum dodano",
+ "size": "Velikost",
+ "originalDate": "Original",
+ "releaseDate": "Izdano",
+ "releases": "Izdaja |||| Izdaje",
+ "released": "Izdano",
+ "recordLabel": "Založba",
+ "catalogNum": "Kataloška številka",
+ "releaseType": "Tip",
+ "grouping": "Grupiranje",
+ "media": "Medij",
+ "mood": "Razpoloženje",
+ "date": "Datum snemanja",
+ "missing": "Manjka",
+ "libraryName": "Knjižnica"
+ },
+ "actions": {
+ "playAll": "Predvajaj vse",
+ "playNext": "Naslednji",
+ "addToQueue": "Predvajaj kasneje",
+ "shuffle": "Premešaj",
+ "addToPlaylist": "Dodaj v seznam predvajanja",
+ "download": "Naloži",
+ "info": "Več informacij",
+ "share": "Deli"
+ },
+ "lists": {
+ "all": "Vse",
+ "random": "Naključno",
+ "recentlyAdded": "Dodan nedavno",
+ "recentlyPlayed": "Predvajan nedavno",
+ "mostPlayed": "Največ predvajano",
+ "starred": "Priljubljeni",
+ "topRated": "Najvišje ocenjeno"
+ }
},
- "message": {
- "note": "OPOMBA",
- "transcodingDisabled": "Sprememba konfiguracije transkodiranja skozi spletni vmesnik je onemogočeno zaradi varnostnih razlogov. Če želite spremeniti (urediti ali izbrisati) možnosti transkodiranja, ponovno zaženite strežnik z %{config} nastavitvami.",
- "transcodingEnabled": "Navidrome trenutno uporablja nastavitve %{config}, kar pomeni da je možno pognati sistemske ukaze v nastavitvah transkodiranja preko spletnega vmesnika.\nZaradi varnostnih razlogov je možnost priporočeno onemogočiti , razen v primeru spreminjanja nastavitev.",
- "songsAddedToPlaylist": "Dodaj pesem na seznam predvajanj |||| Dodaj %{smart_count} pesmi na seznam predvajanj",
- "noPlaylistsAvailable": "Ni seznamov",
- "delete_user_title": "Odstrani uporabnika '%{name}'",
- "delete_user_content": "Ste prepričani o izbrisu uporabnika, vključno z njegovimi podatki (tudi seznami predvajanj in nastavitvami)?",
- "notifications_blocked": "V vašem brskljalniku Imate blokirana možnost obvestil za to spletno stran",
- "notifications_not_available": "Vaš brskljalnik ne omogoča obvestil na namizju ali pa do Navidrome ne dostopate po varni povezavi (https)",
- "lastfmLinkSuccess": "Last.fm uspešno povezan in 'scrobbling' omogočen",
- "lastfmLinkFailure": "Last.fm ni uspešno povezan",
- "lastfmUnlinkSuccess": "Last.fm povezava prekinjena in 'scrobbling' onemogočen",
- "lastfmUnlinkFailure": "Last.fm povezava neuspešno prekinjena",
- "openIn": {
- "lastfm": "Odpri v Last.fm",
- "musicbrainz": "Odpri v MusicBrainz"
- },
- "lastfmLink": "Preberi več...",
- "listenBrainzLinkSuccess": "ListenBrainz uspešno povezan in scrobbling vključen za uporabnika: %{user}",
- "listenBrainzLinkFailure": "ListBrainz neuspešno povezan: %{error}",
- "listenBrainzUnlinkSuccess": "ListenBrainz povezava prekinjena in scrobbling izključen",
- "listenBrainzUnlinkFailure": "ListenBrainz prekinitev povezave neuspešna",
- "downloadOriginalFormat": "Prenesi v izvirni obliki",
- "shareOriginalFormat": "Deli v izvirni obliki",
- "shareDialogTitle": "Deli %{resource} '%{name}'",
- "shareBatchDialogTitle": "Deli 1 %{resource} |||| Deli %{smart_count} %{resource}",
- "shareSuccess": "URL kopiran v odložišče: %{url}",
- "shareFailure": "Napaka pri kopiranju URL-ja %{url} v odložišče",
- "downloadDialogTitle": "Prenesi %{resource} '%{name}' (%{size})",
- "shareCopyToClipboard": "Kopiraj v odložišče: Ctrl+C, Enter"
+ "artist": {
+ "name": "Izvajalec |||| Izvajalci",
+ "fields": {
+ "name": "Ime",
+ "albumCount": "# albumov",
+ "songCount": "# pesmi",
+ "playCount": "# predvajanj",
+ "rating": "Ocena",
+ "genre": "Žanr",
+ "size": "Velikost",
+ "role": "Vloga",
+ "missing": "Manjka"
+ },
+ "roles": {
+ "albumartist": "Izvajalec albuma |||| Izvajalci albuma",
+ "artist": "Izvajalec |||| Izvajalci",
+ "composer": "Skladatelj |||| Skladatelji",
+ "conductor": "Dirigent |||| Dirigenti",
+ "lyricist": "Tekstopisec |||| Tekstopisci",
+ "arranger": "Aranžer |||| Aranžerji",
+ "producer": "Producent |||| Producenti",
+ "director": "Glasbeni vodja |||| Glasbene vodje",
+ "engineer": "Inženir |||| Inženirji",
+ "mixer": "Mešalec |||| Mešalci",
+ "remixer": "Remikser |||| Remikserji",
+ "djmixer": "DJ mešalec |||| DJ mešalci",
+ "performer": "Izvajalec |||| Izvajalci",
+ "maincredit": "Izvajalec albuma ali izvajalec |||| Izvajalci albuma ali izvajalci"
+ },
+ "actions": {
+ "shuffle": "Naključno predvajanje",
+ "radio": "Radio",
+ "topSongs": "Najboljše pesmi"
+ }
},
- "menu": {
- "library": "Knjižnica",
- "settings": "Nastavitve",
- "version": "Različica",
- "theme": "Tema",
- "personal": {
- "name": "Osebno",
- "options": {
- "theme": "Tema",
- "language": "Jezik",
- "defaultView": "Privzet pogled",
- "desktop_notifications": "Namizna obvestila",
- "lastfmScrobbling": "'Scrobble' do Last.fm",
- "listenBrainzScrobbling": "Scrobble k ListenBrainz",
- "replaygain": "ReplayGain način",
- "preAmp": "ReplayGain PreAmp (dB)",
- "gain": {
- "none": "Onemogočeno",
- "album": "Uporabi Album Gain",
- "track": "Uporabi Track Gain"
- }
- }
- },
- "albumList": "Albumi",
- "about": "O programu",
- "playlists": "Seznami predvajanj",
- "sharedPlaylists": "Deljeni seznami predvajanj"
+ "user": {
+ "name": "Uporabnik |||| Uporabniki",
+ "fields": {
+ "userName": "Uporabnik",
+ "isAdmin": "Upravitelj",
+ "lastLoginAt": "Zadnji vpis",
+ "updatedAt": "Posodobljeno",
+ "name": "Ime",
+ "password": "Geslo",
+ "createdAt": "Ustvarjeno",
+ "changePassword": "Spremeni geslo?",
+ "currentPassword": "Trenutno geslo",
+ "newPassword": "Novo geslo",
+ "token": "Žeton",
+ "lastAccessAt": "Zadnji dostop",
+ "libraries": "Knjižnice"
+ },
+ "helperTexts": {
+ "name": "Sprememba imena bo vidna pri naslednjem vpisu",
+ "libraries": "Izberite določene knjižnice za uporabnika ali pustite prazno, če želite uporabiti privzete knjižnice"
+ },
+ "notifications": {
+ "created": "Uporabnik ustvarjen",
+ "updated": "Uporabnik posodobljen",
+ "deleted": "Uporabnik izbrisan"
+ },
+ "message": {
+ "listenBrainzToken": "Vnesi žeton uporabnika ListenBrainz.",
+ "clickHereForToken": "Klikni za žeton",
+ "selectAllLibraries": "Izberi vse knjižnice",
+ "adminAutoLibraries": "Skrbniški uporabniki imajo samodejno dostop do vseh knjižnic"
+ },
+ "validation": {
+ "librariesRequired": "Za uporabnike brez skrbniških pravic mora biti izbrana vsaj ena knjižnica"
+ }
},
"player": {
- "playListsText": "Predvajaj vrsto",
- "openText": "Odpri",
- "closeText": "Zapri",
- "notContentText": "Ni glasbe",
- "clickToPlayText": "Predvajaj",
- "clickToPauseText": "Premor predvajanja",
- "nextTrackText": "Naslednje predvajanje",
- "previousTrackText": "Prejšnji",
- "reloadText": "Ponovno naloži",
- "volumeText": "Glasnost",
- "toggleLyricText": "Preklopi besedila",
- "toggleMiniModeText": "Pomanjšaj",
- "destroyText": "Uniči",
- "downloadText": "Naloži",
- "removeAudioListsText": "Izbriši avdio seznam",
- "clickToDeleteText": "Klikni za izbris %{name}",
- "emptyLyricText": "Ni besedila",
- "playModeText": {
- "order": "Po vrsti",
- "orderLoop": "Ponavljaj",
- "singleLoop": "Ponovi enkrat",
- "shufflePlay": "Premešaj"
- }
+ "name": "Predvajalnik |||| Predvajalniki",
+ "fields": {
+ "name": "Naziv",
+ "transcodingId": "Transkodiranje",
+ "maxBitRate": "Maks. bitrate",
+ "client": "Klijent",
+ "userName": "Uporabnik",
+ "lastSeen": "Zadnjič viden",
+ "reportRealPath": "Zabeleži pravo pot",
+ "scrobbleEnabled": "Pošlji Scrobbles zunanjim storitvam"
+ }
},
- "about": {
- "links": {
- "homepage": "Domača stran",
- "source": "Izvorna koda",
- "featureRequests": "Funkcionalni zahtevki"
- }
+ "transcoding": {
+ "name": "Transkodiranje |||| Transkodiranje",
+ "fields": {
+ "name": "Ime",
+ "targetFormat": "Ciljni format",
+ "defaultBitRate": "Privzet bitrate",
+ "command": "Ukaz"
+ }
},
- "activity": {
- "title": "Aktivnost",
- "totalScanned": "Skupaj preiskanih map",
- "quickScan": "Hitro preišči",
- "fullScan": "Polno preišči",
- "serverUptime": "Čas delovanja",
- "serverDown": "NEPOVEZAN"
+ "playlist": {
+ "name": "Seznam predvajanj |||| Seznami predvajanj",
+ "fields": {
+ "name": "Ime",
+ "duration": "Dolžina",
+ "ownerName": "Lastnik",
+ "public": "Javno",
+ "updatedAt": "Posodobljen",
+ "createdAt": "Ustvarjen",
+ "songCount": "# pesmi",
+ "comment": "Opomba",
+ "sync": "Avtomatski uvoz",
+ "path": "Uvozi iz"
+ },
+ "actions": {
+ "selectPlaylist": "Izberi seznam",
+ "addNewPlaylist": "Ustvari \"%{name}\"",
+ "export": "Izvozi",
+ "makePublic": "Naredi javno",
+ "makePrivate": "Naredi zasebno",
+ "saveQueue": "Shrani čakalno vrsto na seznam predvajanja",
+ "searchOrCreate": "Iščite po seznamih predvajanja ali vnesite besedilo, da ustvarite nove ...",
+ "pressEnterToCreate": "Pritisnite Enter za ustvarjanje novega seznama predvajanja",
+ "removeFromSelection": "Odstrani iz izbora"
+ },
+ "message": {
+ "duplicate_song": "Dodaj podvojene pesmi",
+ "song_exist": "Seznamu predvajanja boste dodali duplikate. Jih želite dodati ali izpustiti?",
+ "noPlaylistsFound": "Ni najdenih seznamov predvajanja",
+ "noPlaylists": "Ni na voljo seznamov predvajanja"
+ }
},
- "help": {
- "title": "Hitre tipke",
- "hotkeys": {
- "show_help": "Prikaži pomoč",
- "toggle_menu": "Preklopi stransko vrstico menija",
- "toggle_play": "Predvajaj / Pavza",
- "prev_song": "Prejšnja",
- "next_song": "Naslednja",
- "vol_up": "Zvišaj glasnost",
- "vol_down": "Znižaj glasnost",
- "toggle_love": "Dodaj med priljubljene",
- "current_song": "Skoči na predvajano"
- }
+ "radio": {
+ "name": "Radio |||| Radiji",
+ "fields": {
+ "name": "Ime",
+ "streamUrl": "URL toka",
+ "homePageUrl": "URL domače strani",
+ "updatedAt": "Posodobljeno ob",
+ "createdAt": "Ustvarjeno ob"
+ },
+ "actions": {
+ "playNow": "Predvajaj"
+ }
+ },
+ "share": {
+ "name": "Deli |||| Delitev",
+ "fields": {
+ "username": "Delil z",
+ "url": "URL",
+ "description": "Opis",
+ "contents": "Vsebine",
+ "expiresAt": "Poteče",
+ "lastVisitedAt": "Nazadnje obiskano",
+ "visitCount": "Obiski",
+ "format": "Oblika",
+ "maxBitRate": "Maks. bitna hitrost",
+ "updatedAt": "Posodobljeno ob",
+ "createdAt": "Ustvarjeno ob",
+ "downloadable": "Dovoli prenose?"
+ }
+ },
+ "missing": {
+ "name": "Manjkajoča datoteka |||| Manjkajoče datoteke",
+ "fields": {
+ "path": "Pot",
+ "size": "Velikost",
+ "updatedAt": "Izginil",
+ "libraryName": "Knjižnica"
+ },
+ "actions": {
+ "remove": "Odstrani",
+ "remove_all": "Odstrani vse"
+ },
+ "notifications": {
+ "removed": "Manjkajoče datoteke odstranjene"
+ },
+ "empty": "Brez manjkajočih datotek"
+ },
+ "library": {
+ "name": "Knjižnica |||| Knjižnice",
+ "fields": {
+ "name": "Ime",
+ "path": "Pot",
+ "remotePath": "Oddaljena pot",
+ "lastScanAt": "Zadnje skeniranje",
+ "songCount": "Pesmi",
+ "albumCount": "Albumi",
+ "artistCount": "Umetniki",
+ "totalSongs": "Pesmi",
+ "totalAlbums": "Albumi",
+ "totalArtists": "Umetniki",
+ "totalFolders": "Mape",
+ "totalFiles": "Datoteke",
+ "totalMissingFiles": "Manjkajoče datoteke",
+ "totalSize": "Skupna velikost",
+ "totalDuration": "Trajanje",
+ "defaultNewUsers": "Privzeto za nove uporabnike",
+ "createdAt": "Ustvarjeno",
+ "updatedAt": "Posodobljeno"
+ },
+ "sections": {
+ "basic": "Osnovne informacije",
+ "statistics": "Statistika"
+ },
+ "actions": {
+ "scan": "Skeniraj knjižnico",
+ "manageUsers": "Upravljanje dostopa uporabnikov",
+ "viewDetails": "Ogled podrobnosti"
+ },
+ "notifications": {
+ "created": "Knjižnica je uspešno ustvarjena",
+ "updated": "Knjižnica je bila uspešno posodobljena",
+ "deleted": "Knjižnica je uspešno izbrisana",
+ "scanStarted": "Skeniranje knjižnice se je začelo",
+ "scanCompleted": "Skeniranje knjižnice končano"
+ },
+ "validation": {
+ "nameRequired": "Ime knjižnice je obvezno",
+ "pathRequired": "Pot do knjižnice je obvezna",
+ "pathNotDirectory": "Pot do knjižnice mora biti imenik",
+ "pathNotFound": "Pot do knjižnice ni bila najdena",
+ "pathNotAccessible": "Pot do knjižnice ni dostopna",
+ "pathInvalid": "Neveljavna pot do knjižnice"
+ },
+ "messages": {
+ "deleteConfirm": "Ali ste prepričani, da želite izbrisati to knjižnico? S tem boste odstranili vse povezane podatke in dostop uporabnikov.",
+ "scanInProgress": "Skeniranje v teku...",
+ "noLibrariesAssigned": "Uporabnik nima dodeljenih knjižnic"
+ }
}
+ },
+ "ra": {
+ "auth": {
+ "welcome1": "Hvala, da ste naložili Navidrome!",
+ "welcome2": "Za začetek, ustvarite upraviteljski račun",
+ "confirmPassword": "Potrdi Geslo",
+ "buttonCreateAdmin": "Ustvari upravitelja",
+ "auth_check_error": "Vpišite se za nadaljevanje",
+ "user_menu": "Profil",
+ "username": "Uporabnik",
+ "password": "Geslo",
+ "sign_in": "Vpis",
+ "sign_in_error": "Avtentikacija neuspešna, poskusite ponovno",
+ "logout": "Izpis",
+ "insightsCollectionNote": "Navidrome zbira anonimne podatke o uporabi \nz namenom izboljšanja projekta. \nKliknite [tukaj], če želite izvedeti več ali se odjaviti"
+ },
+ "validation": {
+ "invalidChars": "Uporabi samo alfanumerične znake",
+ "passwordDoesNotMatch": "Geslo se ne ujema",
+ "required": "Potreben",
+ "minLength": "Potrebnih je vsaj %{min} znakov",
+ "maxLength": "Potrebnih je največ %{max}",
+ "minValue": "Potrebnih je vsaj %{min}",
+ "maxValue": "Potrebnih je največ %{max}",
+ "number": "Mora biti številka",
+ "email": "Veljaven e-poštni naslov",
+ "oneOf": "Mora biti ena izmed %{options}",
+ "regex": "Mora se ujemati z določeno obliko (regexp): %{pattern}",
+ "unique": "Mora biti edinstven",
+ "url": "Biti mora veljaven URL"
+ },
+ "action": {
+ "add_filter": "Dodaj filter",
+ "add": "Dodaj",
+ "back": "Nazaj",
+ "bulk_actions": "Izbran 1 element |||| Izbranih %{smart_count} elementov",
+ "cancel": "Prekliči",
+ "clear_input_value": "Pobriši",
+ "clone": "Podvoji",
+ "confirm": "Potrdi",
+ "create": "Ustvari",
+ "delete": "Izbriši",
+ "edit": "Uredi",
+ "export": "Izvozi",
+ "list": "Seznam",
+ "refresh": "Osveži",
+ "remove_filter": "Odstrani filter",
+ "remove": "Odstrani",
+ "save": "Shrani",
+ "search": "Išči",
+ "show": "Prikaži",
+ "sort": "Razvrsti",
+ "undo": "Razveljavi",
+ "expand": "Razširi",
+ "close": "Zapri",
+ "open_menu": "Odpri meni",
+ "close_menu": "Zapri meni",
+ "unselect": "Prekliči izbiro",
+ "skip": "Izpusti",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
+ "share": "Deli",
+ "download": "Prenesi"
+ },
+ "boolean": {
+ "true": "Da",
+ "false": "Ne"
+ },
+ "page": {
+ "create": "Ustvari %{name}",
+ "dashboard": "Nadzorna plošča",
+ "edit": "%{name} #%{id}",
+ "error": "Nedoločena napaka",
+ "list": "%{name}",
+ "loading": "Nalagam",
+ "not_found": "Ni zadetka",
+ "show": "%{name} #%{id}",
+ "empty": "Še brez %{name}.",
+ "invite": "Ga želite dodati?"
+ },
+ "input": {
+ "file": {
+ "upload_several": "Povlecite datoteke ali pa kliknite in izberite.",
+ "upload_single": "Povlecite datoteko ali pa kliknite in izberite."
+ },
+ "image": {
+ "upload_several": "Povlecite slike, ali pa kliknite in izberite.",
+ "upload_single": "Povlecite sliko, ali pa kliknite in izberite."
+ },
+ "references": {
+ "all_missing": "Ne najdem referenciranih podatkov.",
+ "many_missing": "Zdi se, da vsaj ena asociirana referenca ni več na voljo.",
+ "single_missing": "Zdi se, da asociirana referenca ni več na voljo."
+ },
+ "password": {
+ "toggle_visible": "Skrij geslo",
+ "toggle_hidden": "Prikaži geslo"
+ }
+ },
+ "message": {
+ "about": "O programu",
+ "are_you_sure": "Ste prepričani?",
+ "bulk_delete_content": "Ste prepričani, da želite izbrisati %{name}? |||| Ste prepričani, da želite izbrisati %{smart_count} elementov?",
+ "bulk_delete_title": "Izbriši %{name} |||| Izbriši %{smart_count} %{name}",
+ "delete_content": "Ste prepričani, da želite izbrisati ta element?",
+ "delete_title": "Izbriši %{name} #%{id}",
+ "details": "Podrobnosti",
+ "error": "Napak klijenta. Vaš zahtevek se je zaključil neuspešno.",
+ "invalid_form": "Oblika ni veljavna. Prosim preverite napake",
+ "loading": "Stran se nalaga, trenutek",
+ "no": "Ne",
+ "not_found": "Ali ste vtipkali napačen naslov (URL), ali pa sledili neobstoječi povezavi.",
+ "yes": "Da",
+ "unsaved_changes": "Nekate spremembe se niso shranile. Ste prepričani, da jih želite ignorirati?"
+ },
+ "navigation": {
+ "no_results": "Ni zadetkov",
+ "no_more_results": "Številka strani %{page} je zunaj meja. Preizkusite prejšnjo stran.",
+ "page_out_of_boundaries": "Številka strani %{page} je zunaj meja",
+ "page_out_from_end": "Ne gre dalje od zadnje strani",
+ "page_out_from_begin": "Ne gre pred prvo stran",
+ "page_range_info": "%{offsetBegin}-%{offsetEnd} od %{total}",
+ "page_rows_per_page": "Elementov na stran:",
+ "next": "Naslednji",
+ "prev": "Prejšnji",
+ "skip_nav": "Preskoči k vsebini"
+ },
+ "notification": {
+ "updated": "Element posodobljen |||| Posodobljenih %{smart_count} elementov",
+ "created": "Element dodan",
+ "deleted": "Element izbrisan |||| %{smart_count} elementov izbrisanih",
+ "bad_item": "Nepravilen element",
+ "item_doesnt_exist": "Element ne obstaja",
+ "http_error": "Strežnika napaka v komunikaciji",
+ "data_provider_error": "Napaka dataProvider error. Preverite konzolo za podrobnosti.",
+ "i18n_error": "Ne uspem naložiti prevode za izbran jezik",
+ "canceled": "Akcija preklicana",
+ "logged_out": "Seja je potekla, prosim povežite se ponovno.",
+ "new_version": "Na voljo je nova verzija! Prosim osvežite okno."
+ },
+ "toggleFieldsMenu": {
+ "columnsToDisplay": "Prikaži stolpce",
+ "layout": "Razporeditev",
+ "grid": "Mreža",
+ "table": "Tabela"
+ }
+ },
+ "message": {
+ "note": "OPOMBA",
+ "transcodingDisabled": "Sprememba konfiguracije transkodiranja skozi spletni vmesnik je onemogočeno zaradi varnostnih razlogov. Če želite spremeniti (urediti ali izbrisati) možnosti transkodiranja, ponovno zaženite strežnik z %{config} nastavitvami.",
+ "transcodingEnabled": "Navidrome trenutno uporablja nastavitve %{config}, kar pomeni da je možno pognati sistemske ukaze v nastavitvah transkodiranja preko spletnega vmesnika.\nZaradi varnostnih razlogov je možnost priporočeno onemogočiti , razen v primeru spreminjanja nastavitev.",
+ "songsAddedToPlaylist": "Dodaj pesem na seznam predvajanj |||| Dodaj %{smart_count} pesmi na seznam predvajanj",
+ "noPlaylistsAvailable": "Ni seznamov",
+ "delete_user_title": "Odstrani uporabnika '%{name}'",
+ "delete_user_content": "Ste prepričani o izbrisu uporabnika, vključno z njegovimi podatki (tudi seznami predvajanj in nastavitvami)?",
+ "notifications_blocked": "V vašem brskljalniku Imate blokirana možnost obvestil za to spletno stran",
+ "notifications_not_available": "Vaš brskljalnik ne omogoča obvestil na namizju ali pa do Navidrome ne dostopate po varni povezavi (https)",
+ "lastfmLinkSuccess": "Last.fm uspešno povezan in 'scrobbling' omogočen",
+ "lastfmLinkFailure": "Last.fm ni uspešno povezan",
+ "lastfmUnlinkSuccess": "Last.fm povezava prekinjena in 'scrobbling' onemogočen",
+ "lastfmUnlinkFailure": "Last.fm povezava neuspešno prekinjena",
+ "openIn": {
+ "lastfm": "Odpri v Last.fm",
+ "musicbrainz": "Odpri v MusicBrainz"
+ },
+ "lastfmLink": "Preberi več...",
+ "listenBrainzLinkSuccess": "ListenBrainz uspešno povezan in scrobbling vključen za uporabnika: %{user}",
+ "listenBrainzLinkFailure": "ListBrainz neuspešno povezan: %{error}",
+ "listenBrainzUnlinkSuccess": "ListenBrainz povezava prekinjena in scrobbling izključen",
+ "listenBrainzUnlinkFailure": "ListenBrainz prekinitev povezave neuspešna",
+ "downloadOriginalFormat": "Prenesi v izvirni obliki",
+ "shareOriginalFormat": "Deli v izvirni obliki",
+ "shareDialogTitle": "Deli %{resource} '%{name}'",
+ "shareBatchDialogTitle": "Deli 1 %{resource} |||| Deli %{smart_count} %{resource}",
+ "shareSuccess": "URL kopiran v odložišče: %{url}",
+ "shareFailure": "Napaka pri kopiranju URL-ja %{url} v odložišče",
+ "downloadDialogTitle": "Prenesi %{resource} '%{name}' (%{size})",
+ "shareCopyToClipboard": "Kopiraj v odložišče: Ctrl+C, Enter",
+ "remove_missing_title": "Odstrani manjkajoče datoteke",
+ "remove_missing_content": "Ste prepričani, da želite odstraniti izbrane manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.",
+ "remove_all_missing_title": "Odstrani vse manjkajoče datoteke",
+ "remove_all_missing_content": "Ste prepričani, da želite odstraniti vse manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.",
+ "noSimilarSongsFound": "Ni najdenih podobnih pesmi",
+ "noTopSongsFound": "Ni najdenih najboljših pesmi"
+ },
+ "menu": {
+ "library": "Knjižnica",
+ "settings": "Nastavitve",
+ "version": "Različica",
+ "theme": "Tema",
+ "personal": {
+ "name": "Osebno",
+ "options": {
+ "theme": "Tema",
+ "language": "Jezik",
+ "defaultView": "Privzet pogled",
+ "desktop_notifications": "Namizna obvestila",
+ "lastfmScrobbling": "'Scrobble' do Last.fm",
+ "listenBrainzScrobbling": "Scrobble k ListenBrainz",
+ "replaygain": "ReplayGain način",
+ "preAmp": "ReplayGain PreAmp (dB)",
+ "gain": {
+ "none": "Onemogočeno",
+ "album": "Uporabi Album Gain",
+ "track": "Uporabi Track Gain"
+ },
+ "lastfmNotConfigured": "Last.fm API ključ ni konfiguriran"
+ }
+ },
+ "albumList": "Albumi",
+ "about": "O programu",
+ "playlists": "Seznami predvajanj",
+ "sharedPlaylists": "Deljeni seznami predvajanj",
+ "librarySelector": {
+ "allLibraries": "Vse knjižnice (%{count})",
+ "multipleLibraries": "%{selected} od %{total} knjižnic",
+ "selectLibraries": "Izberite knjižnice",
+ "none": "Nobena"
+ }
+ },
+ "player": {
+ "playListsText": "Predvajaj vrsto",
+ "openText": "Odpri",
+ "closeText": "Zapri",
+ "notContentText": "Ni glasbe",
+ "clickToPlayText": "Predvajaj",
+ "clickToPauseText": "Premor predvajanja",
+ "nextTrackText": "Naslednje predvajanje",
+ "previousTrackText": "Prejšnji",
+ "reloadText": "Ponovno naloži",
+ "volumeText": "Glasnost",
+ "toggleLyricText": "Preklopi besedila",
+ "toggleMiniModeText": "Pomanjšaj",
+ "destroyText": "Uniči",
+ "downloadText": "Naloži",
+ "removeAudioListsText": "Izbriši avdio seznam",
+ "clickToDeleteText": "Klikni za izbris %{name}",
+ "emptyLyricText": "Ni besedila",
+ "playModeText": {
+ "order": "Po vrsti",
+ "orderLoop": "Ponavljaj",
+ "singleLoop": "Ponovi enkrat",
+ "shufflePlay": "Premešaj"
+ }
+ },
+ "about": {
+ "links": {
+ "homepage": "Domača stran",
+ "source": "Izvorna koda",
+ "featureRequests": "Funkcionalni zahtevki",
+ "lastInsightsCollection": "Zbirka zadnjih vpogledov",
+ "insights": {
+ "disabled": "Onemogočeno",
+ "waiting": "Čakanje"
+ }
+ },
+ "tabs": {
+ "about": "O nas",
+ "config": "Konfiguracija"
+ },
+ "config": {
+ "configName": "Ime konfiguracije",
+ "environmentVariable": "Spremenljivka okolja",
+ "currentValue": "Trenutna vrednost",
+ "configurationFile": "Konfiguracijska datoteka",
+ "exportToml": "Izvozi konfiguracijo (TOML)",
+ "exportSuccess": "Konfiguracija izvožena v odložišče v formatu TOML",
+ "exportFailed": "Kopiranje konfiguracije ni uspelo",
+ "devFlagsHeader": "Razvojne zastavice (lahko se spremenijo/odstranijo)",
+ "devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah"
+ }
+ },
+ "activity": {
+ "title": "Aktivnost",
+ "totalScanned": "Skupaj preiskanih map",
+ "quickScan": "Hitro preišči",
+ "fullScan": "Polno preišči",
+ "serverUptime": "Čas delovanja",
+ "serverDown": "NEPOVEZAN",
+ "scanType": "Tip",
+ "status": "Napaka pri skeniranju",
+ "elapsedTime": "Pretečeni čas"
+ },
+ "help": {
+ "title": "Hitre tipke",
+ "hotkeys": {
+ "show_help": "Prikaži pomoč",
+ "toggle_menu": "Preklopi stransko vrstico menija",
+ "toggle_play": "Predvajaj / Pavza",
+ "prev_song": "Prejšnja",
+ "next_song": "Naslednja",
+ "vol_up": "Zvišaj glasnost",
+ "vol_down": "Znižaj glasnost",
+ "toggle_love": "Dodaj med priljubljene",
+ "current_song": "Skoči na predvajano"
+ }
+ },
+ "nowPlaying": {
+ "title": "Zdaj se predvaja",
+ "empty": "Nič se ne predvaja",
+ "minutesAgo": "Pred %{smart_count} minuto |||| Pred %{smart_count} minutami"
+ }
}
\ No newline at end of file
diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json
index f5ca01084..30bf89ec4 100644
--- a/resources/i18n/sv.json
+++ b/resources/i18n/sv.json
@@ -35,7 +35,8 @@
"rawTags": "Omodifierade taggar",
"bitDepth": "Bitdjup",
"sampleRate": "Samplingsfrekvens",
- "missing": "Saknade"
+ "missing": "Saknade",
+ "libraryName": "Bibliotek"
},
"actions": {
"addToQueue": "Lägg till i kön",
@@ -44,7 +45,8 @@
"shuffleAll": "Shuffle",
"download": "Ladda ner",
"playNext": "Spela nästa",
- "info": "Mer information"
+ "info": "Mer information",
+ "showInPlaylist": "Visa i spellista"
}
},
"album": {
@@ -75,7 +77,8 @@
"media": "Media",
"mood": "Stämning",
"date": "Inspelningsdatum",
- "missing": "Saknade"
+ "missing": "Saknade",
+ "libraryName": "Bibliotek"
},
"actions": {
"playAll": "Spela",
@@ -123,7 +126,13 @@
"mixer": "Mixare |||| Mixare",
"remixer": "Remixare |||| Remixare",
"djmixer": "DJ-mixare |||| DJ-mixare",
- "performer": "Utövande artist |||| Utövande artister"
+ "performer": "Utövande artist |||| Utövande artister",
+ "maincredit": "Albumartister eller Artist |||| Albumartister eller Artister"
+ },
+ "actions": {
+ "shuffle": "Shuffle",
+ "radio": "Radio",
+ "topSongs": "Topplåtar"
}
},
"user": {
@@ -140,10 +149,12 @@
"currentPassword": "Nuvarande lösenord",
"newPassword": "Nytt lösenord",
"token": "Token",
- "lastAccessAt": "Senaste åtkomst"
+ "lastAccessAt": "Senaste åtkomst",
+ "libraries": "Bibliotek"
},
"helperTexts": {
- "name": "Ändringar av ditt namn syns först vid nästa inloggning"
+ "name": "Ändringar av ditt namn syns först vid nästa inloggning",
+ "libraries": "Välj ett bibliotek för denna användare eller lämna blankt för standardbibliotek"
},
"notifications": {
"created": "Användare skapad",
@@ -152,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Ange din ListenBrainz användar-token.",
- "clickHereForToken": "Klicka här för att hämta din token"
+ "clickHereForToken": "Klicka här för att hämta din token",
+ "selectAllLibraries": "Välj alla bibliotek",
+ "adminAutoLibraries": "Administratörer har automatiskt tillgång till alla bibliotek"
+ },
+ "validation": {
+ "librariesRequired": "Minst ett bibliotek måste väljas för icke-administratörer"
}
},
"player": {
@@ -197,11 +213,16 @@
"export": "Exportera",
"makePublic": "Gör offentlig",
"makePrivate": "Gör privat",
- "saveQueue": "Spara kö till spellista"
+ "saveQueue": "Spara kö till spellista",
+ "searchOrCreate": "Sök spellista eller skapa ny...",
+ "pressEnterToCreate": "Tryck Enter för att skapa ny spellista",
+ "removeFromSelection": "Ta bort från urval"
},
"message": {
"duplicate_song": "Lägg till dubletter",
- "song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?"
+ "song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?",
+ "noPlaylistsFound": "Hittade inga spellistor",
+ "noPlaylists": "Inga spellistor tillgängliga"
}
},
"radio": {
@@ -239,7 +260,8 @@
"fields": {
"path": "Sökväg",
"size": "Storlek",
- "updatedAt": "Försvann"
+ "updatedAt": "Försvann",
+ "libraryName": "Bibliotek"
},
"actions": {
"remove": "Radera",
@@ -249,6 +271,63 @@
"removed": "Saknade fil(er) borttagna"
},
"empty": "Inga saknade filer"
+ },
+ "library": {
+ "name": "Bibliotek |||| Bibliotek",
+ "fields": {
+ "name": "Namn",
+ "path": "Sökväg",
+ "remotePath": "Ta bort sökväg",
+ "lastScanAt": "Senaste scan",
+ "songCount": "Låtar",
+ "albumCount": "Album",
+ "artistCount": "Artister",
+ "totalSongs": "Låtar",
+ "totalAlbums": "Album",
+ "totalArtists": "Artister",
+ "totalFolders": "Mappar",
+ "totalFiles": "Filer",
+ "totalMissingFiles": "Saknade filer",
+ "totalSize": "Sammanlagd storlek",
+ "totalDuration": "Längd",
+ "defaultNewUsers": "Standard för nya användare",
+ "createdAt": "Skapad",
+ "updatedAt": "Uppdaterad"
+ },
+ "sections": {
+ "basic": "Grundinformation",
+ "statistics": "Statistik"
+ },
+ "actions": {
+ "scan": "Scanna bibliotek",
+ "manageUsers": "Hantera användaråtkomst",
+ "viewDetails": "Se detaljer",
+ "quickScan": "Snabbscan",
+ "fullScan": "Komplett scan"
+ },
+ "notifications": {
+ "created": "Biblioteket har skapats",
+ "updated": "Biblioteket har uppdaterats",
+ "deleted": "Biblioteket har raderats",
+ "scanStarted": "Biblioteksscan startad",
+ "scanCompleted": "Biblioteksscan avslutad",
+ "quickScanStarted": "Snabbscan startad",
+ "fullScanStarted": "Komplett scan startad",
+ "scanError": "Fel vid start av scan. Se loggarna"
+ },
+ "validation": {
+ "nameRequired": "Biblioteksnamn krävs",
+ "pathRequired": "Bibliotekssökväg krävs",
+ "pathNotDirectory": "Bibliotekssökvägen måste vara en katalog",
+ "pathNotFound": "Bibliotekssökväg hittades inte",
+ "pathNotAccessible": "Bibliotekssökväg inte tillgänglig",
+ "pathInvalid": "Ogiltig bibliotekssökväg"
+ },
+ "messages": {
+ "deleteConfirm": "Är du säker på att du vill ta bort detta bibliotek? Detta raderar all förbunden data och användartillgång.",
+ "scanInProgress": "Scanning pågår...",
+ "noLibrariesAssigned": "Inga bibliotek har tilldelats den här användaren"
+ }
}
},
"ra": {
@@ -430,7 +509,9 @@
"remove_missing_title": "Ta bort saknade filer",
"remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
"remove_all_missing_title": "Ta bort alla saknade filer",
- "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg."
+ "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
+ "noSimilarSongsFound": "Hittade inga liknande låtar",
+ "noTopSongsFound": "Hittade inga topplåtar"
},
"menu": {
"library": "Bibliotek",
@@ -459,7 +540,13 @@
"albumList": "Album",
"about": "Om",
"playlists": "Spellistor",
- "sharedPlaylists": "Delade spellistor"
+ "sharedPlaylists": "Delade spellistor",
+ "librarySelector": {
+ "allLibraries": "Alla bibliotek (%{count})",
+ "multipleLibraries": "%{selected} av %{total} bibliotek",
+ "selectLibraries": "Valda bibliotek",
+ "none": "Inga"
+ }
},
"player": {
"playListsText": "Spela kön",
@@ -496,6 +583,21 @@
"disabled": "Inaktiverad",
"waiting": "Väntar"
}
+ },
+ "tabs": {
+ "about": "Om",
+ "config": "Inställningar"
+ },
+ "config": {
+ "configName": "Inställningsnamn",
+ "environmentVariable": "Miljövariabel",
+ "currentValue": "Nuvarande värde",
+ "configurationFile": "Inställningsfil",
+ "exportToml": "Exportera inställningar (TOML)",
+ "exportSuccess": "Inställningarna kopierade till urklippet i TOML-format",
+ "exportFailed": "Kopiering av inställningarna misslyckades",
+ "devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)",
+ "devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner"
}
},
"activity": {
@@ -507,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Fel vid scanning",
- "elapsedTime": "Spelad tid"
+ "elapsedTime": "Spelad tid",
+ "selectiveScan": "Urval"
},
"help": {
"title": "Navidrome kortkommandon",
@@ -522,5 +625,10 @@
"toggle_love": "Lägg till låt i favoriter",
"current_song": "Hoppa till nuvarande låt"
}
+ },
+ "nowPlaying": {
+ "title": "Spelas nu",
+ "empty": "Inget spelas",
+ "minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan"
}
}
\ No newline at end of file
diff --git a/resources/i18n/th.json b/resources/i18n/th.json
index 2f96f4958..833a68ab9 100644
--- a/resources/i18n/th.json
+++ b/resources/i18n/th.json
@@ -26,7 +26,17 @@
"bpm": "BPM",
"playDate": "เล่นล่าสุด",
"channels": "ช่อง",
- "createdAt": "เพิ่มเมื่อ"
+ "createdAt": "เพิ่มเมื่อ",
+ "grouping": "จัดกลุ่ม",
+ "mood": "อารมณ์",
+ "participants": "ผู้มีส่วนร่วม",
+ "tags": "แทกเพิ่มเติม",
+ "mappedTags": "แมพแทก",
+ "rawTags": "แทกเริ่มต้น",
+ "bitDepth": "Bit depth",
+ "sampleRate": "แซมเปิ้ลเรต",
+ "missing": "หายไป",
+ "libraryName": "ห้องสมุด"
},
"actions": {
"addToQueue": "เพิ่มในคิว",
@@ -35,7 +45,8 @@
"shuffleAll": "สุ่มทั้งหมด",
"download": "ดาวน์โหลด",
"playNext": "เล่นถัดไป",
- "info": "ดูรายละเอียด"
+ "info": "ดูรายละเอียด",
+ "showInPlaylist": "แสดงในเพลย์ลิสต์"
}
},
"album": {
@@ -58,7 +69,16 @@
"originalDate": "วันที่เริ่ม",
"releaseDate": "เผยแพร่เมื่อ",
"releases": "เผยแพร่ |||| เผยแพร่",
- "released": "เผยแพร่เมื่อ"
+ "released": "เผยแพร่เมื่อ",
+ "recordLabel": "ป้าย",
+ "catalogNum": "หมายเลขแคตาล็อก",
+ "releaseType": "ประเภท",
+ "grouping": "จัดกลุ่ม",
+ "media": "มีเดีย",
+ "mood": "อารมณ์",
+ "date": "บันทึกเมื่อ",
+ "missing": "หายไป",
+ "libraryName": "ห้องสมุด"
},
"actions": {
"playAll": "เล่นทั้งหมด",
@@ -89,7 +109,30 @@
"playCount": "เล่นแล้ว",
"rating": "ความนิยม",
"genre": "ประเภท",
- "size": "ขนาด"
+ "size": "ขนาด",
+ "role": "Role",
+ "missing": "หายไป"
+ },
+ "roles": {
+ "albumartist": "ศิลปินอัลบั้ม |||| ศิลปินอัลบั้ม",
+ "artist": "ศิลปิน |||| ศิลปิน",
+ "composer": "ผู้แต่ง |||| ผู้แต่ง",
+ "conductor": "คอนดักเตอร์ |||| คอนดักเตอร์",
+ "lyricist": "เนื้อเพลง |||| เนื้อเพลง",
+ "arranger": "ผู้ดำเนินการ |||| ผู้ดำเนินการ",
+ "producer": "ผู้จัด |||| ผู้จัด",
+ "director": "ไดเรกเตอร์ |||| ไดเรกเตอร์",
+ "engineer": "วิศวกร |||| วิศวกร",
+ "mixer": "มิกเซอร์ |||| มิกเซอร์",
+ "remixer": "รีมิกเซอร์ |||| รีมิกเซอร์",
+ "djmixer": "ดีเจมิกเซอร์ |||| ดีเจมิกเซอร์",
+ "performer": "ผู้เล่น |||| ผู้เล่น",
+ "maincredit": "ศิลปิน |||| ศิลปิน"
+ },
+ "actions": {
+ "shuffle": "เล่นสุ่ม",
+ "radio": "วิทยุ",
+ "topSongs": "เพลงยอดนิยม"
}
},
"user": {
@@ -106,10 +149,12 @@
"currentPassword": "รหัสผ่านปัจจุบัน",
"newPassword": "รหัสผ่านใหม่",
"token": "โทเคน",
- "lastAccessAt": "เข้าใช้ล่าสุด"
+ "lastAccessAt": "เข้าใช้ล่าสุด",
+ "libraries": "ห้องสมุด"
},
"helperTexts": {
- "name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป"
+ "name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป",
+ "libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น"
},
"notifications": {
"created": "สร้างชื่อผู้ใช้",
@@ -118,7 +163,12 @@
},
"message": {
"listenBrainzToken": "ใส่โทเคน ListenBrainz ของคุณ",
- "clickHereForToken": "กดที่นี่เพื่อรับโทเคนของคุณ"
+ "clickHereForToken": "กดที่นี่เพื่อรับโทเคนของคุณ",
+ "selectAllLibraries": "เลือกห้องสมุดทั้งหมด",
+ "adminAutoLibraries": "ผู้ดูแลเข้าถึงห้องสมุดทั้งหมดโดยอัตโนมัติ"
+ },
+ "validation": {
+ "librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล"
}
},
"player": {
@@ -162,11 +212,17 @@
"addNewPlaylist": "สร้าง \"%{name}\"",
"export": "ส่งออก",
"makePublic": "ทำเป็นสาธารณะ",
- "makePrivate": "ทำเป็นส่วนตัว"
+ "makePrivate": "ทำเป็นส่วนตัว",
+ "saveQueue": "บันทึกคิวลงเพลย์ลิสต์",
+ "searchOrCreate": "ค้นหาเพลย์ลิสต์หรือพิมพ์เพื่อสร้างใหม่",
+ "pressEnterToCreate": "กด Enter เพื่อสร้างเพลย์ลิสต์",
+ "removeFromSelection": "เอาออกจากที่เลือกไว้"
},
"message": {
"duplicate_song": "เพิ่มเพลงซ้ำ",
- "song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม"
+ "song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม",
+ "noPlaylistsFound": "ไม่พบเพลย์ลิสต์",
+ "noPlaylists": "ไม่มีเพลย์ลิสต์อยู่"
}
},
"radio": {
@@ -198,6 +254,80 @@
"createdAt": "สร้างเมื่อ",
"downloadable": "อนุญาตให้ดาวโหลด?"
}
+ },
+ "missing": {
+ "name": "ไฟล์ที่หายไป |||| ไฟล์ที่หายไป",
+ "fields": {
+ "path": "พาร์ท",
+ "size": "ขนาด",
+ "updatedAt": "หายไปจาก",
+ "libraryName": "ห้องสมุด"
+ },
+ "actions": {
+ "remove": "เอาออก",
+ "remove_all": "เอาออกทั้งหมด"
+ },
+ "notifications": {
+ "removed": "เอาไฟล์ที่หายไปออกแล้ว"
+ },
+ "empty": "ไม่มีไฟล์หาย"
+ },
+ "library": {
+ "name": "ห้องสมุด |||| ห้องสมุด",
+ "fields": {
+ "name": "ชื่อ",
+ "path": "พาร์ท",
+ "remotePath": "รีโมทพาร์ท",
+ "lastScanAt": "สแกนล่าสุด",
+ "songCount": "เพลง",
+ "albumCount": "อัลบัม",
+ "artistCount": "ศิลปิน",
+ "totalSongs": "เพลง",
+ "totalAlbums": "อัลบัม",
+ "totalArtists": "ศิลปิน",
+ "totalFolders": "แฟ้ม",
+ "totalFiles": "ไฟล์",
+ "totalMissingFiles": "ไฟล์ที่หายไป",
+ "totalSize": "ขนาดทั้งหมด",
+ "totalDuration": "ความยาว",
+ "defaultNewUsers": "ค่าเริ่มต้นผู้ใช้ใหม่",
+ "createdAt": "สร้าง",
+ "updatedAt": "อัพเดท"
+ },
+ "sections": {
+ "basic": "ข้อมูลเบื้องต้น",
+ "statistics": "สถิติ"
+ },
+ "actions": {
+ "scan": "สแกนห้องสมุด",
+ "manageUsers": "ตั้งค่าการเข้าถึง",
+ "viewDetails": "ดูรายละเอียด",
+ "quickScan": "สแกนแบบเร็ว",
+ "fullScan": "สแกนแบบเต็ม"
+ },
+ "notifications": {
+ "created": "สร้างห้องสมุดเรียบร้อย",
+ "updated": "อัพเดทห้องสมุดเรียบร้อย",
+ "deleted": "ลบห้องสมุดเพลงเรียบร้อยแล้ว",
+ "scanStarted": "เริ่มสแกนห้องสมุด",
+ "scanCompleted": "สแกนห้องสมุดเสร็จแล้ว",
+ "quickScanStarted": "เริ่มสแกนแบบเร็ว",
+ "fullScanStarted": "เริ่มสแกนแบบเต็ม",
+ "scanError": "การเริ่มสแกนผิดพลาด ดูในบันทึก"
+ },
+ "validation": {
+ "nameRequired": "ต้องใส่ชื่อห้องสมุดเพลง",
+ "pathRequired": "ต้องใส่พาร์ทของห้องสมุด",
+ "pathNotDirectory": "พาร์ทของห้องสมุดต้องเป็นแฟ้ม",
+ "pathNotFound": "ไม่เจอพาร์ทของห้องสมุด",
+ "pathNotAccessible": "ไม่สามารถเข้าพาร์ทของห้องสมุด",
+ "pathInvalid": "พาร์ทห้องสมุดไม่ถูก"
+ },
+ "messages": {
+ "deleteConfirm": "คุณแน่ใจว่าจะลบห้องสมุดนี้? นี่จะลบข้อมูลและการเข้าถึงของผู้ใช้ที่เกี่ยวข้องทั้งหมด",
+ "scanInProgress": "กำลังสแกน...",
+ "noLibrariesAssigned": "ไม่มีห้องสมุดสำหรับผู้ใช้นี้"
+ }
}
},
"ra": {
@@ -375,7 +505,13 @@
"shareSuccess": "คัดลอก URL ไปคลิปบอร์ด: %{url}",
"shareFailure": "คัดลอก URL %{url} ไปคลิปบอร์ดผิดพลาด",
"downloadDialogTitle": "ดาวโหลด %{resource} '%{name}' (%{size})",
- "shareCopyToClipboard": "คัดลอกไปคลิปบอร์ด: Ctrl+C, Enter"
+ "shareCopyToClipboard": "คัดลอกไปคลิปบอร์ด: Ctrl+C, Enter",
+ "remove_missing_title": "ลบรายการไฟล์ที่หายไป",
+ "remove_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
+ "remove_all_missing_title": "เอารายการไฟล์ที่หายไปออกทั้งหมด",
+ "remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
+ "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน",
+ "noTopSongsFound": "ไม่พบเพลงยอดนิยม"
},
"menu": {
"library": "ห้องสมุดเพลง",
@@ -404,7 +540,13 @@
"albumList": "อัลบั้ม",
"about": "เกี่ยวกับ",
"playlists": "เพลย์ลิสต์",
- "sharedPlaylists": "เพลย์ลิสต์ที่แบ่งปัน"
+ "sharedPlaylists": "เพลย์ลิสต์ที่แบ่งปัน",
+ "librarySelector": {
+ "allLibraries": "ห้องสมุด (%{count}) ห้อง",
+ "multipleLibraries": "%{selected} ของ %{total} ห้องสมุด",
+ "selectLibraries": "เลือกห้องสมุด",
+ "none": "ไม่มี"
+ }
},
"player": {
"playListsText": "คิวเล่น",
@@ -441,6 +583,21 @@
"disabled": "ปิดการทำงาน",
"waiting": "รอ"
}
+ },
+ "tabs": {
+ "about": "เกี่ยวกับ",
+ "config": "การตั้งค่า"
+ },
+ "config": {
+ "configName": "ชื่อการตั้งค่า",
+ "environmentVariable": "ค่าทั่วไป",
+ "currentValue": "ค่าปัจจุบัน",
+ "configurationFile": "ไฟล์การตั้งค่า",
+ "exportToml": "นำออกการตั้งค่า (TOML)",
+ "exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว",
+ "exportFailed": "คัดลอกการตั้งค่าล้มเหลว",
+ "devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)",
+ "devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง"
}
},
"activity": {
@@ -449,7 +606,11 @@
"quickScan": "สแกนแบบเร็ว",
"fullScan": "สแกนทั้งหมด",
"serverUptime": "เซิร์ฟเวอร์ออนไลน์นาน",
- "serverDown": "ออฟไลน์"
+ "serverDown": "ออฟไลน์",
+ "scanType": "ประเภท",
+ "status": "สแกนผิดพลาด",
+ "elapsedTime": "เวลาที่ใช้",
+ "selectiveScan": "เลือก"
},
"help": {
"title": "คีย์ลัด Navidrome",
@@ -464,5 +625,10 @@
"toggle_love": "เพิ่มเพลงนี้ไปยังรายการโปรด",
"current_song": "ไปยังเพลงปัจจุบัน"
}
+ },
+ "nowPlaying": {
+ "title": "กำลังเล่น",
+ "empty": "ไม่มีเพลงเล่น",
+ "minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว"
}
}
\ No newline at end of file
diff --git a/resources/i18n/tr.json b/resources/i18n/tr.json
index 3cd801738..7c1a82c08 100644
--- a/resources/i18n/tr.json
+++ b/resources/i18n/tr.json
@@ -35,7 +35,8 @@
"rawTags": "Ham etiketler",
"bitDepth": "Bit derinliği",
"sampleRate": "Örnekleme Oranı",
- "missing": ""
+ "missing": "Eksik",
+ "libraryName": "Kütüphane"
},
"actions": {
"addToQueue": "Oynatma Sırasına Ekle",
@@ -44,7 +45,8 @@
"shuffleAll": "Tümünü karıştır",
"download": "İndir",
"playNext": "Dinlenenden Sonra Oynat",
- "info": "Bilgiler"
+ "info": "Bilgiler",
+ "showInPlaylist": "Çalma Listesinde Göster"
}
},
"album": {
@@ -75,7 +77,8 @@
"media": "Medya",
"mood": "Mod",
"date": "Kayıt Tarihi",
- "missing": ""
+ "missing": "Eksik",
+ "libraryName": "Kütüphane"
},
"actions": {
"playAll": "Oynat",
@@ -108,7 +111,7 @@
"genre": "Tür",
"size": "Boyut",
"role": "Rol",
- "missing": ""
+ "missing": "Eksik"
},
"roles": {
"albumartist": "Albüm Sanatçısı |||| Albüm Sanatçısı",
@@ -123,7 +126,13 @@
"mixer": "Mikser |||| Mikser",
"remixer": "Remiks |||| Remiks",
"djmixer": "DJ Mikseri |||| DJ Mikseri",
- "performer": "Sanatçı |||| Sanatçı"
+ "performer": "Sanatçı |||| Sanatçı",
+ "maincredit": "Albüm Sanatçısı veya Sanatçı |||| Albüm Sanatçısı veya Sanatçılar"
+ },
+ "actions": {
+ "shuffle": "Karıştır",
+ "radio": "Radyo",
+ "topSongs": "En İyi Şarkılar"
}
},
"user": {
@@ -140,10 +149,12 @@
"currentPassword": "Mevcut Şifre",
"newPassword": "Yeni Şifre",
"token": "Token",
- "lastAccessAt": "Son Erişim Tarihi"
+ "lastAccessAt": "Son Erişim Tarihi",
+ "libraries": "Kütüphaneler"
},
"helperTexts": {
- "name": "Adınızda yaptığımız değişikliğin geçerli olması için tekrar giriş yapmanız gerekmektedir"
+ "name": "Adınızda yaptığımız değişikliğin geçerli olması için tekrar giriş yapmanız gerekmektedir",
+ "libraries": "Bu kullanıcı için belirli kütüphaneleri seçin veya varsayılan kütüphaneleri kullanmak için boş bırakın"
},
"notifications": {
"created": "Kullanıcı oluşturuldu",
@@ -152,7 +163,12 @@
},
"message": {
"listenBrainzToken": "ListenBrainz kullanıcı Token'ınızı girin.",
- "clickHereForToken": "Token almak için buraya tıklayın"
+ "clickHereForToken": "Token almak için buraya tıklayın",
+ "selectAllLibraries": "Tüm kütüphaneleri seç",
+ "adminAutoLibraries": "Yönetici yetkili kullanıcılar tüm kütüphanelere otomatik olarak erişebilir"
+ },
+ "validation": {
+ "librariesRequired": "Yönetici olmayan kullanıcılar için en az bir kütüphane seçilmelidir"
}
},
"player": {
@@ -197,11 +213,16 @@
"export": "Aktar",
"makePublic": "Herkese Açık Yap",
"makePrivate": "Özel Yap",
- "saveQueue": ""
+ "saveQueue": "Kuyruktakileri Çalma Listesine Kaydet",
+ "searchOrCreate": "Çalma listelerini arayın veya yenisini oluşturmak için yazın...",
+ "pressEnterToCreate": "Yeni çalma listesi oluşturmak için Enter'a basın",
+ "removeFromSelection": "Seçimden kaldır"
},
"message": {
"duplicate_song": "Yinelenen şarkıları ekle",
- "song_exist": "Seçili müziklerin bazıları eklemek istediğin çalma listesinde mevcut. Yine de eklemek ister misin ?"
+ "song_exist": "Seçili müziklerin bazıları eklemek istediğin çalma listesinde mevcut. Yine de eklemek ister misin ?",
+ "noPlaylistsFound": "Hiç çalma listesi bulunamadı",
+ "noPlaylists": "Çalma listesi mevcut değil"
}
},
"radio": {
@@ -239,7 +260,8 @@
"fields": {
"path": "Yol",
"size": "Boyut",
- "updatedAt": "Kaybolma"
+ "updatedAt": "Kaybolma",
+ "libraryName": "Kütüphane"
},
"actions": {
"remove": "Kaldır",
@@ -249,6 +271,58 @@
"removed": "Eksik dosya(lar) kaldırıldı"
},
"empty": "Eksik Dosya Yok"
+ },
+ "library": {
+ "name": "Kütüphane |||| Kütüphaneler",
+ "fields": {
+ "name": "İsim",
+ "path": "Yol",
+ "remotePath": "Uzak Yol",
+ "lastScanAt": "Son Tarama",
+ "songCount": "Şarkılar",
+ "albumCount": "Albümler",
+ "artistCount": "Sanatçılar",
+ "totalSongs": "Şarkılar",
+ "totalAlbums": "Albümler",
+ "totalArtists": "Sanatçılar",
+ "totalFolders": "Klasörler",
+ "totalFiles": "Dosyalar",
+ "totalMissingFiles": "Eksik Dosyalar",
+ "totalSize": "Toplam Boyut",
+ "totalDuration": "Süre",
+ "defaultNewUsers": "Yeni Kullanıcılar için Varsayılan",
+ "createdAt": "Oluşturuldu",
+ "updatedAt": "Güncellendi"
+ },
+ "sections": {
+ "basic": "Temel Bilgiler",
+ "statistics": "İstatistikler"
+ },
+ "actions": {
+ "scan": "Kütüphaneyi Tara",
+ "manageUsers": "Kullanıcı Erişimini Yönet",
+ "viewDetails": "Ayrıntıları Görüntüle"
+ },
+ "notifications": {
+ "created": "Kütüphane başarıyla oluşturuldu",
+ "updated": "Kütüphane başarıyla güncellendi",
+ "deleted": "Kütüphane başarıyla silindi",
+ "scanStarted": "Kütüphane taraması başladı",
+ "scanCompleted": "Kütüphane taraması tamamlandı"
+ },
+ "validation": {
+ "nameRequired": "Kütüphane adı gereklidir",
+ "pathRequired": "Kütüphane yolu gereklidir",
+ "pathNotDirectory": "Kütüphane yolu bir dizin olmalıdır",
+ "pathNotFound": "Kütüphane yolu bulunamadı",
+ "pathNotAccessible": "Kütüphane yoluna erişim sağlanamıyor",
+ "pathInvalid": "Geçersiz kütüphane yolu"
+ },
+ "messages": {
+ "deleteConfirm": "Bu kütüphaneyi silmek istediğinizden emin misiniz? Bu işlem, ilgili tüm verileri ve kullanıcı erişimini kaldıracaktır.",
+ "scanInProgress": "Tarama devam ediyor...",
+ "noLibrariesAssigned": "Bu kullanıcıya hiçbir kütüphane atanmadı"
+ }
}
},
"ra": {
@@ -430,7 +504,9 @@
"remove_missing_title": "Eksik dosyaları kaldır",
"remove_missing_content": "Seçili eksik dosyaları veritabanından kaldırmak istediğinizden emin misiniz? Bu, oynatma sayıları ve derecelendirmeleri dahil olmak üzere bunlara ilişkin tüm referansları kalıcı olarak kaldıracaktır.",
"remove_all_missing_title": "Tüm eksik dosyaları kaldırın",
- "remove_all_missing_content": "Veritabanından tüm eksik dosyaları kaldırmak istediğinizden emin misiniz? Bu, oynatma sayısı ve derecelendirmelerde dahil olmak üzere bunlara ilişkili tüm değerleri kalıcı olarak kaldıracaktır."
+ "remove_all_missing_content": "Veritabanından tüm eksik dosyaları kaldırmak istediğinizden emin misiniz? Bu, oynatma sayısı ve derecelendirmelerde dahil olmak üzere bunlara ilişkili tüm değerleri kalıcı olarak kaldıracaktır.",
+ "noSimilarSongsFound": "Benzer şarkı bulunamadı",
+ "noTopSongsFound": "En iyi şarkı listesi boş"
},
"menu": {
"library": "Kütüphane",
@@ -459,7 +535,13 @@
"albumList": "Albümler",
"about": "Hakkında",
"playlists": "Çalma Listeleri",
- "sharedPlaylists": "Paylaşılan Çalma Listeleri"
+ "sharedPlaylists": "Paylaşılan Çalma Listeleri",
+ "librarySelector": {
+ "allLibraries": "Tüm Kitaplıklar (%{count})",
+ "multipleLibraries": "%{total} kütüphaneden %{selected} tanesi seçildi",
+ "selectLibraries": "Seçili Kütüphaneler",
+ "none": "Hiçbiri"
+ }
},
"player": {
"playListsText": "Oynatma Sırası",
@@ -496,6 +578,21 @@
"disabled": "Pasif",
"waiting": "Bekle"
}
+ },
+ "tabs": {
+ "about": "Hakkında",
+ "config": "Yapılandırma"
+ },
+ "config": {
+ "configName": "Yapılandırma Adı",
+ "environmentVariable": "Çevre Değişkeni",
+ "currentValue": "Güncel Değer",
+ "configurationFile": "Yapılandırma Dosyası",
+ "exportToml": "Yapılandırmayı Dışa Aktar (TOML)",
+ "exportSuccess": "Yapılandırma TOML formatında dışa aktarıldı",
+ "exportFailed": "Yapılandırma kopyalanamadı",
+ "devFlagsHeader": "Geliştirme Bayrakları (değişime/kaldırılmaya tabidir)",
+ "devFlagsComment": "Bunlar deneysel ayarlardır ve gelecekteki sürümlerde kaldırılabilir"
}
},
"activity": {
@@ -522,5 +619,10 @@
"toggle_love": "Bu şarkıyı favorilere ekle",
"current_song": "Mevcut Şarkıya Git"
}
+ },
+ "nowPlaying": {
+ "title": "Şu An Çalıyor",
+ "empty": "Çalan şarkı yok",
+ "minutesAgo": "%{smart_count} dakika önce"
}
}
\ No newline at end of file
diff --git a/resources/i18n/uk.json b/resources/i18n/uk.json
index a8be902c9..2c74c890a 100644
--- a/resources/i18n/uk.json
+++ b/resources/i18n/uk.json
@@ -35,7 +35,8 @@
"rawTags": "Вихідні теги",
"bitDepth": "Глибина розрядності",
"sampleRate": "Частота дискретизації",
- "missing": "Поле відсутнє"
+ "missing": "Поле відсутнє",
+ "libraryName": "Бібліотека"
},
"actions": {
"addToQueue": "Прослухати пізніше",
@@ -44,7 +45,8 @@
"shuffleAll": "Перемішати",
"download": "Завантажити",
"playNext": "Наступна",
- "info": "Отримати інформацію"
+ "info": "Отримати інформацію",
+ "showInPlaylist": "Показати у плейлісті"
}
},
"album": {
@@ -75,7 +77,8 @@
"media": "Медіа",
"mood": "Настрій",
"date": "Дата запису",
- "missing": "Поле відсутнє"
+ "missing": "Поле відсутнє",
+ "libraryName": "Бібліотека"
},
"actions": {
"playAll": "Прослухати",
@@ -123,7 +126,13 @@
"mixer": "Звукоінженер |||| Звукоінженери",
"remixer": "Реміксер |||| Реміксери",
"djmixer": "DJ-звукоінженер |||| DJ-звукоінженери",
- "performer": "Виконавець |||| Виконавці"
+ "performer": "Виконавець |||| Виконавці",
+ "maincredit": "Виконавець альбому або Виконавець |||| Виконавці альбому або Виконавці"
+ },
+ "actions": {
+ "shuffle": "Перетасовка",
+ "radio": "Радіо",
+ "topSongs": "ТОП-треки"
}
},
"user": {
@@ -140,10 +149,12 @@
"currentPassword": "Поточний пароль",
"newPassword": "Новий пароль",
"token": "Токен",
- "lastAccessAt": "Останній доступ"
+ "lastAccessAt": "Останній доступ",
+ "libraries": "Бібліотеки"
},
"helperTexts": {
- "name": "Змінене ім'я буде відображатися при наступній авторизації"
+ "name": "Змінене ім'я буде відображатися при наступній авторизації",
+ "libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням"
},
"notifications": {
"created": "Користувача створено",
@@ -152,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Введіть свій токен користувача ListenBrainz.",
- "clickHereForToken": "Натисніть тут для отримання токену"
+ "clickHereForToken": "Натисніть тут для отримання токену",
+ "selectAllLibraries": "Вибрати всі бібліотеки",
+ "adminAutoLibraries": "Користувачі-адміністратори автоматично отримують доступ до всіх бібліотек"
+ },
+ "validation": {
+ "librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека"
}
},
"player": {
@@ -197,11 +213,16 @@
"export": "Експортувати",
"makePublic": "Зробити публічним",
"makePrivate": "Зробити приватним",
- "saveQueue": "Зберегти чергу до плейлиста"
+ "saveQueue": "Зберегти чергу до плейлиста",
+ "searchOrCreate": "Знайти плейлист або введіть текст, щоб створити новий...",
+ "pressEnterToCreate": "Натисніть Enter щоб створити новий плейлист",
+ "removeFromSelection": "Вилучити з вибору"
},
"message": {
"duplicate_song": "Додати повторювані пісні",
- "song_exist": "У список відтворення додаються дублікати. Хочете додати дублікати або пропустити їх?"
+ "song_exist": "У список відтворення додаються дублікати. Хочете додати дублікати або пропустити їх?",
+ "noPlaylistsFound": "Не знайдено плейлистів",
+ "noPlaylists": "Немає доступних плейлистів"
}
},
"radio": {
@@ -239,7 +260,8 @@
"fields": {
"path": "Шлях файлу",
"size": "Розмір",
- "updatedAt": "Зник"
+ "updatedAt": "Зник",
+ "libraryName": "Бібліотека"
},
"actions": {
"remove": "Видалити",
@@ -249,6 +271,63 @@
"removed": "Видалено зниклі файл(и)"
},
"empty": "Немає відсутніх файлів"
+ },
+ "library": {
+ "name": "Бібліотека |||| Бібліотеки",
+ "fields": {
+ "name": "Ім'я",
+ "path": "Шлях",
+ "remotePath": "Віддалений шлях",
+ "lastScanAt": "Останнє сканування",
+ "songCount": "Треки",
+ "albumCount": "Альбоми",
+ "artistCount": "Виконавці",
+ "totalSongs": "Треки",
+ "totalAlbums": "Альбоми",
+ "totalArtists": "Виконавці",
+ "totalFolders": "Папки",
+ "totalFiles": "Файлів",
+ "totalMissingFiles": "Зниклих файлів",
+ "totalSize": "Загальний розмір",
+ "totalDuration": "Тривалість",
+ "defaultNewUsers": "За замовчуванням для нових користувачів",
+ "createdAt": "Створено",
+ "updatedAt": "Оновлено"
+ },
+ "sections": {
+ "basic": "Основна інформація",
+ "statistics": "Статистика"
+ },
+ "actions": {
+ "scan": "Сканувати бібліотеку",
+ "manageUsers": "Керування доступом користувачів",
+ "viewDetails": "Переглянути подробиці",
+ "quickScan": "Швидке сканування",
+ "fullScan": "Повне сканування"
+ },
+ "notifications": {
+ "created": "Бібліотеку успішно створено",
+ "updated": "Бібліотеку успішно оновлено",
+ "deleted": "Бібліотеку успішно видалено",
+ "scanStarted": "Сканування бібліотеки розпочато",
+ "scanCompleted": "Сканування бібліотеки закінчено",
+ "quickScanStarted": "Швидке сканування виконується",
+ "fullScanStarted": "Повне сканування виконується",
+ "scanError": "Помилка при виконанні сканування. Перевірте лоґи"
+ },
+ "validation": {
+ "nameRequired": "Ім'я бібліотеки обов'язкове",
+ "pathRequired": "Шлях до бібліотеки обов'язковий",
+ "pathNotDirectory": "Шлях до бібліотеки має бути директорією",
+ "pathNotFound": "Шлях до бібліотеки не знайдено",
+ "pathNotAccessible": "Шлях до бібліотеки недоступний",
+ "pathInvalid": "Помилковий шлях до бібліотеки"
+ },
+ "messages": {
+ "deleteConfirm": "Ви впевнені, що хочете видалити цю бібліотеку? Це призведе до видалення всіх пов'язаних з нею даних і доступу користувачів.",
+ "scanInProgress": "Сканування триває...",
+ "noLibrariesAssigned": "Немає бібліотек, призначених цьому користувачеві"
+ }
}
},
"ra": {
@@ -430,7 +509,9 @@
"remove_missing_title": "Видалити зниклі файли",
"remove_missing_content": "Ви впевнені, що хочете видалити вибрані відсутні файли з бази даних? Це назавжди видалить усі посилання на них, включаючи кількість прослуховувань та рейтинги.",
"remove_all_missing_title": "Видалити всі відсутні файли",
- "remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами."
+ "remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами.",
+ "noSimilarSongsFound": "Не знайдено схожих треків",
+ "noTopSongsFound": "Не знайдено ТОП-треків"
},
"menu": {
"library": "Бібліотека",
@@ -459,7 +540,13 @@
"albumList": "Альбом",
"about": "Довідка",
"playlists": "Списки відтворення",
- "sharedPlaylists": "Загальнодоступний список відтворення"
+ "sharedPlaylists": "Загальнодоступний список відтворення",
+ "librarySelector": {
+ "allLibraries": "Усі бібліотеки (%{count})",
+ "multipleLibraries": "%{selected} з %{total} Бібліотеки",
+ "selectLibraries": "Вибір бібліотек",
+ "none": "Відсутня"
+ }
},
"player": {
"playListsText": "Грати по черзі",
@@ -496,6 +583,21 @@
"disabled": "Вимкнено",
"waiting": "Очікування"
}
+ },
+ "tabs": {
+ "about": "Про",
+ "config": "Конфігурація"
+ },
+ "config": {
+ "configName": "Назва конфігурації",
+ "environmentVariable": "Змінна середовища",
+ "currentValue": "Поточне значення",
+ "configurationFile": "Файл конфігурації",
+ "exportToml": "Експортувати Конфігурацію (у форматі TOML)",
+ "exportSuccess": "Конфігурацію експортовано в буфер обміну у форматі TOML",
+ "exportFailed": "Не вдалося скопіювати конфігурацію",
+ "devFlagsHeader": "Прапорці розробки (можуть бути змінені/видалені)",
+ "devFlagsComment": "Це експериментальні налаштування, які можуть бути видалені в майбутніх версіях."
}
},
"activity": {
@@ -507,7 +609,8 @@
"serverDown": "Оффлайн",
"scanType": "Тип",
"status": "Помилка сканування",
- "elapsedTime": "Пройдений час"
+ "elapsedTime": "Пройдений час",
+ "selectiveScan": "Вибірковий"
},
"help": {
"title": "Гарячі клавіші Navidrome",
@@ -522,5 +625,10 @@
"toggle_love": "Відмітити поточні пісні",
"current_song": "Перейти до поточної пісні"
}
+ },
+ "nowPlaying": {
+ "title": "Зараз грає",
+ "empty": "Нічого не грає",
+ "minutesAgo": "%{smart_count} хвилин тому |||| %{smart_count} хвилин тому"
}
}
\ No newline at end of file
diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json
index c447f7d72..cde28c4f3 100644
--- a/resources/i18n/zh-Hans.json
+++ b/resources/i18n/zh-Hans.json
@@ -13,12 +13,14 @@
"album": "专辑",
"path": "文件路径",
"genre": "流派",
+ "libraryName": "媒体库",
"compilation": "合辑",
"year": "发行年份",
"size": "文件大小",
"updatedAt": "更新于",
"bitRate": "比特率",
"bitDepth": "比特深度",
+ "sampleRate": "采样率",
"channels": "声道",
"discSubtitle": "字幕",
"starred": "收藏",
@@ -33,12 +35,14 @@
"participants": "其他参与人员",
"tags": "附加标签",
"mappedTags": "映射标签",
- "rawTags": "原始标签"
+ "rawTags": "原始标签",
+ "missing": "缺失"
},
"actions": {
"addToQueue": "加入播放列表",
"playNow": "立即播放",
"addToPlaylist": "加入歌单",
+ "showInPlaylist": "定位到播放列表",
"shuffleAll": "全部随机播放",
"download": "下载",
"playNext": "下一首播放",
@@ -56,6 +60,7 @@
"size": "文件大小",
"name": "名称",
"genre": "流派",
+ "libraryName": "媒体库",
"compilation": "合辑",
"year": "发行年份",
"date": "录制日期",
@@ -72,7 +77,8 @@
"releaseType": "发行类型",
"grouping": "分组",
"media": "媒体类型",
- "mood": "情绪"
+ "mood": "情绪",
+ "missing": "缺失"
},
"actions": {
"playAll": "立即播放",
@@ -104,7 +110,8 @@
"playCount": "播放次数",
"rating": "评分",
"genre": "流派",
- "role": "参与角色"
+ "role": "参与角色",
+ "missing": "缺失"
},
"roles": {
"albumartist": "专辑歌手",
@@ -119,7 +126,13 @@
"mixer": "混音师",
"remixer": "重混师",
"djmixer": "DJ混音师",
- "performer": "演奏家"
+ "performer": "演奏家",
+ "maincredit": "主要艺术家"
+ },
+ "actions": {
+ "topSongs": "热门歌曲",
+ "shuffle": "随机播放",
+ "radio": "电台"
}
},
"user": {
@@ -136,19 +149,26 @@
"changePassword": "修改密码?",
"currentPassword": "当前密码",
"newPassword": "新密码",
- "token": "令牌"
+ "token": "令牌",
+ "libraries": "媒体库"
},
"helperTexts": {
- "name": "名称的更改将在下次登录时生效"
+ "name": "名称的更改将在下次登录时生效",
+ "libraries": "为该用户选择指定媒体库,留空则使用默认媒体库"
},
"notifications": {
"created": "用户已创建",
"updated": "用户已更新",
"deleted": "用户已删除"
},
+ "validation": {
+ "librariesRequired": "普通用户必须至少选择一个媒体库"
+ },
"message": {
"listenBrainzToken": "输入您的 ListenBrainz 用户令牌",
- "clickHereForToken": "点击这里来获得你的 ListenBrainz 令牌"
+ "clickHereForToken": "点击这里来获得你的 ListenBrainz 令牌",
+ "selectAllLibraries": "选择全部媒体库",
+ "adminAutoLibraries": "管理员默认可访问所有媒体库"
}
},
"player": {
@@ -191,12 +211,18 @@
"selectPlaylist": "选择歌单",
"addNewPlaylist": "新建 %{name}",
"export": "导出",
+ "saveQueue": "保存为歌单",
"makePublic": "设为公开",
- "makePrivate": "设为私有"
+ "makePrivate": "设为私有",
+ "searchOrCreate": "搜索歌单,或输入名称新建…",
+ "pressEnterToCreate": "按 Enter 键新建歌单",
+ "removeFromSelection": "移除选中项"
},
"message": {
"duplicate_song": "添加重复的歌曲",
- "song_exist": "部分选定的歌曲已存在歌单中,继续添加或是跳过它们?"
+ "song_exist": "部分选定的歌曲已存在歌单中,继续添加或是跳过它们?",
+ "noPlaylistsFound": "未找到歌单",
+ "noPlaylists": "暂无可用歌单"
}
},
"radio": {
@@ -237,14 +263,68 @@
"fields": {
"path": "路径",
"size": "文件大小",
+ "libraryName": "媒体库",
"updatedAt": "丢失于"
},
"actions": {
- "remove": "移除"
+ "remove": "移除",
+ "remove_all": "移除所有"
},
"notifications": {
"removed": "丢失文件已移除"
}
+ },
+ "library": {
+ "name": "媒体库",
+ "fields": {
+ "name": "名称",
+ "path": "路径",
+ "remotePath": "远程路径",
+ "lastScanAt": "上次扫描",
+ "songCount": "歌曲",
+ "albumCount": "专辑",
+ "artistCount": "艺术家",
+ "totalSongs": "歌曲",
+ "totalAlbums": "专辑",
+ "totalArtists": "艺术家",
+ "totalFolders": "目录",
+ "totalFiles": "文件",
+ "totalMissingFiles": "缺失的文件",
+ "totalSize": "总大小",
+ "totalDuration": "时长",
+ "defaultNewUsers": "新用户默认",
+ "createdAt": "创建于",
+ "updatedAt": "更新于"
+ },
+ "sections": {
+ "basic": "基本信息",
+ "statistics": "统计数据"
+ },
+ "actions": {
+ "scan": "扫描媒体库",
+ "manageUsers": "管理用户权限",
+ "viewDetails": "查看详情"
+ },
+ "notifications": {
+ "created": "媒体库已创建",
+ "updated": "媒体库已更新",
+ "deleted": "媒体库已删除",
+ "scanStarted": "开始扫描媒体库",
+ "scanCompleted": "媒体库扫描已完成"
+ },
+ "validation": {
+ "nameRequired": "媒体库名称不能为空!",
+ "pathRequired": "媒体库路径不能为空!",
+ "pathNotDirectory": "媒体库路径必须为目录!",
+ "pathNotFound": "媒体库路径不存在!",
+ "pathNotAccessible": "媒体库路径无法访问!",
+ "pathInvalid": "媒体库路径无效!"
+ },
+ "messages": {
+ "deleteConfirm": "您确定要删除此媒体库吗?此操作将删除所有关联数据及用户访问权限!",
+ "scanInProgress": "正在扫描...",
+ "noLibrariesAssigned": "该用户未分配任何媒体库!"
+ }
}
},
"ra": {
@@ -397,11 +477,15 @@
"transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。",
"transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过配置转码选项来执行任意命令,建议仅在配置转码选项时启用此功能。",
"songsAddedToPlaylist": "已添加 %{smart_count} 首歌到歌单",
+ "noSimilarSongsFound": "未找到相似歌曲",
+ "noTopSongsFound": "未找到热门歌曲",
"noPlaylistsAvailable": "没有有效的歌单",
"delete_user_title": "删除用户 %{name}",
"delete_user_content": "您确定要删除该用户及其相关数据(包括歌单和用户配置)吗?",
"remove_missing_title": "移除丢失文件",
"remove_missing_content": "您确定要将选中的丢失文件从数据库中永久移除吗?此操作将删除所有相关信息,包括播放次数和评分。",
+ "remove_all_missing_title": "删除所有丢失文件",
+ "remove_all_missing_content": "您确定要从数据库中删除所有丢失文件吗?这将永久删除对它们的所有引用,包括它们的播放次数和评分。",
"notifications_blocked": "您已在浏览器的设置中屏蔽了此网站的通知",
"notifications_not_available": "此浏览器不支持桌面通知",
"lastfmLinkSuccess": "Last.fm 已关联并启用喜好记录",
@@ -428,6 +512,12 @@
},
"menu": {
"library": "曲库",
+ "librarySelector": {
+ "allLibraries": "全部媒体库 (%{count})",
+ "multipleLibraries": "已选 %{selected} 共 %{total} 媒体库",
+ "selectLibraries": "选择媒体库",
+ "none": "无"
+ },
"settings": "设置",
"version": "版本",
"theme": "主题",
@@ -490,6 +580,21 @@
"disabled": "禁用",
"waiting": "等待"
}
+ },
+ "tabs": {
+ "about": "关于",
+ "config": "配置"
+ },
+ "config": {
+ "configName": "配置名称",
+ "environmentVariable": "环境变量",
+ "currentValue": "当前值",
+ "configurationFile": "配置文件",
+ "exportToml": "导出配置(TOML)",
+ "exportSuccess": "配置以 TOML 格式导出到剪贴板",
+ "exportFailed": "复制配置失败",
+ "devFlagsHeader": "开发标志(可能会更改/删除)",
+ "devFlagsComment": "这些是实验性设置,可能会在未来版本中删除"
}
},
"activity": {
@@ -498,7 +603,15 @@
"quickScan": "快速扫描",
"fullScan": "完全扫描",
"serverUptime": "服务器已运行",
- "serverDown": "服务器已离线"
+ "serverDown": "服务器已离线",
+ "scanType": "扫描类型",
+ "status": "扫描状态",
+ "elapsedTime": "用时"
+ },
+ "nowPlaying": {
+ "title": "正在播放",
+ "empty": "无播放内容",
+ "minutesAgo": "%{smart_count} 分钟前"
},
"help": {
"title": "Navidrome 快捷键",
@@ -514,4 +627,4 @@
"toggle_love": "添加/移除星标"
}
}
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json
index 3d6bbd268..7d8ce2872 100644
--- a/resources/i18n/zh-Hant.json
+++ b/resources/i18n/zh-Hant.json
@@ -1,463 +1,630 @@
{
- "languageName": "繁體中文",
- "resources": {
- "song": {
- "name": "歌曲 |||| 歌曲",
- "fields": {
- "albumArtist": "專輯藝人",
- "duration": "長度",
- "trackNumber": "#",
- "playCount": "播放次數",
- "title": "標題",
- "artist": "藝人",
- "album": "專輯",
- "path": "文件路徑",
- "genre": "類型",
- "compilation": "合輯",
- "year": "發行年份",
- "size": "檔案大小",
- "updatedAt": "更新於",
- "bitRate": "位元率",
- "discSubtitle": "字幕",
- "starred": "收藏",
- "comment": "註解",
- "rating": "評分",
- "quality": "品質",
- "bpm": "BPM",
- "playDate": "上次播放",
- "channels": "聲道",
- "createdAt": "創建於"
- },
- "actions": {
- "addToQueue": "加入至播放佇列",
- "playNow": "立即播放",
- "addToPlaylist": "加入至播放清單",
- "shuffleAll": "全部隨機播放",
- "download": "下載",
- "playNext": "下一首播放",
- "info": "取得資訊"
- }
- },
- "album": {
- "name": "專輯 |||| 專輯",
- "fields": {
- "albumArtist": "專輯藝人",
- "artist": "藝人",
- "duration": "長度",
- "songCount": "歌曲數量",
- "playCount": "播放次數",
- "name": "名稱",
- "genre": "類型",
- "compilation": "合輯",
- "year": "發行年份",
- "updatedAt": "更新於",
- "comment": "註解",
- "rating": "評分",
- "createdAt": "創建於",
- "size": "檔案大小",
- "originalDate": "原始日期",
- "releaseDate": "發行日期",
- "releases": "發行",
- "released": "已發行"
- },
- "actions": {
- "playAll": "立即播放",
- "playNext": "下首播放",
- "addToQueue": "加入至播放佇列",
- "shuffle": "隨機播放",
- "addToPlaylist": "加入播放清單",
- "download": "下載",
- "info": "取得資訊",
- "share": "分享"
- },
- "lists": {
- "all": "所有",
- "random": "隨機",
- "recentlyAdded": "最近加入",
- "recentlyPlayed": "最近播放",
- "mostPlayed": "最多播放的",
- "starred": "收藏",
- "topRated": "最高評分"
- }
- },
- "artist": {
- "name": "藝人 |||| 藝人",
- "fields": {
- "name": "名稱",
- "albumCount": "專輯數",
- "songCount": "歌曲數",
- "playCount": "播放次數",
- "rating": "評分",
- "genre": "類型",
- "size": "檔案大小"
- }
- },
- "user": {
- "name": "使用者 |||| 使用者",
- "fields": {
- "userName": "使用者名稱",
- "isAdmin": "是否管理員",
- "lastLoginAt": "上次登入",
- "lastAccessAt": "上此訪問",
- "updatedAt": "更新於",
- "name": "名稱",
- "password": "密碼",
- "createdAt": "創建於",
- "changePassword": "變更密碼?",
- "currentPassword": "現在的密碼",
- "newPassword": "新密碼",
- "token": "權杖"
- },
- "helperTexts": {
- "name": "你的名稱會在下次登入時生效"
- },
- "notifications": {
- "created": "使用者已創建",
- "updated": "使用者已更新",
- "deleted": "使用者已刪除"
- },
- "message": {
- "listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖",
- "clickHereForToken": "點擊此處來獲得你的 ListenBrainz 權杖"
- }
- },
- "player": {
- "name": "用戶端 |||| 用戶端",
- "fields": {
- "name": "名稱",
- "transcodingId": "轉碼",
- "maxBitRate": "最大位元率",
- "client": "用戶端",
- "userName": "使用者名稱",
- "lastSeen": "上次瀏覽",
- "reportRealPath": "回報實際路徑",
- "scrobbleEnabled": "傳送音樂記錄至外部服務"
- }
- },
- "transcoding": {
- "name": "轉碼 |||| 轉碼",
- "fields": {
- "name": "名稱",
- "targetFormat": "目標格式",
- "defaultBitRate": "預設位元率",
- "command": "命令"
- }
- },
- "playlist": {
- "name": "播放清單 |||| 播放清單",
- "fields": {
- "name": "名稱",
- "duration": "長度",
- "ownerName": "擁有者",
- "public": "公開",
- "updatedAt": "更新於",
- "createdAt": "創建於",
- "songCount": "歌曲數",
- "comment": "註解",
- "sync": "自動導入",
- "path": "導入"
- },
- "actions": {
- "selectPlaylist": "選擇播放清單",
- "addNewPlaylist": "創建 %{name}",
- "export": "導出",
- "makePublic": "設為公開",
- "makePrivate": "設為私人"
- },
- "message": {
- "duplicate_song": "加入重複的歌曲",
- "song_exist": "有重複歌曲正在播放清單裡,您要加入或略過重複歌曲?"
- }
- },
- "radio": {
- "name": "電台",
- "fields": {
- "name": "名稱",
- "streamUrl": "串流網址",
- "homePageUrl": "首頁網址",
- "updatedAt": "更新於",
- "createdAt": "創建於"
- },
- "actions": {
- "playNow": "立即播放"
- }
- },
- "share": {
- "name": "分享",
- "fields": {
- "username": "使用者名稱",
- "url": "網址",
- "description": "描述",
- "contents": "內容",
- "expiresAt": "過期時間",
- "lastVisitedAt": "上次訪問時間",
- "visitCount": "訪問次數",
- "format": "格式",
- "maxBitRate": "最大位元率",
- "updatedAt": "更新於",
- "createdAt": "創建於",
- "downloadable": "可下載"
- },
- "notifications": {},
- "actions": {}
- }
+ "languageName": "繁體中文",
+ "resources": {
+ "song": {
+ "name": "歌曲 |||| 歌曲",
+ "fields": {
+ "albumArtist": "專輯藝人",
+ "duration": "長度",
+ "trackNumber": "#",
+ "playCount": "播放次數",
+ "title": "標題",
+ "artist": "藝人",
+ "album": "專輯",
+ "path": "檔案路徑",
+ "libraryName": "媒體庫",
+ "genre": "曲風",
+ "compilation": "合輯",
+ "year": "發行年份",
+ "size": "檔案大小",
+ "updatedAt": "更新於",
+ "bitRate": "位元率",
+ "bitDepth": "位元深度",
+ "sampleRate": "取樣率",
+ "channels": "聲道",
+ "discSubtitle": "光碟副標題",
+ "starred": "收藏",
+ "comment": "註解",
+ "rating": "評分",
+ "quality": "品質",
+ "bpm": "BPM",
+ "playDate": "上次播放",
+ "createdAt": "建立於",
+ "grouping": "分組",
+ "mood": "情緒",
+ "participants": "其他參與人員",
+ "tags": "額外標籤",
+ "mappedTags": "分類後標籤",
+ "rawTags": "原始標籤",
+ "missing": "遺失"
+ },
+ "actions": {
+ "addToQueue": "加入至播放佇列",
+ "playNow": "立即播放",
+ "addToPlaylist": "加入至播放清單",
+ "showInPlaylist": "在播放清單中顯示",
+ "shuffleAll": "全部隨機播放",
+ "download": "下載",
+ "playNext": "下一首播放",
+ "info": "取得資訊"
+ }
},
- "ra": {
- "auth": {
- "welcome1": "感謝您安裝 Navidrome!",
- "welcome2": "開始前,請創建一個管理員帳戶",
- "confirmPassword": "確認密碼",
- "buttonCreateAdmin": "創建管理員",
- "auth_check_error": "請登入以訪問更多內容",
- "user_menu": "配置",
- "username": "使用者名稱",
- "password": "密碼",
- "sign_in": "登入",
- "sign_in_error": "驗證失敗,請重試",
- "logout": "登出"
- },
- "validation": {
- "invalidChars": "請使用字母和數字",
- "passwordDoesNotMatch": "密碼不相符",
- "required": "必填",
- "minLength": "必須不少於 %{min} 個字元",
- "maxLength": "必須不多於 %{max} 個字元",
- "minValue": "必須不小於 %{min}",
- "maxValue": "必須不大於 %{max}",
- "number": "必須為數字",
- "email": "必須是有效的電子郵件",
- "oneOf": "必須為: %{options}其中一項",
- "regex": "必須符合指定的格式(正規表達式):%{pattern}",
- "unique": "必須是唯一的",
- "url": "網址"
- },
- "action": {
- "add_filter": "加入篩選",
- "add": "加入",
- "back": "返回",
- "bulk_actions": "選中 %{smart_count} 項",
- "cancel": "取消",
- "clear_input_value": "清除",
- "clone": "複製",
- "confirm": "確認",
- "create": "創建",
- "delete": "刪除",
- "edit": "編輯",
- "export": "匯出",
- "list": "列表",
- "refresh": "重新整理",
- "remove_filter": "清除此條件",
- "remove": "清除",
- "save": "保存",
- "search": "搜尋",
- "show": "顯示",
- "sort": "排序",
- "undo": "撤銷",
- "expand": "展開",
- "close": "關閉",
- "open_menu": "打開選單",
- "close_menu": "關閉選單",
- "unselect": "未選擇",
- "skip": "略過",
- "bulk_actions_mobile": "%{smart_count}",
- "share": "分享",
- "download": "下載"
- },
- "boolean": {
- "true": "是",
- "false": "否"
- },
- "page": {
- "create": "創建 %{name}",
- "dashboard": "儀表板",
- "edit": "%{name} #%{id}",
- "error": "發生錯誤",
- "list": "%{name}",
- "loading": "載入中",
- "not_found": "未發現",
- "show": "%{name} #%{id}",
- "empty": "還沒有 %{name}。",
- "invite": "你要創建一個嗎?"
- },
- "input": {
- "file": {
- "upload_several": "拖拽多個文件上傳或點擊選擇一個",
- "upload_single": "拖拽單個文件上傳或點擊選擇一個"
- },
- "image": {
- "upload_several": "拖拽多個圖片上傳或點擊選擇一個",
- "upload_single": "拖拽單個圖片上傳或點擊選擇一個"
- },
- "references": {
- "all_missing": "未找到參考數據",
- "many_missing": "至少有一條參考數據不再可用",
- "single_missing": "關聯的參考數據不再可用"
- },
- "password": {
- "toggle_visible": "隱藏密碼",
- "toggle_hidden": "顯示密碼"
- }
- },
- "message": {
- "about": "關於",
- "are_you_sure": "確定進行此操作?",
- "bulk_delete_content": "您確定要刪除 %{name}? |||| 您確定要刪除 %{smart_count} 項?",
- "bulk_delete_title": "刪除 %{name} |||| 刪除 %{smart_count} 項 %{name}",
- "delete_content": "您確定要刪除該項目?",
- "delete_title": "刪除 %{name} #%{id}",
- "details": "詳細資訊",
- "error": "發生一個用戶端錯誤,您的請求無法完成",
- "invalid_form": "提交內容無效,請檢查錯誤",
- "loading": "正在載入頁面,請稍候",
- "no": "否",
- "not_found": "您輸入的連結格式不對或連結遺失",
- "yes": "是",
- "unsaved_changes": "某些更改尚未保存,您確定要離開此頁面嗎?"
- },
- "navigation": {
- "no_results": "無內容",
- "no_more_results": "頁碼 %{page} 超出邊界,嘗試返回上一頁",
- "page_out_of_boundaries": "頁碼 %{page} 超出邊界",
- "page_out_from_end": "已經最後一頁",
- "page_out_from_begin": "已經是第一頁",
- "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
- "page_rows_per_page": "每頁行數:",
- "next": "下一頁",
- "prev": "上一頁",
- "skip_nav": "跳過"
- },
- "notification": {
- "updated": "項已更新 |||| %{smart_count} 項已更新",
- "created": "項已創建",
- "deleted": "項已刪除 |||| %{smart_count} 項已刪除",
- "bad_item": "不確定的項",
- "item_doesnt_exist": "項不存在",
- "http_error": "伺服器通訊錯誤",
- "data_provider_error": "資料來源錯誤,請檢查控制台的詳細資訊",
- "i18n_error": "無法載入所選語言",
- "canceled": "操作已取消",
- "logged_out": "您的會話已結束,請重新登入",
- "new_version": "發現新版本!請重新整理視窗"
- },
- "toggleFieldsMenu": {
- "columnsToDisplay": "顯示欄目",
- "layout": "版面",
- "grid": "框格",
- "table": "表格"
- }
+ "album": {
+ "name": "專輯 |||| 專輯",
+ "fields": {
+ "albumArtist": "專輯藝人",
+ "artist": "藝人",
+ "duration": "長度",
+ "songCount": "歌曲數",
+ "playCount": "播放次數",
+ "size": "檔案大小",
+ "name": "名稱",
+ "libraryName": "媒體庫",
+ "genre": "曲風",
+ "compilation": "合輯",
+ "year": "發行年份",
+ "date": "錄製日期",
+ "originalDate": "原始日期",
+ "releaseDate": "發行日期",
+ "releases": "發行",
+ "released": "已發行",
+ "updatedAt": "更新於",
+ "comment": "註解",
+ "rating": "評分",
+ "createdAt": "建立於",
+ "recordLabel": "唱片公司",
+ "catalogNum": "目錄編號",
+ "releaseType": "發行類型",
+ "grouping": "分組",
+ "media": "媒體類型",
+ "mood": "情緒",
+ "missing": "遺失"
+ },
+ "actions": {
+ "playAll": "播放全部",
+ "playNext": "下一首播放",
+ "addToQueue": "加入至播放佇列",
+ "share": "分享",
+ "shuffle": "隨機播放",
+ "addToPlaylist": "加入至播放清單",
+ "download": "下載",
+ "info": "取得資訊"
+ },
+ "lists": {
+ "all": "所有",
+ "random": "隨機",
+ "recentlyAdded": "最近加入",
+ "recentlyPlayed": "最近播放",
+ "mostPlayed": "最常播放",
+ "starred": "收藏",
+ "topRated": "最高評分"
+ }
},
- "message": {
- "note": "註解",
- "transcodingDisabled": "出於安全原因,禁用了從 Web 介面更改參數。要更改(編輯或新增)轉檔選項,請在啟用 %{config} 選項的情況下重新啟動伺服器。",
- "transcodingEnabled": "Navidrome 當前與 %{config} 一起使用,可以通過配置轉檔參數執行任意命令,建議僅在配置轉檔選項時啟用此功能。",
- "songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已添加 %{smart_count} 首歌到播放清單",
- "noPlaylistsAvailable": "沒有可用的播放清單",
- "delete_user_title": "刪除使用者 %{name}",
- "delete_user_content": "您確定要刪除該使用者及其相關數據(包括播放清單和使用者配置)嗎?",
- "notifications_blocked": "您已在瀏覽器的設置中封鎖了此網站的通知",
- "notifications_not_available": "此瀏覽器不支援桌面通知",
- "lastfmLinkSuccess": "Last.fm 成功連接並開啟音樂記錄",
- "lastfmLinkFailure": "Last.fm 無法連接",
- "lastfmUnlinkSuccess": "Last.fm 已無連接並停用音樂記錄",
- "lastfmUnlinkFailure": "Last.fm 無法取消連接",
- "openIn": {
- "lastfm": "在 Last.fm 打開",
- "musicbrainz": "在 MusicBrainz 打開"
- },
- "lastfmLink": "繼續閱讀…",
- "listenBrainzLinkSuccess": "ListenBrainz 成功連接並開啟音樂記錄",
- "listenBrainzLinkFailure": "ListenBrainz 無法連接:%{error}",
- "listenBrainzUnlinkSuccess": "ListenBrainz 已無連接並停用音樂記錄",
- "listenBrainzUnlinkFailure": "ListenBrainz 無法取消連接",
- "downloadOriginalFormat": "下載原始格式",
- "shareOriginalFormat": "分享原始格式",
- "shareDialogTitle": "分享",
- "shareBatchDialogTitle": "批次分享",
- "shareSuccess": "分享成功",
- "shareFailure": "分享失敗",
- "downloadDialogTitle": "下載",
- "shareCopyToClipboard": "複製到剪貼簿"
+ "artist": {
+ "name": "藝人 |||| 藝人",
+ "fields": {
+ "name": "名稱",
+ "albumCount": "專輯數",
+ "songCount": "歌曲數",
+ "size": "檔案大小",
+ "playCount": "播放次數",
+ "rating": "評分",
+ "genre": "曲風",
+ "role": "參與角色",
+ "missing": "遺失"
+ },
+ "roles": {
+ "albumartist": "專輯藝人 |||| 專輯藝人",
+ "artist": "藝人 |||| 藝人",
+ "composer": "作曲 |||| 作曲",
+ "conductor": "指揮 |||| 指揮",
+ "lyricist": "作詞 |||| 作詞",
+ "arranger": "編曲 |||| 編曲",
+ "producer": "製作人 |||| 製作人",
+ "director": "導演 |||| 導演",
+ "engineer": "工程師 |||| 工程師",
+ "mixer": "混音師 |||| 混音師",
+ "remixer": "重混師 |||| 重混師",
+ "djmixer": "DJ 混音師 |||| DJ 混音師",
+ "performer": "表演者 |||| 表演者",
+ "maincredit": "專輯藝人或藝人 |||| 專輯藝人或藝人"
+ },
+ "actions": {
+ "topSongs": "熱門歌曲",
+ "shuffle": "隨機播放",
+ "radio": "電台"
+ }
},
- "menu": {
- "library": "音樂庫",
- "settings": "設定",
- "version": "版本",
- "theme": "主題",
- "personal": {
- "name": "個人化",
- "options": {
- "theme": "主題",
- "language": "語言",
- "defaultView": "預設畫面",
- "desktop_notifications": "桌面通知",
- "lastfmScrobbling": "啟用 Last.fm 音樂記錄",
- "listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄",
- "replaygain": "重播增益",
- "preAmp": "前置放大器 (dB)",
- "gain": {
- "none": "無",
- "album": "專輯增益",
- "track": "曲目增益"
- }
- }
- },
- "albumList": "專輯",
- "about": "關於",
- "playlists": "播放清單",
- "sharedPlaylists": "分享的播放清單"
+ "user": {
+ "name": "使用者 |||| 使用者",
+ "fields": {
+ "userName": "使用者名稱",
+ "isAdmin": "管理員",
+ "lastLoginAt": "上次登入",
+ "lastAccessAt": "上次存取",
+ "updatedAt": "更新於",
+ "name": "名稱",
+ "password": "密碼",
+ "createdAt": "建立於",
+ "changePassword": "變更密碼?",
+ "currentPassword": "目前密碼",
+ "newPassword": "新密碼",
+ "token": "權杖",
+ "libraries": "媒體庫"
+ },
+ "helperTexts": {
+ "name": "您的名稱會在下次登入時生效",
+ "libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫"
+ },
+ "notifications": {
+ "created": "使用者已建立",
+ "updated": "使用者已更新",
+ "deleted": "使用者已刪除"
+ },
+ "validation": {
+ "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
+ },
+ "message": {
+ "listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖",
+ "clickHereForToken": "點擊此處來獲得您的 ListenBrainz 權杖",
+ "selectAllLibraries": "選取全部媒體庫",
+ "adminAutoLibraries": "管理員預設可存取所有媒體庫"
+ }
},
"player": {
- "playListsText": "播放佇列",
- "openText": "打開",
- "closeText": "關閉",
- "notContentText": "沒有音樂",
- "clickToPlayText": "點擊播放",
- "clickToPauseText": "點擊暫停",
- "nextTrackText": "下一首",
- "previousTrackText": "上一首",
- "reloadText": "重新播放",
- "volumeText": "音量",
- "toggleLyricText": "切換歌詞",
- "toggleMiniModeText": "最小化",
- "destroyText": "關閉",
- "downloadText": "下載",
- "removeAudioListsText": "清空播放佇列",
- "clickToDeleteText": "點擊刪除 %{name}",
- "emptyLyricText": "無歌詞",
- "playModeText": {
- "order": "順序播放",
- "orderLoop": "列表循環",
- "singleLoop": "單曲循環",
- "shufflePlay": "隨機播放"
- }
+ "name": "播放器 |||| 播放器",
+ "fields": {
+ "name": "名稱",
+ "transcodingId": "轉碼",
+ "maxBitRate": "最大位元率",
+ "client": "客戶端",
+ "userName": "使用者名稱",
+ "lastSeen": "上次上線",
+ "reportRealPath": "回報實際路徑",
+ "scrobbleEnabled": "傳送音樂記錄至外部服務"
+ }
},
- "about": {
- "links": {
- "homepage": "主頁",
- "source": "原始碼",
- "featureRequests": "功能請求"
- }
+ "transcoding": {
+ "name": "轉碼 |||| 轉碼",
+ "fields": {
+ "name": "名稱",
+ "targetFormat": "目標格式",
+ "defaultBitRate": "預設位元率",
+ "command": "指令"
+ }
},
- "activity": {
- "title": "運作狀況",
- "totalScanned": "已完成掃描的目錄",
- "quickScan": "快速掃描",
- "fullScan": "完全掃描",
- "serverUptime": "伺服器已運作時間",
- "serverDown": "伺服器離線"
+ "playlist": {
+ "name": "播放清單 |||| 播放清單",
+ "fields": {
+ "name": "名稱",
+ "duration": "長度",
+ "ownerName": "擁有者",
+ "public": "公開",
+ "updatedAt": "更新於",
+ "createdAt": "建立於",
+ "songCount": "歌曲數",
+ "comment": "註解",
+ "sync": "自動匯入",
+ "path": "匯入來源"
+ },
+ "actions": {
+ "selectPlaylist": "選取播放清單:",
+ "addNewPlaylist": "建立「%{name}」",
+ "export": "匯出",
+ "saveQueue": "將播放佇列儲存到播放清單",
+ "makePublic": "設為公開",
+ "makePrivate": "設為私人",
+ "searchOrCreate": "搜尋播放清單,或輸入名稱來新建…",
+ "pressEnterToCreate": "按 Enter 鍵建立新的播放清單",
+ "removeFromSelection": "移除選取項目"
+ },
+ "message": {
+ "duplicate_song": "加入重複的歌曲",
+ "song_exist": "有重複歌曲正要加入播放清單,您要加入或略過重複歌曲?",
+ "noPlaylistsFound": "找不到播放清單",
+ "noPlaylists": "暫無播放清單"
+ }
},
- "help": {
- "title": "Navidrome 快捷鍵",
- "hotkeys": {
- "show_help": "顯示此幫助",
- "toggle_menu": "顯示/隱藏選單側欄",
- "toggle_play": "播放/暫停",
- "prev_song": "上一首歌",
- "next_song": "下一首歌",
- "vol_up": "提高音量",
- "vol_down": "降低音量",
- "toggle_love": "添加或移除星標",
- "current_song": "目前歌曲"
- }
+ "radio": {
+ "name": "電台 |||| 電台",
+ "fields": {
+ "name": "名稱",
+ "streamUrl": "串流網址",
+ "homePageUrl": "首頁網址",
+ "updatedAt": "更新於",
+ "createdAt": "建立於"
+ },
+ "actions": {
+ "playNow": "立即播放"
+ }
+ },
+ "share": {
+ "name": "分享 |||| 分享",
+ "fields": {
+ "username": "分享者",
+ "url": "網址",
+ "description": "描述",
+ "downloadable": "允許下載?",
+ "contents": "內容",
+ "expiresAt": "過期時間",
+ "lastVisitedAt": "上次造訪時間",
+ "visitCount": "造訪次數",
+ "format": "格式",
+ "maxBitRate": "最大位元率",
+ "updatedAt": "更新於",
+ "createdAt": "建立於"
+ },
+ "notifications": {},
+ "actions": {}
+ },
+ "missing": {
+ "name": "遺失檔案 |||| 遺失檔案",
+ "empty": "無遺失檔案",
+ "fields": {
+ "path": "路徑",
+ "size": "檔案大小",
+ "libraryName": "媒體庫",
+ "updatedAt": "遺失於"
+ },
+ "actions": {
+ "remove": "刪除",
+ "remove_all": "刪除所有"
+ },
+ "notifications": {
+ "removed": "遺失檔案已刪除"
+ }
+ },
+ "library": {
+ "name": "媒體庫 |||| 媒體庫",
+ "fields": {
+ "name": "名稱",
+ "path": "路徑",
+ "remotePath": "遠端路徑",
+ "lastScanAt": "上次掃描",
+ "songCount": "歌曲",
+ "albumCount": "專輯",
+ "artistCount": "藝人",
+ "totalSongs": "歌曲",
+ "totalAlbums": "專輯",
+ "totalArtists": "藝人",
+ "totalFolders": "資料夾",
+ "totalFiles": "檔案",
+ "totalMissingFiles": "遺失檔案",
+ "totalSize": "總大小",
+ "totalDuration": "時長",
+ "defaultNewUsers": "新使用者預設媒體庫",
+ "createdAt": "建立於",
+ "updatedAt": "更新於"
+ },
+ "sections": {
+ "basic": "基本資訊",
+ "statistics": "統計"
+ },
+ "actions": {
+ "scan": "掃描媒體庫",
+ "manageUsers": "管理使用者權限",
+ "viewDetails": "查看詳細資料"
+ },
+ "notifications": {
+ "created": "成功建立媒體庫",
+ "updated": "成功更新媒體庫",
+ "deleted": "成功刪除媒體庫",
+ "scanStarted": "開始掃描媒體庫",
+ "scanCompleted": "媒體庫掃描完成"
+ },
+ "validation": {
+ "nameRequired": "請輸入媒體庫名稱",
+ "pathRequired": "請提供媒體庫路徑",
+ "pathNotDirectory": "媒體庫路徑必須為目錄",
+ "pathNotFound": "媒體庫路徑不存在",
+ "pathNotAccessible": "無法存取媒體庫路徑",
+ "pathInvalid": "媒體庫路徑無效"
+ },
+ "messages": {
+ "deleteConfirm": "您確定要刪除此媒體庫嗎?這將刪除所有相關資料和使用者存取權限。",
+ "scanInProgress": "正在掃描...",
+ "noLibrariesAssigned": "沒有為該使用者指派任何媒體庫"
+ }
}
+ },
+ "ra": {
+ "auth": {
+ "welcome1": "感謝您安裝 Navidrome!",
+ "welcome2": "開始前,請先建立一個管理員帳號",
+ "confirmPassword": "確認密碼",
+ "buttonCreateAdmin": "建立管理員",
+ "auth_check_error": "請登入以繼續",
+ "user_menu": "個人檔案",
+ "username": "使用者名稱",
+ "password": "密碼",
+ "sign_in": "登入",
+ "sign_in_error": "驗證失敗,請重試",
+ "logout": "登出",
+ "insightsCollectionNote": "Navidrome 會收集匿名使用資料以協助改善項目。\n點擊[此處]了解更多資訊或選擇退出。"
+ },
+ "validation": {
+ "invalidChars": "請使用字母和數字",
+ "passwordDoesNotMatch": "密碼不相符",
+ "required": "必填",
+ "minLength": "必須不少於 %{min} 個字元",
+ "maxLength": "必須不多於 %{max} 個字元",
+ "minValue": "必須不小於 %{min}",
+ "maxValue": "必須不大於 %{max}",
+ "number": "必須為數字",
+ "email": "必須為有效的電子郵件",
+ "oneOf": "必須為以下其中一項:%{options}",
+ "regex": "必須符合指定的格式(正規表達式):%{pattern}",
+ "unique": "必須是唯一的",
+ "url": "必須為有效的網址"
+ },
+ "action": {
+ "add_filter": "加入篩選",
+ "add": "加入",
+ "back": "返回",
+ "bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
+ "cancel": "取消",
+ "clear_input_value": "清除",
+ "clone": "複製",
+ "confirm": "確認",
+ "create": "建立",
+ "delete": "刪除",
+ "edit": "編輯",
+ "export": "匯出",
+ "list": "列表",
+ "refresh": "重新整理",
+ "remove_filter": "清除此條件",
+ "remove": "移除",
+ "save": "儲存",
+ "search": "搜尋",
+ "show": "顯示",
+ "sort": "排序",
+ "undo": "復原",
+ "expand": "展開",
+ "close": "關閉",
+ "open_menu": "開啟選單",
+ "close_menu": "關閉選單",
+ "unselect": "取消選取",
+ "skip": "略過",
+ "share": "分享",
+ "download": "下載"
+ },
+ "boolean": {
+ "true": "是",
+ "false": "否"
+ },
+ "page": {
+ "create": "建立 %{name}",
+ "dashboard": "儀表板",
+ "edit": "%{name} #%{id}",
+ "error": "發生錯誤",
+ "list": "%{name}",
+ "loading": "載入中",
+ "not_found": "找不到",
+ "show": "%{name} #%{id}",
+ "empty": "還沒有 %{name}。",
+ "invite": "您要建立一個嗎?"
+ },
+ "input": {
+ "file": {
+ "upload_several": "拖曳多個檔案上傳或點擊選擇一個",
+ "upload_single": "拖曳單個檔案上傳或點擊選擇一個"
+ },
+ "image": {
+ "upload_several": "拖曳多個圖片上傳或點擊選擇一個",
+ "upload_single": "拖曳單個圖片上傳或點擊選擇一個"
+ },
+ "references": {
+ "all_missing": "未找到參考數據",
+ "many_missing": "至少有一條參考數據不再可用",
+ "single_missing": "關聯的參考數據不再可用"
+ },
+ "password": {
+ "toggle_visible": "隱藏密碼",
+ "toggle_hidden": "顯示密碼"
+ }
+ },
+ "message": {
+ "about": "關於",
+ "are_you_sure": "您確定嗎?",
+ "bulk_delete_content": "您確定要刪除 %{name}? |||| 您確定要刪除這 %{smart_count} 個項目嗎?",
+ "bulk_delete_title": "刪除 %{name} |||| 刪除 %{smart_count} 項 %{name}",
+ "delete_content": "您確定要刪除該項目?",
+ "delete_title": "刪除 %{name} #%{id}",
+ "details": "詳細資訊",
+ "error": "發生客戶端錯誤,您的請求無法完成",
+ "invalid_form": "提交內容無效,請檢查錯誤",
+ "loading": "正在載入頁面,請稍候",
+ "no": "否",
+ "not_found": "您輸入了錯誤的連結或連結遺失",
+ "yes": "是",
+ "unsaved_changes": "某些更改尚未儲存,您確定要離開此頁面嗎?"
+ },
+ "navigation": {
+ "no_results": "沒有找到結果",
+ "no_more_results": "頁碼 %{page} 超出邊界,嘗試返回上一頁",
+ "page_out_of_boundaries": "頁碼 %{page} 超出邊界",
+ "page_out_from_end": "已經是最後一頁",
+ "page_out_from_begin": "已經是第一頁",
+ "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
+ "page_rows_per_page": "每頁項目數:",
+ "next": "下一頁",
+ "prev": "上一頁",
+ "skip_nav": "跳至內容"
+ },
+ "notification": {
+ "updated": "項目已更新 |||| %{smart_count} 項已更新",
+ "created": "項目已建立",
+ "deleted": "項目已刪除 |||| %{smart_count} 項已刪除",
+ "bad_item": "項目不正確",
+ "item_doesnt_exist": "項目不存在",
+ "http_error": "伺服器通訊錯誤",
+ "data_provider_error": "資料來源錯誤,請檢查控制台的詳細資訊",
+ "i18n_error": "無法載入所選語言",
+ "canceled": "操作已取消",
+ "logged_out": "您的工作階段已結束,請重新登入",
+ "new_version": "發現新版本!請重新整理視窗"
+ },
+ "toggleFieldsMenu": {
+ "columnsToDisplay": "顯示欄位",
+ "layout": "版面",
+ "grid": "網格",
+ "table": "表格"
+ }
+ },
+ "message": {
+ "note": "注意",
+ "transcodingDisabled": "出於安全原因,已停用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 選項的情況下重新啟動伺服器。",
+ "transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。",
+ "songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單",
+ "noSimilarSongsFound": "找不到相似歌曲",
+ "noTopSongsFound": "找不到熱門歌曲",
+ "noPlaylistsAvailable": "沒有可用的播放清單",
+ "delete_user_title": "刪除使用者「%{name}」",
+ "delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?",
+ "remove_missing_title": "刪除遺失檔案",
+ "remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。",
+ "remove_all_missing_title": "刪除所有遺失檔案",
+ "remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。",
+ "notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知",
+ "notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome",
+ "lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄",
+ "lastfmLinkFailure": "無法連接 Last.fm",
+ "lastfmUnlinkSuccess": "已取消 Last.fm 的連接並停用音樂記錄",
+ "lastfmUnlinkFailure": "無法取消 Last.fm 的連接",
+ "listenBrainzLinkSuccess": "已成功以 %{user} 身份連接 ListenBrainz 並開啟音樂記錄",
+ "listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}",
+ "listenBrainzUnlinkSuccess": "已取消 ListenBrainz 的連接並停用音樂記錄",
+ "listenBrainzUnlinkFailure": "無法取消 ListenBrainz 的連接",
+ "openIn": {
+ "lastfm": "在 Last.fm 中開啟",
+ "musicbrainz": "在 MusicBrainz 中開啟"
+ },
+ "lastfmLink": "查看更多…",
+ "shareOriginalFormat": "分享原始格式",
+ "shareDialogTitle": "分享 %{resource} '%{name}'",
+ "shareBatchDialogTitle": "分享 1 個%{resource} |||| 分享 %{smart_count} 個%{resource}",
+ "shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter",
+ "shareSuccess": "分享成功,連結已複製到剪貼簿:%{url}",
+ "shareFailure": "分享連結複製失敗:%{url}",
+ "downloadDialogTitle": "下載 %{resource} '%{name}' (%{size})",
+ "downloadOriginalFormat": "下載原始格式"
+ },
+ "menu": {
+ "library": "媒體庫",
+ "librarySelector": {
+ "allLibraries": "所有媒體庫 (%{count})",
+ "multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
+ "selectLibraries": "選取媒體庫",
+ "none": "無"
+ },
+ "settings": "設定",
+ "version": "版本",
+ "theme": "主題",
+ "personal": {
+ "name": "個人化",
+ "options": {
+ "theme": "主題",
+ "language": "語言",
+ "defaultView": "預設畫面",
+ "desktop_notifications": "桌面通知",
+ "lastfmNotConfigured": "Last.fm API 金鑰未設定",
+ "lastfmScrobbling": "啟用 Last.fm 音樂記錄",
+ "listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄",
+ "replaygain": "重播增益模式",
+ "preAmp": "重播增益前置放大器 (dB)",
+ "gain": {
+ "none": "無",
+ "album": "專輯增益",
+ "track": "曲目增益"
+ }
+ }
+ },
+ "albumList": "專輯",
+ "playlists": "播放清單",
+ "sharedPlaylists": "分享的播放清單",
+ "about": "關於"
+ },
+ "player": {
+ "playListsText": "播放佇列",
+ "openText": "開啟",
+ "closeText": "關閉",
+ "notContentText": "沒有音樂",
+ "clickToPlayText": "點擊播放",
+ "clickToPauseText": "點擊暫停",
+ "nextTrackText": "下一首",
+ "previousTrackText": "上一首",
+ "reloadText": "重新載入",
+ "volumeText": "音量",
+ "toggleLyricText": "切換歌詞",
+ "toggleMiniModeText": "最小化",
+ "destroyText": "關閉",
+ "downloadText": "下載",
+ "removeAudioListsText": "清空播放佇列",
+ "clickToDeleteText": "點擊刪除 %{name}",
+ "emptyLyricText": "無歌詞",
+ "playModeText": {
+ "order": "順序播放",
+ "orderLoop": "循環播放",
+ "singleLoop": "單曲循環",
+ "shufflePlay": "隨機播放"
+ }
+ },
+ "about": {
+ "links": {
+ "homepage": "首頁",
+ "source": "原始碼",
+ "featureRequests": "功能請求",
+ "lastInsightsCollection": "最近一次洞察資料收集",
+ "insights": {
+ "disabled": "已停用",
+ "waiting": "等待中"
+ }
+ },
+ "tabs": {
+ "about": "關於",
+ "config": "設定"
+ },
+ "config": {
+ "configName": "設定名稱",
+ "environmentVariable": "環境變數",
+ "currentValue": "目前值",
+ "configurationFile": "設定檔案",
+ "exportToml": "匯出設定(TOML 格式)",
+ "exportSuccess": "設定已以 TOML 格式匯出至剪貼簿",
+ "exportFailed": "設定複製失敗",
+ "devFlagsHeader": "開發旗標(可能會更改/刪除)",
+ "devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除"
+ }
+ },
+ "activity": {
+ "title": "運作狀況",
+ "totalScanned": "已掃描的資料夾總數",
+ "quickScan": "快速掃描",
+ "fullScan": "完全掃描",
+ "serverUptime": "伺服器運作時間",
+ "serverDown": "伺服器已離線",
+ "scanType": "掃描類型",
+ "status": "掃描錯誤",
+ "elapsedTime": "經過時間"
+ },
+ "nowPlaying": {
+ "title": "正在播放",
+ "empty": "無播放內容",
+ "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
+ },
+ "help": {
+ "title": "Navidrome 快捷鍵",
+ "hotkeys": {
+ "show_help": "顯示此說明",
+ "toggle_menu": "顯示/隱藏選單側欄",
+ "toggle_play": "播放/暫停",
+ "prev_song": "上一首歌",
+ "next_song": "下一首歌",
+ "current_song": "前往目前歌曲",
+ "vol_up": "提高音量",
+ "vol_down": "降低音量",
+ "toggle_love": "新增此歌曲至收藏"
+ }
+ }
}
diff --git a/resources/mappings.yaml b/resources/mappings.yaml
index f461d889e..d1da5c620 100644
--- a/resources/mappings.yaml
+++ b/resources/mappings.yaml
@@ -108,7 +108,8 @@ main:
bpm:
aliases: [ tbpm, bpm, tmpo, wm/beatsperminute ]
lyrics:
- aliases: [ uslt:description, lyrics, ©lyr, wm/lyrics, unsyncedlyrics ]
+ # Note, @lyr and wm/lyrics have been removed. Taglib somehow appears to always populate `lyrics:xxx`
+ aliases: [ uslt:description, lyrics, unsyncedlyrics ]
maxLength: 32768
type: pair # ex: lyrics:eng, lyrics:xxx
comment:
diff --git a/scanner/controller.go b/scanner/controller.go
index f3fdd593f..b42246a50 100644
--- a/scanner/controller.go
+++ b/scanner/controller.go
@@ -26,24 +26,8 @@ var (
ErrAlreadyScanning = errors.New("already scanning")
)
-type Scanner interface {
- // ScanAll starts a full scan of the music library. This is a blocking operation.
- ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
- Status(context.Context) (*StatusInfo, error)
-}
-
-type StatusInfo struct {
- Scanning bool
- LastScan time.Time
- Count uint32
- FolderCount uint32
- LastError string
- ScanType string
- ElapsedTime time.Duration
-}
-
func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker,
- pls core.Playlists, m metrics.Metrics) Scanner {
+ pls core.Playlists, m metrics.Metrics) model.Scanner {
c := &controller{
rootCtx: rootCtx,
ds: ds,
@@ -65,9 +49,10 @@ func (s *controller) getScanner() scanner {
return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls}
}
-// CallScan starts an in-process scan of the music library.
+// CallScan starts an in-process scan of specific library/folder pairs.
+// If targets is empty, it scans all libraries.
// This is meant to be called from the command line (see cmd/scan.go).
-func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool) (<-chan *ProgressInfo, error) {
+func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) {
release, err := lockScan(ctx)
if err != nil {
return nil, err
@@ -79,7 +64,7 @@ func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullS
go func() {
defer close(progress)
scanner := &scannerImpl{ds: ds, cw: artwork.NoopCacheWarmer(), pls: pls}
- scanner.scanAll(ctx, fullScan, progress)
+ scanner.scanFolders(ctx, fullScan, targets, progress)
}()
return progress, nil
}
@@ -99,8 +84,11 @@ type ProgressInfo struct {
ForceUpdate bool
}
+// scanner defines the interface for different scanner implementations.
+// This allows for swapping between in-process and external scanners.
type scanner interface {
- scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo)
+ // scanFolders performs the actual scanning of folders. If targets is nil, it scans all libraries.
+ scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo)
}
type controller struct {
@@ -116,6 +104,24 @@ type controller struct {
changesDetected bool
}
+// getLastScanTime returns the most recent scan time across all libraries
+func (s *controller) getLastScanTime(ctx context.Context) (time.Time, error) {
+ libs, err := s.ds.Library(ctx).GetAll(model.QueryOptions{
+ Sort: "last_scan_at",
+ Order: "desc",
+ Max: 1,
+ })
+ if err != nil {
+ return time.Time{}, fmt.Errorf("getting libraries: %w", err)
+ }
+
+ if len(libs) == 0 {
+ return time.Time{}, nil
+ }
+
+ return libs[0].LastScanAt, nil
+}
+
// getScanInfo retrieves scan status from the database
func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed time.Duration, lastErr string) {
lastErr, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "")
@@ -128,10 +134,10 @@ func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed
if running.Load() {
elapsed = time.Since(startTime)
} else {
- // If scan is not running, try to get the last scan time for the library
- lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
- if err == nil {
- elapsed = lib.LastScanAt.Sub(startTime)
+ // If scan is not running, calculate elapsed time using the most recent scan time
+ lastScanTime, err := s.getLastScanTime(ctx)
+ if err == nil && !lastScanTime.IsZero() {
+ elapsed = lastScanTime.Sub(startTime)
}
}
}
@@ -140,18 +146,18 @@ func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed
return scanType, elapsed, lastErr
}
-func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
- lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
+func (s *controller) Status(ctx context.Context) (*model.ScannerStatus, error) {
+ lastScanTime, err := s.getLastScanTime(ctx)
if err != nil {
- return nil, fmt.Errorf("getting library: %w", err)
+ return nil, fmt.Errorf("getting last scan time: %w", err)
}
scanType, elapsed, lastErr := s.getScanInfo(ctx)
if running.Load() {
- status := &StatusInfo{
+ status := &model.ScannerStatus{
Scanning: true,
- LastScan: lib.LastScanAt,
+ LastScan: lastScanTime,
Count: s.count.Load(),
FolderCount: s.folderCount.Load(),
LastError: lastErr,
@@ -165,9 +171,9 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
if err != nil {
return nil, fmt.Errorf("getting library stats: %w", err)
}
- return &StatusInfo{
+ return &model.ScannerStatus{
Scanning: false,
- LastScan: lib.LastScanAt,
+ LastScan: lastScanTime,
Count: uint32(count),
FolderCount: uint32(folderCount),
LastError: lastErr,
@@ -190,6 +196,10 @@ func (s *controller) getCounters(ctx context.Context) (int64, int64, error) {
}
func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]string, error) {
+ return s.ScanFolders(requestCtx, fullScan, nil)
+}
+
+func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targets []model.ScanTarget) ([]string, error) {
release, err := lockScan(requestCtx)
if err != nil {
return nil, err
@@ -198,7 +208,6 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
// Prepare the context for the scan
ctx := request.AddValues(s.rootCtx, requestCtx)
- ctx = events.BroadcastToAll(ctx)
ctx = auth.WithAdminUser(ctx, s.ds)
// Send the initial scan status event
@@ -207,7 +216,7 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
go func() {
defer close(progress)
scanner := s.getScanner()
- scanner.scanAll(ctx, fullScan, progress)
+ scanner.scanFolders(ctx, fullScan, targets, progress)
}()
// Wait for the scan to finish, sending progress events to all connected clients
@@ -218,7 +227,7 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
// If changes were detected, send a refresh event to all clients
if s.changesDetected {
log.Debug(ctx, "Library changes imported. Sending refresh event")
- s.broker.SendMessage(ctx, &events.RefreshResource{})
+ s.broker.SendBroadcastMessage(ctx, &events.RefreshResource{})
}
// Send the final scan status event, with totals
if count, folderCount, err := s.getCounters(ctx); err != nil {
@@ -297,5 +306,5 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres
}
func (s *controller) sendMessage(ctx context.Context, status *events.ScanStatus) {
- s.broker.SendMessage(ctx, status)
+ s.broker.SendBroadcastMessage(ctx, status)
}
diff --git a/scanner/controller_test.go b/scanner/controller_test.go
index 4f6576a39..f5ccabc86 100644
--- a/scanner/controller_test.go
+++ b/scanner/controller_test.go
@@ -21,7 +21,7 @@ import (
var _ = Describe("Controller", func() {
var ctx context.Context
var ds *tests.MockDataStore
- var ctrl scanner.Scanner
+ var ctrl model.Scanner
Describe("Status", func() {
BeforeEach(func() {
@@ -32,7 +32,6 @@ var _ = Describe("Controller", func() {
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
ds.MockedProperty = &tests.MockedPropertyRepo{}
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance())
- Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Name: "lib", Path: "/tmp"})).To(Succeed())
})
It("includes last scan error", func() {
diff --git a/scanner/external.go b/scanner/external.go
index c4a29efa3..f5a117e48 100644
--- a/scanner/external.go
+++ b/scanner/external.go
@@ -11,7 +11,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
- . "github.com/navidrome/navidrome/utils/gg"
+ "github.com/navidrome/navidrome/model"
)
// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid
@@ -23,19 +23,42 @@ import (
// process will forward them to the caller.
type scannerExternal struct{}
-func (s *scannerExternal) scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo) {
+func (s *scannerExternal) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
+ s.scan(ctx, fullScan, targets, progress)
+}
+
+func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
exe, err := os.Executable()
if err != nil {
progress <- &ProgressInfo{Error: fmt.Sprintf("failed to get executable path: %s", err)}
return
}
- log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe)
- cmd := exec.CommandContext(ctx, exe, "scan",
+
+ // Build command arguments
+ args := []string{
+ "scan",
"--nobanner", "--subprocess",
"--configfile", conf.Server.ConfigFile,
"--datafolder", conf.Server.DataFolder,
"--cachefolder", conf.Server.CacheFolder,
- If(fullScan, "--full", ""))
+ }
+
+ // Add targets if provided
+ if len(targets) > 0 {
+ for _, target := range targets {
+ args = append(args, "-t", target.String())
+ }
+ log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targets)
+ } else {
+ log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe)
+ }
+
+ // Add full scan flag if needed
+ if fullScan {
+ args = append(args, "--full")
+ }
+
+ cmd := exec.CommandContext(ctx, exe, args...)
in, out := io.Pipe()
defer in.Close()
diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go
index fc68cb561..9d8d0c571 100644
--- a/scanner/folder_entry.go
+++ b/scanner/folder_entry.go
@@ -15,9 +15,7 @@ import (
"github.com/navidrome/navidrome/utils/chrono"
)
-func newFolderEntry(job *scanJob, path string) *folderEntry {
- id := model.FolderID(job.lib, path)
- info := job.popLastUpdate(id)
+func newFolderEntry(job *scanJob, id, path string, updTime time.Time, hash string) *folderEntry {
f := &folderEntry{
id: id,
job: job,
@@ -25,8 +23,8 @@ func newFolderEntry(job *scanJob, path string) *folderEntry {
audioFiles: make(map[string]fs.DirEntry),
imageFiles: make(map[string]fs.DirEntry),
albumIDMap: make(map[string]string),
- updTime: info.UpdatedAt,
- prevHash: info.Hash,
+ updTime: updTime,
+ prevHash: hash,
}
return f
}
diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go
index c6d1b2ce4..0328c6653 100644
--- a/scanner/folder_entry_test.go
+++ b/scanner/folder_entry_test.go
@@ -40,9 +40,8 @@ var _ = Describe("folder_entry", func() {
UpdatedAt: time.Now().Add(-30 * time.Minute),
Hash: "previous-hash",
}
- job.lastUpdates[folderID] = updateInfo
- entry := newFolderEntry(job, path)
+ entry := newFolderEntry(job, folderID, path, updateInfo.UpdatedAt, updateInfo.Hash)
Expect(entry.id).To(Equal(folderID))
Expect(entry.job).To(Equal(job))
@@ -53,15 +52,10 @@ var _ = Describe("folder_entry", func() {
Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt))
Expect(entry.prevHash).To(Equal(updateInfo.Hash))
})
+ })
- It("creates a new folder entry with zero time when no previous update exists", func() {
- entry := newFolderEntry(job, path)
-
- Expect(entry.updTime).To(BeZero())
- Expect(entry.prevHash).To(BeEmpty())
- })
-
- It("removes the lastUpdate from the job after popping", func() {
+ Describe("createFolderEntry", func() {
+ It("removes the lastUpdate from the job after creation", func() {
folderID := model.FolderID(lib, path)
updateInfo := model.FolderUpdateInfo{
UpdatedAt: time.Now().Add(-30 * time.Minute),
@@ -69,8 +63,10 @@ var _ = Describe("folder_entry", func() {
}
job.lastUpdates[folderID] = updateInfo
- newFolderEntry(job, path)
+ entry := job.createFolderEntry(path)
+ Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt))
+ Expect(entry.prevHash).To(Equal(updateInfo.Hash))
Expect(job.lastUpdates).ToNot(HaveKey(folderID))
})
})
@@ -79,7 +75,8 @@ var _ = Describe("folder_entry", func() {
var entry *folderEntry
BeforeEach(func() {
- entry = newFolderEntry(job, path)
+ folderID := model.FolderID(lib, path)
+ entry = newFolderEntry(job, folderID, path, time.Time{}, "")
})
Describe("hasNoFiles", func() {
@@ -458,7 +455,9 @@ var _ = Describe("folder_entry", func() {
Describe("integration scenarios", func() {
It("handles complete folder lifecycle", func() {
// Create new folder entry
- entry := newFolderEntry(job, "music/rock/album")
+ folderPath := "music/rock/album"
+ folderID := model.FolderID(lib, folderPath)
+ entry := newFolderEntry(job, folderID, folderPath, time.Time{}, "")
// Initially new and has no files
Expect(entry.isNew()).To(BeTrue())
diff --git a/scanner/ignore_checker.go b/scanner/ignore_checker.go
new file mode 100644
index 000000000..da74293fa
--- /dev/null
+++ b/scanner/ignore_checker.go
@@ -0,0 +1,163 @@
+package scanner
+
+import (
+ "bufio"
+ "context"
+ "io/fs"
+ "path"
+ "strings"
+
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/log"
+ ignore "github.com/sabhiram/go-gitignore"
+)
+
+// IgnoreChecker manages .ndignore patterns using a stack-based approach.
+// Use Push() to add patterns when entering a folder, Pop() when leaving,
+// and ShouldIgnore() to check if a path should be ignored.
+type IgnoreChecker struct {
+ fsys fs.FS
+ patternStack [][]string // Stack of patterns for each folder level
+ currentPatterns []string // Flattened current patterns
+ matcher *ignore.GitIgnore // Compiled matcher for current patterns
+}
+
+// newIgnoreChecker creates a new IgnoreChecker for the given filesystem.
+func newIgnoreChecker(fsys fs.FS) *IgnoreChecker {
+ return &IgnoreChecker{
+ fsys: fsys,
+ patternStack: make([][]string, 0),
+ }
+}
+
+// Push loads .ndignore patterns from the specified folder and adds them to the pattern stack.
+// Use this when entering a folder during directory tree traversal.
+func (ic *IgnoreChecker) Push(ctx context.Context, folder string) error {
+ patterns := ic.loadPatternsFromFolder(ctx, folder)
+ ic.patternStack = append(ic.patternStack, patterns)
+ ic.rebuildCurrentPatterns()
+ return nil
+}
+
+// Pop removes the most recent patterns from the stack.
+// Use this when leaving a folder during directory tree traversal.
+func (ic *IgnoreChecker) Pop() {
+ if len(ic.patternStack) > 0 {
+ ic.patternStack = ic.patternStack[:len(ic.patternStack)-1]
+ ic.rebuildCurrentPatterns()
+ }
+}
+
+// PushAllParents pushes patterns from root down to the target path.
+// This is a convenience method for when you need to check a specific path
+// without recursively walking the tree. It handles the common pattern of
+// pushing all parent directories from root to the target.
+// This method is optimized to compile patterns only once at the end.
+func (ic *IgnoreChecker) PushAllParents(ctx context.Context, targetPath string) error {
+ if targetPath == "." || targetPath == "" {
+ // Simple case: just push root
+ return ic.Push(ctx, ".")
+ }
+
+ // Load patterns for root
+ patterns := ic.loadPatternsFromFolder(ctx, ".")
+ ic.patternStack = append(ic.patternStack, patterns)
+
+ // Load patterns for each parent directory
+ currentPath := "."
+ parts := strings.Split(path.Clean(targetPath), "/")
+ for _, part := range parts {
+ if part == "." || part == "" {
+ continue
+ }
+ currentPath = path.Join(currentPath, part)
+ patterns = ic.loadPatternsFromFolder(ctx, currentPath)
+ ic.patternStack = append(ic.patternStack, patterns)
+ }
+
+ // Rebuild and compile patterns only once at the end
+ ic.rebuildCurrentPatterns()
+ return nil
+}
+
+// ShouldIgnore checks if the given path should be ignored based on the current patterns.
+// Returns true if the path matches any ignore pattern, false otherwise.
+func (ic *IgnoreChecker) ShouldIgnore(ctx context.Context, relPath string) bool {
+ // Handle root/empty path - never ignore
+ if relPath == "" || relPath == "." {
+ return false
+ }
+
+ // If no patterns loaded, nothing to ignore
+ if ic.matcher == nil {
+ return false
+ }
+
+ matches := ic.matcher.MatchesPath(relPath)
+ if matches {
+ log.Trace(ctx, "Scanner: Ignoring entry matching .ndignore", "path", relPath)
+ }
+ return matches
+}
+
+// loadPatternsFromFolder reads the .ndignore file in the specified folder and returns the patterns.
+// If the file doesn't exist, returns an empty slice.
+// If the file exists but is empty, returns a pattern to ignore everything ("**/*").
+func (ic *IgnoreChecker) loadPatternsFromFolder(ctx context.Context, folder string) []string {
+ ignoreFilePath := path.Join(folder, consts.ScanIgnoreFile)
+ var patterns []string
+
+ // Check if .ndignore file exists
+ if _, err := fs.Stat(ic.fsys, ignoreFilePath); err != nil {
+ // No .ndignore file in this folder
+ return patterns
+ }
+
+ // Read and parse the .ndignore file
+ ignoreFile, err := ic.fsys.Open(ignoreFilePath)
+ if err != nil {
+ log.Warn(ctx, "Scanner: Error opening .ndignore file", "path", ignoreFilePath, err)
+ return patterns
+ }
+ defer ignoreFile.Close()
+
+ lineScanner := bufio.NewScanner(ignoreFile)
+ for lineScanner.Scan() {
+ line := strings.TrimSpace(lineScanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue // Skip empty lines, whitespace-only lines, and comments
+ }
+ patterns = append(patterns, line)
+ }
+
+ if err := lineScanner.Err(); err != nil {
+ log.Warn(ctx, "Scanner: Error reading .ndignore file", "path", ignoreFilePath, err)
+ return patterns
+ }
+
+ // If the .ndignore file is empty, ignore everything
+ if len(patterns) == 0 {
+ log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", folder)
+ patterns = []string{"**/*"}
+ }
+
+ return patterns
+}
+
+// rebuildCurrentPatterns flattens the pattern stack into currentPatterns and recompiles the matcher.
+func (ic *IgnoreChecker) rebuildCurrentPatterns() {
+ ic.currentPatterns = make([]string, 0)
+ for _, patterns := range ic.patternStack {
+ ic.currentPatterns = append(ic.currentPatterns, patterns...)
+ }
+ ic.compilePatterns()
+}
+
+// compilePatterns compiles the current patterns into a GitIgnore matcher.
+func (ic *IgnoreChecker) compilePatterns() {
+ if len(ic.currentPatterns) == 0 {
+ ic.matcher = nil
+ return
+ }
+ ic.matcher = ignore.CompileIgnoreLines(ic.currentPatterns...)
+}
diff --git a/scanner/ignore_checker_test.go b/scanner/ignore_checker_test.go
new file mode 100644
index 000000000..5378ed4fa
--- /dev/null
+++ b/scanner/ignore_checker_test.go
@@ -0,0 +1,313 @@
+package scanner
+
+import (
+ "context"
+ "testing/fstest"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("IgnoreChecker", func() {
+ Describe("loadPatternsFromFolder", func() {
+ var ic *IgnoreChecker
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ })
+
+ Context("when .ndignore file does not exist", func() {
+ It("should return empty patterns", func() {
+ fsys := fstest.MapFS{}
+ ic = newIgnoreChecker(fsys)
+ patterns := ic.loadPatternsFromFolder(ctx, ".")
+ Expect(patterns).To(BeEmpty())
+ })
+ })
+
+ Context("when .ndignore file is empty", func() {
+ It("should return wildcard to ignore everything", func() {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("")},
+ }
+ ic = newIgnoreChecker(fsys)
+ patterns := ic.loadPatternsFromFolder(ctx, ".")
+ Expect(patterns).To(Equal([]string{"**/*"}))
+ })
+ })
+
+ DescribeTable("parsing .ndignore content",
+ func(content string, expectedPatterns []string) {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte(content)},
+ }
+ ic = newIgnoreChecker(fsys)
+ patterns := ic.loadPatternsFromFolder(ctx, ".")
+ Expect(patterns).To(Equal(expectedPatterns))
+ },
+ Entry("single pattern", "*.txt", []string{"*.txt"}),
+ Entry("multiple patterns", "*.txt\n*.log", []string{"*.txt", "*.log"}),
+ Entry("with comments", "# comment\n*.txt\n# another\n*.log", []string{"*.txt", "*.log"}),
+ Entry("with empty lines", "*.txt\n\n*.log\n\n", []string{"*.txt", "*.log"}),
+ Entry("mixed content", "# header\n\n*.txt\n# middle\n*.log\n\n", []string{"*.txt", "*.log"}),
+ Entry("only comments and empty lines", "# comment\n\n# another\n", []string{"**/*"}),
+ Entry("trailing newline", "*.txt\n*.log\n", []string{"*.txt", "*.log"}),
+ Entry("directory pattern", "temp/", []string{"temp/"}),
+ Entry("wildcard pattern", "**/*.mp3", []string{"**/*.mp3"}),
+ Entry("multiple wildcards", "**/*.mp3\n**/*.flac\n*.log", []string{"**/*.mp3", "**/*.flac", "*.log"}),
+ Entry("negation pattern", "!important.txt", []string{"!important.txt"}),
+ Entry("comment with hash not at start is pattern", "not#comment", []string{"not#comment"}),
+ Entry("whitespace-only lines skipped", "*.txt\n \n*.log\n\t\n", []string{"*.txt", "*.log"}),
+ Entry("patterns with whitespace trimmed", " *.txt \n\t*.log\t", []string{"*.txt", "*.log"}),
+ )
+ })
+
+ Describe("Push and Pop", func() {
+ var ic *IgnoreChecker
+ var fsys fstest.MapFS
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ fsys = fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("*.txt")},
+ "folder1/.ndignore": &fstest.MapFile{Data: []byte("*.mp3")},
+ "folder2/.ndignore": &fstest.MapFile{Data: []byte("*.flac")},
+ }
+ ic = newIgnoreChecker(fsys)
+ })
+
+ Context("Push", func() {
+ It("should add patterns to stack", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(1))
+ Expect(ic.currentPatterns).To(ContainElement("*.txt"))
+ })
+
+ It("should compile matcher after push", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.matcher).ToNot(BeNil())
+ })
+
+ It("should accumulate patterns from multiple levels", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ err = ic.Push(ctx, "folder1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(2))
+ Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.mp3"))
+ })
+
+ It("should handle push when no .ndignore exists", func() {
+ err := ic.Push(ctx, "nonexistent")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(1))
+ Expect(ic.currentPatterns).To(BeEmpty())
+ })
+ })
+
+ Context("Pop", func() {
+ It("should remove most recent patterns", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ err = ic.Push(ctx, "folder1")
+ Expect(err).ToNot(HaveOccurred())
+ ic.Pop()
+ Expect(len(ic.patternStack)).To(Equal(1))
+ Expect(ic.currentPatterns).To(Equal([]string{"*.txt"}))
+ })
+
+ It("should handle Pop on empty stack gracefully", func() {
+ Expect(func() { ic.Pop() }).ToNot(Panic())
+ Expect(ic.patternStack).To(BeEmpty())
+ })
+
+ It("should set matcher to nil when all patterns popped", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.matcher).ToNot(BeNil())
+ ic.Pop()
+ Expect(ic.matcher).To(BeNil())
+ })
+
+ It("should update matcher after pop", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ err = ic.Push(ctx, "folder1")
+ Expect(err).ToNot(HaveOccurred())
+ matcher1 := ic.matcher
+ ic.Pop()
+ matcher2 := ic.matcher
+ Expect(matcher1).ToNot(Equal(matcher2))
+ })
+ })
+
+ Context("multiple Push/Pop cycles", func() {
+ It("should maintain correct state through cycles", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.currentPatterns).To(Equal([]string{"*.txt"}))
+
+ err = ic.Push(ctx, "folder1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.mp3"))
+
+ ic.Pop()
+ Expect(ic.currentPatterns).To(Equal([]string{"*.txt"}))
+
+ err = ic.Push(ctx, "folder2")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.flac"))
+
+ ic.Pop()
+ Expect(ic.currentPatterns).To(Equal([]string{"*.txt"}))
+
+ ic.Pop()
+ Expect(ic.currentPatterns).To(BeEmpty())
+ })
+ })
+ })
+
+ Describe("PushAllParents", func() {
+ var ic *IgnoreChecker
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("root.txt")},
+ "folder1/.ndignore": &fstest.MapFile{Data: []byte("level1.txt")},
+ "folder1/folder2/.ndignore": &fstest.MapFile{Data: []byte("level2.txt")},
+ "folder1/folder2/folder3/.ndignore": &fstest.MapFile{Data: []byte("level3.txt")},
+ }
+ ic = newIgnoreChecker(fsys)
+ })
+
+ DescribeTable("loading parent patterns",
+ func(targetPath string, expectedStackDepth int, expectedPatterns []string) {
+ err := ic.PushAllParents(ctx, targetPath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(expectedStackDepth))
+ Expect(ic.currentPatterns).To(ConsistOf(expectedPatterns))
+ },
+ Entry("root path", ".", 1, []string{"root.txt"}),
+ Entry("empty path", "", 1, []string{"root.txt"}),
+ Entry("single level", "folder1", 2, []string{"root.txt", "level1.txt"}),
+ Entry("two levels", "folder1/folder2", 3, []string{"root.txt", "level1.txt", "level2.txt"}),
+ Entry("three levels", "folder1/folder2/folder3", 4, []string{"root.txt", "level1.txt", "level2.txt", "level3.txt"}),
+ )
+
+ It("should only compile patterns once at the end", func() {
+ // This is more of a behavioral test - we verify the matcher is not nil after PushAllParents
+ err := ic.PushAllParents(ctx, "folder1/folder2")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.matcher).ToNot(BeNil())
+ })
+
+ It("should handle paths with dot", func() {
+ err := ic.PushAllParents(ctx, "./folder1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(2))
+ })
+
+ Context("when some parent folders have no .ndignore", func() {
+ BeforeEach(func() {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("root.txt")},
+ "folder1/folder2/.ndignore": &fstest.MapFile{Data: []byte("level2.txt")},
+ }
+ ic = newIgnoreChecker(fsys)
+ })
+
+ It("should still push all parent levels", func() {
+ err := ic.PushAllParents(ctx, "folder1/folder2")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(3)) // root, folder1 (empty), folder2
+ Expect(ic.currentPatterns).To(ConsistOf("root.txt", "level2.txt"))
+ })
+ })
+ })
+
+ Describe("ShouldIgnore", func() {
+ var ic *IgnoreChecker
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ })
+
+ Context("with no patterns loaded", func() {
+ It("should not ignore any path", func() {
+ fsys := fstest.MapFS{}
+ ic = newIgnoreChecker(fsys)
+ Expect(ic.ShouldIgnore(ctx, "anything.txt")).To(BeFalse())
+ Expect(ic.ShouldIgnore(ctx, "folder/file.mp3")).To(BeFalse())
+ })
+ })
+
+ Context("special paths", func() {
+ BeforeEach(func() {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("**/*")},
+ }
+ ic = newIgnoreChecker(fsys)
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("should never ignore root or empty paths", func() {
+ Expect(ic.ShouldIgnore(ctx, "")).To(BeFalse())
+ Expect(ic.ShouldIgnore(ctx, ".")).To(BeFalse())
+ })
+
+ It("should ignore all other paths with wildcard", func() {
+ Expect(ic.ShouldIgnore(ctx, "file.txt")).To(BeTrue())
+ Expect(ic.ShouldIgnore(ctx, "folder/file.mp3")).To(BeTrue())
+ })
+ })
+
+ DescribeTable("pattern matching",
+ func(pattern string, path string, shouldMatch bool) {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte(pattern)},
+ }
+ ic = newIgnoreChecker(fsys)
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.ShouldIgnore(ctx, path)).To(Equal(shouldMatch))
+ },
+ Entry("glob match", "*.txt", "file.txt", true),
+ Entry("glob no match", "*.txt", "file.mp3", false),
+ Entry("directory pattern match", "tmp/", "tmp/file.txt", true),
+ Entry("directory pattern no match", "tmp/", "temporary/file.txt", false),
+ Entry("nested glob match", "**/*.log", "deep/nested/file.log", true),
+ Entry("nested glob no match", "**/*.log", "deep/nested/file.txt", false),
+ Entry("specific file match", "ignore.me", "ignore.me", true),
+ Entry("specific file no match", "ignore.me", "keep.me", false),
+ Entry("wildcard all", "**/*", "any/path/file.txt", true),
+ Entry("nested specific match", "temp/*", "temp/cache.db", true),
+ Entry("nested specific no match", "temp/*", "temporary/cache.db", false),
+ )
+
+ Context("with multiple patterns", func() {
+ BeforeEach(func() {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("*.txt\n*.log\ntemp/")},
+ }
+ ic = newIgnoreChecker(fsys)
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("should match any of the patterns", func() {
+ Expect(ic.ShouldIgnore(ctx, "file.txt")).To(BeTrue())
+ Expect(ic.ShouldIgnore(ctx, "debug.log")).To(BeTrue())
+ Expect(ic.ShouldIgnore(ctx, "temp/cache")).To(BeTrue())
+ Expect(ic.ShouldIgnore(ctx, "music.mp3")).To(BeFalse())
+ })
+ })
+ })
+})
diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go
index 8397d6924..329029951 100644
--- a/scanner/phase_1_folders.go
+++ b/scanner/phase_1_folders.go
@@ -26,28 +26,18 @@ import (
"github.com/navidrome/navidrome/utils/slice"
)
-func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer, libs []model.Library) *phaseFolders {
+func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer) *phaseFolders {
var jobs []*scanJob
- for _, lib := range libs {
- if lib.LastScanStartedAt.IsZero() {
- err := ds.Library(ctx).ScanBegin(lib.ID, state.fullScan)
- if err != nil {
- log.Error(ctx, "Scanner: Error updating last scan started at", "lib", lib.Name, err)
- state.sendWarning(err.Error())
- continue
- }
- // Reload library to get updated state
- l, err := ds.Library(ctx).Get(lib.ID)
- if err != nil {
- log.Error(ctx, "Scanner: Error reloading library", "lib", lib.Name, err)
- state.sendWarning(err.Error())
- continue
- }
- lib = *l
- } else {
- log.Debug(ctx, "Scanner: Resuming previous scan", "lib", lib.Name, "lastScanStartedAt", lib.LastScanStartedAt, "fullScan", lib.FullScanInProgress)
+
+ // Create scan jobs for all libraries
+ for _, lib := range state.libraries {
+ // Get target folders for this library if selective scan
+ var targetFolders []string
+ if state.isSelectiveScan() {
+ targetFolders = state.targets[lib.ID]
}
- job, err := newScanJob(ctx, ds, cw, lib, state.fullScan)
+
+ job, err := newScanJob(ctx, ds, cw, lib, state.fullScan, targetFolders)
if err != nil {
log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err)
state.sendWarning(err.Error())
@@ -55,23 +45,27 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor
}
jobs = append(jobs, job)
}
+
return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state}
}
type scanJob struct {
- lib model.Library
- fs storage.MusicFS
- cw artwork.CacheWarmer
- lastUpdates map[string]model.FolderUpdateInfo
- lock sync.Mutex
- numFolders atomic.Int64
+ lib model.Library
+ fs storage.MusicFS
+ cw artwork.CacheWarmer
+ lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library
+ targetFolders []string // Specific folders to scan (including all descendants)
+ lock sync.Mutex
+ numFolders atomic.Int64
}
-func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, lib model.Library, fullScan bool) (*scanJob, error) {
- lastUpdates, err := ds.Folder(ctx).GetLastUpdates(lib)
+func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) {
+ // Get folder updates, optionally filtered to specific target folders
+ lastUpdates, err := ds.Folder(ctx).GetFolderUpdateInfo(lib, targetFolders...)
if err != nil {
return nil, fmt.Errorf("getting last updates: %w", err)
}
+
fileStore, err := storage.For(lib.Path)
if err != nil {
log.Error(ctx, "Error getting storage for library", "library", lib.Name, "path", lib.Path, err)
@@ -82,15 +76,17 @@ func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer,
log.Error(ctx, "Error getting fs for library", "library", lib.Name, "path", lib.Path, err)
return nil, fmt.Errorf("getting fs for library: %w", err)
}
- lib.FullScanInProgress = lib.FullScanInProgress || fullScan
return &scanJob{
- lib: lib,
- fs: fsys,
- cw: cw,
- lastUpdates: lastUpdates,
+ lib: lib,
+ fs: fsys,
+ cw: cw,
+ lastUpdates: lastUpdates,
+ targetFolders: targetFolders,
}, nil
}
+// popLastUpdate retrieves and removes the last update info for the given folder ID
+// This is used to track which folders have been found during the walk_dir_tree
func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo {
j.lock.Lock()
defer j.lock.Unlock()
@@ -100,6 +96,15 @@ func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo {
return lastUpdate
}
+// createFolderEntry creates a new folderEntry for the given path, using the last update info from the job
+// to populate the previous update time and hash. It also removes the folder from the job's lastUpdates map.
+// This is used to track which folders have been found during the walk_dir_tree.
+func (j *scanJob) createFolderEntry(path string) *folderEntry {
+ id := model.FolderID(j.lib, path)
+ info := j.popLastUpdate(id)
+ return newFolderEntry(j, id, path, info.UpdatedAt, info.Hash)
+}
+
// phaseFolders represents the first phase of the scanning process, which is responsible
// for scanning all libraries and importing new or updated files. This phase involves
// traversing the directory tree of each library, identifying new or modified media files,
@@ -138,7 +143,8 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
if utils.IsCtxDone(p.ctx) {
break
}
- outputChan, err := walkDirTree(p.ctx, job)
+
+ outputChan, err := walkDirTree(p.ctx, job, job.targetFolders...)
if err != nil {
log.Warn(p.ctx, "Scanner: Error scanning library", "lib", job.lib.Name, err)
}
@@ -318,6 +324,9 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
defer p.measure(entry)()
p.state.changesDetected.Store(true)
+ // Collect artwork IDs to pre-cache after the transaction commits
+ var artworkIDs []model.ArtworkID
+
err := p.ds.WithTx(func(tx model.DataStore) error {
// Instantiate all repositories just once per folder
folderRepo := tx.Folder(p.ctx)
@@ -336,7 +345,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
}
// Save all tags to DB
- err = tagRepo.Add(entry.tags...)
+ err = tagRepo.Add(entry.job.lib.ID, entry.tags...)
if err != nil {
log.Error(p.ctx, "Scanner: Error persisting tags to DB", "folder", entry.path, err)
return err
@@ -356,7 +365,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
return err
}
if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists {
- entry.job.cw.PreCache(entry.artists[i].CoverArtID())
+ artworkIDs = append(artworkIDs, entry.artists[i].CoverArtID())
}
}
@@ -368,7 +377,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
return err
}
if entry.albums[i].Name != consts.UnknownAlbum {
- entry.job.cw.PreCache(entry.albums[i].CoverArtID())
+ artworkIDs = append(artworkIDs, entry.albums[i].CoverArtID())
}
}
@@ -405,6 +414,14 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
if err != nil {
log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err)
}
+
+ // Pre-cache artwork after the transaction commits successfully
+ if err == nil {
+ for _, artID := range artworkIDs {
+ entry.job.cw.PreCache(artID)
+ }
+ }
+
return entry, err
}
@@ -418,12 +435,14 @@ func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album,
if prevID == "" {
return nil
}
+
// Reassign annotation from previous album to new album
log.Trace(p.ctx, "Reassigning album annotations", "from", prevID, "to", a.ID, "album", a.Name)
if err := repo.ReassignAnnotation(prevID, a.ID); err != nil {
log.Warn(p.ctx, "Scanner: Could not reassign annotations", "from", prevID, "to", a.ID, "album", a.Name, err)
p.state.sendWarning(fmt.Sprintf("Could not reassign annotations from %s to %s ('%s'): %v", prevID, a.ID, a.Name, err))
}
+
// Keep created_at field from previous instance of the album
if err := repo.CopyAttributes(prevID, a.ID, "created_at"); err != nil {
// Silently ignore when the previous album is not found
diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go
index 6f56f6a52..de93ed6ee 100644
--- a/scanner/phase_2_missing_tracks.go
+++ b/scanner/phase_2_missing_tracks.go
@@ -3,6 +3,7 @@ package scanner
import (
"context"
"fmt"
+ "sync"
"sync/atomic"
ppl "github.com/google/go-pipeline/pkg/pipeline"
@@ -31,14 +32,21 @@ type missingTracks struct {
// 4. Updates the database with the new locations of the matched files and removes the old entries.
// 5. Logs the results and finalizes the phase by reporting the total number of matched files.
type phaseMissingTracks struct {
- ctx context.Context
- ds model.DataStore
- totalMatched atomic.Uint32
- state *scanState
+ ctx context.Context
+ ds model.DataStore
+ totalMatched atomic.Uint32
+ state *scanState
+ processedAlbumAnnotations map[string]bool // Track processed album annotation reassignments
+ annotationMutex sync.RWMutex // Protects processedAlbumAnnotations
}
func createPhaseMissingTracks(ctx context.Context, state *scanState, ds model.DataStore) *phaseMissingTracks {
- return &phaseMissingTracks{ctx: ctx, ds: ds, state: state}
+ return &phaseMissingTracks{
+ ctx: ctx,
+ ds: ds,
+ state: state,
+ processedAlbumAnnotations: make(map[string]bool),
+ }
}
func (p *phaseMissingTracks) description() string {
@@ -52,20 +60,15 @@ func (p *phaseMissingTracks) producer() ppl.Producer[*missingTracks] {
func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error {
count := 0
var putIfMatched = func(mt missingTracks) {
- if mt.pid != "" && len(mt.matched) > 0 {
- log.Trace(p.ctx, "Scanner: Found missing and matching tracks", "pid", mt.pid, "missing", len(mt.missing), "matched", len(mt.matched), "lib", mt.lib.Name)
+ if mt.pid != "" && len(mt.missing) > 0 {
+ log.Trace(p.ctx, "Scanner: Found missing tracks", "pid", mt.pid, "missing", "title", mt.missing[0].Title,
+ len(mt.missing), "matched", len(mt.matched), "lib", mt.lib.Name,
+ )
count++
put(&mt)
}
}
- libs, err := p.ds.Library(p.ctx).GetAll()
- if err != nil {
- return fmt.Errorf("loading libraries: %w", err)
- }
- for _, lib := range libs {
- if lib.LastScanStartedAt.IsZero() {
- continue
- }
+ for _, lib := range p.state.libraries {
log.Debug(p.ctx, "Scanner: Checking missing tracks", "libraryId", lib.ID, "libraryName", lib.Name)
cursor, err := p.ds.MediaFile(p.ctx).GetMissingAndMatching(lib.ID)
if err != nil {
@@ -104,10 +107,13 @@ func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error {
func (p *phaseMissingTracks) stages() []ppl.Stage[*missingTracks] {
return []ppl.Stage[*missingTracks]{
ppl.NewStage(p.processMissingTracks, ppl.Name("process missing tracks")),
+ ppl.NewStage(p.processCrossLibraryMoves, ppl.Name("process cross-library moves")),
}
}
func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTracks, error) {
+ hasMatches := false
+
for _, ms := range in.missing {
var exactMatch model.MediaFile
var equivalentMatch model.MediaFile
@@ -132,6 +138,7 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr
return nil, err
}
p.totalMatched.Add(1)
+ hasMatches = true
continue
}
@@ -145,6 +152,7 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr
return nil, err
}
p.totalMatched.Add(1)
+ hasMatches = true
continue
}
@@ -157,23 +165,141 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr
return nil, err
}
p.totalMatched.Add(1)
+ hasMatches = true
}
}
+
+ // If any matches were found in this missingTracks group, return nil
+ // This signals the next stage to skip processing this group
+ if hasMatches {
+ return nil, nil
+ }
+
+ // If no matches found, pass through to next stage
return in, nil
}
-func (p *phaseMissingTracks) moveMatched(mt, ms model.MediaFile) error {
+// processCrossLibraryMoves processes files that weren't matched within their library
+// and attempts to find matches in other libraries
+func (p *phaseMissingTracks) processCrossLibraryMoves(in *missingTracks) (*missingTracks, error) {
+ // Skip if input is nil (meaning previous stage found matches)
+ if in == nil {
+ return nil, nil
+ }
+
+ log.Debug(p.ctx, "Scanner: Processing cross-library moves", "pid", in.pid, "missing", len(in.missing), "lib", in.lib.Name)
+
+ for _, missing := range in.missing {
+ found, err := p.findCrossLibraryMatch(missing)
+ if err != nil {
+ log.Error(p.ctx, "Scanner: Error searching for cross-library matches", "missing", missing.Path, "lib", in.lib.Name, err)
+ continue
+ }
+
+ if found.ID != "" {
+ log.Debug(p.ctx, "Scanner: Found cross-library moved track", "missing", missing.Path, "movedTo", found.Path, "fromLib", in.lib.Name, "toLib", found.LibraryName)
+ err := p.moveMatched(found, missing)
+ if err != nil {
+ log.Error(p.ctx, "Scanner: Error moving cross-library track", "missing", missing.Path, "movedTo", found.Path, err)
+ continue
+ }
+ p.totalMatched.Add(1)
+ }
+ }
+
+ return in, nil
+}
+
+// findCrossLibraryMatch searches for a missing file in other libraries using two-tier matching
+func (p *phaseMissingTracks) findCrossLibraryMatch(missing model.MediaFile) (model.MediaFile, error) {
+ // First tier: Search by MusicBrainz Track ID if available
+ if missing.MbzReleaseTrackID != "" {
+ matches, err := p.ds.MediaFile(p.ctx).FindRecentFilesByMBZTrackID(missing, missing.CreatedAt)
+ if err != nil {
+ log.Error(p.ctx, "Scanner: Error searching for recent files by MBZ Track ID", "mbzTrackID", missing.MbzReleaseTrackID, err)
+ } else {
+ // Apply the same matching logic as within-library matching
+ for _, match := range matches {
+ if missing.Equals(match) {
+ return match, nil // Exact match found
+ }
+ }
+
+ // If only one match and it's equivalent, use it
+ if len(matches) == 1 && missing.IsEquivalent(matches[0]) {
+ return matches[0], nil
+ }
+ }
+ }
+
+ // Second tier: Search by intrinsic properties (title, size, suffix, etc.)
+ matches, err := p.ds.MediaFile(p.ctx).FindRecentFilesByProperties(missing, missing.CreatedAt)
+ if err != nil {
+ log.Error(p.ctx, "Scanner: Error searching for recent files by properties", "missing", missing.Path, err)
+ return model.MediaFile{}, err
+ }
+
+ // Apply the same matching logic as within-library matching
+ for _, match := range matches {
+ if missing.Equals(match) {
+ return match, nil // Exact match found
+ }
+ }
+
+ // If only one match and it's equivalent, use it
+ if len(matches) == 1 && missing.IsEquivalent(matches[0]) {
+ return matches[0], nil
+ }
+
+ return model.MediaFile{}, nil
+}
+
+func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error {
return p.ds.WithTx(func(tx model.DataStore) error {
- discardedID := mt.ID
- mt.ID = ms.ID
- err := tx.MediaFile(p.ctx).Put(&mt)
+ discardedID := target.ID
+ oldAlbumID := missing.AlbumID
+ newAlbumID := target.AlbumID
+
+ // Update the target media file with the missing file's ID. This effectively "moves" the track
+ // to the new location while keeping its annotations and references intact.
+ target.ID = missing.ID
+ err := tx.MediaFile(p.ctx).Put(&target)
if err != nil {
return fmt.Errorf("update matched track: %w", err)
}
+
+ // Discard the new mediafile row (the one that was moved to)
err = tx.MediaFile(p.ctx).Delete(discardedID)
if err != nil {
return fmt.Errorf("delete discarded track: %w", err)
}
+
+ // Handle album annotation reassignment if AlbumID changed
+ if oldAlbumID != newAlbumID {
+ // Use newAlbumID as key since we only care about avoiding duplicate reassignments to the same target
+ p.annotationMutex.RLock()
+ alreadyProcessed := p.processedAlbumAnnotations[newAlbumID]
+ p.annotationMutex.RUnlock()
+
+ if !alreadyProcessed {
+ p.annotationMutex.Lock()
+ // Double-check pattern to avoid race conditions
+ if !p.processedAlbumAnnotations[newAlbumID] {
+ // Reassign direct album annotations (starred, rating)
+ log.Debug(p.ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID)
+ if err := tx.Album(p.ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil {
+ log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err)
+ }
+
+ // Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here
+ p.processedAlbumAnnotations[newAlbumID] = true
+ }
+ p.annotationMutex.Unlock()
+ } else {
+ log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID)
+ }
+ }
+
p.state.changesDetected.Store(true)
return nil
})
diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go
index 5dd6cc679..e709004c9 100644
--- a/scanner/phase_2_missing_tracks_test.go
+++ b/scanner/phase_2_missing_tracks_test.go
@@ -28,7 +28,9 @@ var _ = Describe("phaseMissingTracks", func() {
lr = &tests.MockLibraryRepo{}
lr.SetData(model.Libraries{{ID: 1, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}})
ds = &tests.MockDataStore{MockedMediaFile: mr, MockedLibrary: lr}
- state = &scanState{}
+ state = &scanState{
+ libraries: model.Libraries{{ID: 1, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}},
+ }
phase = createPhaseMissingTracks(ctx, state, ds)
})
@@ -68,12 +70,31 @@ var _ = Describe("phaseMissingTracks", func() {
err := phase.produce(put)
Expect(err).ToNot(HaveOccurred())
- Expect(produced).To(HaveLen(1))
- Expect(produced[0].pid).To(Equal("A"))
- Expect(produced[0].missing).To(HaveLen(1))
- Expect(produced[0].matched).To(HaveLen(1))
+ Expect(produced).To(HaveLen(2))
+ // PID A should have both missing and matched tracks
+ var pidA *missingTracks
+ for _, p := range produced {
+ if p.pid == "A" {
+ pidA = p
+ break
+ }
+ }
+ Expect(pidA).ToNot(BeNil())
+ Expect(pidA.missing).To(HaveLen(1))
+ Expect(pidA.matched).To(HaveLen(1))
+ // PID B should have only missing tracks
+ var pidB *missingTracks
+ for _, p := range produced {
+ if p.pid == "B" {
+ pidB = p
+ break
+ }
+ }
+ Expect(pidB).ToNot(BeNil())
+ Expect(pidB.missing).To(HaveLen(1))
+ Expect(pidB.matched).To(HaveLen(0))
})
- It("should not call put if there are no matches for any missing tracks", func() {
+ It("should call put for any missing tracks even without matches", func() {
mr.SetData(model.MediaFiles{
{ID: "1", PID: "A", Missing: true, LibraryID: 1},
{ID: "2", PID: "B", Missing: true, LibraryID: 1},
@@ -82,7 +103,22 @@ var _ = Describe("phaseMissingTracks", func() {
err := phase.produce(put)
Expect(err).ToNot(HaveOccurred())
- Expect(produced).To(BeZero())
+ Expect(produced).To(HaveLen(2))
+ // Both PID A and PID B should be produced even without matches
+ var pidA, pidB *missingTracks
+ for _, p := range produced {
+ if p.pid == "A" {
+ pidA = p
+ } else if p.pid == "B" {
+ pidB = p
+ }
+ }
+ Expect(pidA).ToNot(BeNil())
+ Expect(pidA.missing).To(HaveLen(1))
+ Expect(pidA.matched).To(HaveLen(0))
+ Expect(pidB).ToNot(BeNil())
+ Expect(pidB.missing).To(HaveLen(1))
+ Expect(pidB.matched).To(HaveLen(0))
})
})
})
@@ -286,4 +322,448 @@ var _ = Describe("phaseMissingTracks", func() {
})
})
})
+
+ Describe("processCrossLibraryMoves", func() {
+ It("should skip processing if input is nil", func() {
+ result, err := phase.processCrossLibraryMoves(nil)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(BeNil())
+ })
+
+ It("should process cross-library moves using MusicBrainz Track ID", func() {
+ scanStartTime := time.Now().Add(-1 * time.Hour)
+ missingTrack := model.MediaFile{
+ ID: "missing1",
+ LibraryID: 1,
+ MbzReleaseTrackID: "mbz-track-123",
+ Title: "Test Track",
+ Size: 1000,
+ Suffix: "mp3",
+ Path: "/lib1/track.mp3",
+ Missing: true,
+ CreatedAt: scanStartTime.Add(-30 * time.Minute),
+ }
+
+ movedTrack := model.MediaFile{
+ ID: "moved1",
+ LibraryID: 2,
+ MbzReleaseTrackID: "mbz-track-123",
+ Title: "Test Track",
+ Size: 1000,
+ Suffix: "mp3",
+ Path: "/lib2/track.mp3",
+ Missing: false,
+ CreatedAt: scanStartTime.Add(-10 * time.Minute),
+ }
+
+ _ = ds.MediaFile(ctx).Put(&missingTrack)
+ _ = ds.MediaFile(ctx).Put(&movedTrack)
+
+ in := &missingTracks{
+ lib: model.Library{ID: 1, Name: "Library 1"},
+ missing: []model.MediaFile{missingTrack},
+ }
+
+ result, err := phase.processCrossLibraryMoves(in)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(Equal(in))
+ Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
+ Expect(state.changesDetected.Load()).To(BeTrue())
+
+ // Verify the move was performed
+ updatedTrack, _ := ds.MediaFile(ctx).Get("missing1")
+ Expect(updatedTrack.Path).To(Equal("/lib2/track.mp3"))
+ Expect(updatedTrack.LibraryID).To(Equal(2))
+ })
+
+ It("should fall back to intrinsic properties when MBZ Track ID is empty", func() {
+ scanStartTime := time.Now().Add(-1 * time.Hour)
+ missingTrack := model.MediaFile{
+ ID: "missing2",
+ LibraryID: 1,
+ MbzReleaseTrackID: "",
+ Title: "Test Track 2",
+ Size: 2000,
+ Suffix: "flac",
+ DiscNumber: 1,
+ TrackNumber: 1,
+ Album: "Test Album",
+ Path: "/lib1/track2.flac",
+ Missing: true,
+ CreatedAt: scanStartTime.Add(-30 * time.Minute),
+ }
+
+ movedTrack := model.MediaFile{
+ ID: "moved2",
+ LibraryID: 2,
+ MbzReleaseTrackID: "",
+ Title: "Test Track 2",
+ Size: 2000,
+ Suffix: "flac",
+ DiscNumber: 1,
+ TrackNumber: 1,
+ Album: "Test Album",
+ Path: "/lib2/track2.flac",
+ Missing: false,
+ CreatedAt: scanStartTime.Add(-10 * time.Minute),
+ }
+
+ _ = ds.MediaFile(ctx).Put(&missingTrack)
+ _ = ds.MediaFile(ctx).Put(&movedTrack)
+
+ in := &missingTracks{
+ lib: model.Library{ID: 1, Name: "Library 1"},
+ missing: []model.MediaFile{missingTrack},
+ }
+
+ result, err := phase.processCrossLibraryMoves(in)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(Equal(in))
+ Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
+ Expect(state.changesDetected.Load()).To(BeTrue())
+
+ // Verify the move was performed
+ updatedTrack, _ := ds.MediaFile(ctx).Get("missing2")
+ Expect(updatedTrack.Path).To(Equal("/lib2/track2.flac"))
+ Expect(updatedTrack.LibraryID).To(Equal(2))
+ })
+
+ It("should not match files in the same library", func() {
+ scanStartTime := time.Now().Add(-1 * time.Hour)
+ missingTrack := model.MediaFile{
+ ID: "missing3",
+ LibraryID: 1,
+ MbzReleaseTrackID: "mbz-track-456",
+ Title: "Test Track 3",
+ Size: 3000,
+ Suffix: "mp3",
+ Path: "/lib1/track3.mp3",
+ Missing: true,
+ CreatedAt: scanStartTime.Add(-30 * time.Minute),
+ }
+
+ sameLibTrack := model.MediaFile{
+ ID: "same1",
+ LibraryID: 1, // Same library
+ MbzReleaseTrackID: "mbz-track-456",
+ Title: "Test Track 3",
+ Size: 3000,
+ Suffix: "mp3",
+ Path: "/lib1/other/track3.mp3",
+ Missing: false,
+ CreatedAt: scanStartTime.Add(-10 * time.Minute),
+ }
+
+ _ = ds.MediaFile(ctx).Put(&missingTrack)
+ _ = ds.MediaFile(ctx).Put(&sameLibTrack)
+
+ in := &missingTracks{
+ lib: model.Library{ID: 1, Name: "Library 1"},
+ missing: []model.MediaFile{missingTrack},
+ }
+
+ result, err := phase.processCrossLibraryMoves(in)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(Equal(in))
+ Expect(phase.totalMatched.Load()).To(Equal(uint32(0)))
+ Expect(state.changesDetected.Load()).To(BeFalse())
+ })
+
+ It("should prioritize MBZ Track ID over intrinsic properties", func() {
+ scanStartTime := time.Now().Add(-1 * time.Hour)
+ missingTrack := model.MediaFile{
+ ID: "missing4",
+ LibraryID: 1,
+ MbzReleaseTrackID: "mbz-track-789",
+ Title: "Test Track 4",
+ Size: 4000,
+ Suffix: "mp3",
+ Path: "/lib1/track4.mp3",
+ Missing: true,
+ CreatedAt: scanStartTime.Add(-30 * time.Minute),
+ }
+
+ // Track with same MBZ ID
+ mbzTrack := model.MediaFile{
+ ID: "mbz1",
+ LibraryID: 2,
+ MbzReleaseTrackID: "mbz-track-789",
+ Title: "Test Track 4",
+ Size: 4000,
+ Suffix: "mp3",
+ Path: "/lib2/track4.mp3",
+ Missing: false,
+ CreatedAt: scanStartTime.Add(-10 * time.Minute),
+ }
+
+ // Track with same intrinsic properties but no MBZ ID
+ intrinsicTrack := model.MediaFile{
+ ID: "intrinsic1",
+ LibraryID: 3,
+ MbzReleaseTrackID: "",
+ Title: "Test Track 4",
+ Size: 4000,
+ Suffix: "mp3",
+ DiscNumber: 1,
+ TrackNumber: 1,
+ Album: "Test Album",
+ Path: "/lib3/track4.mp3",
+ Missing: false,
+ CreatedAt: scanStartTime.Add(-5 * time.Minute),
+ }
+
+ _ = ds.MediaFile(ctx).Put(&missingTrack)
+ _ = ds.MediaFile(ctx).Put(&mbzTrack)
+ _ = ds.MediaFile(ctx).Put(&intrinsicTrack)
+
+ in := &missingTracks{
+ lib: model.Library{ID: 1, Name: "Library 1"},
+ missing: []model.MediaFile{missingTrack},
+ }
+
+ result, err := phase.processCrossLibraryMoves(in)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(Equal(in))
+ Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
+ Expect(state.changesDetected.Load()).To(BeTrue())
+
+ // Verify the MBZ track was chosen (not the intrinsic one)
+ updatedTrack, _ := ds.MediaFile(ctx).Get("missing4")
+ Expect(updatedTrack.Path).To(Equal("/lib2/track4.mp3"))
+ Expect(updatedTrack.LibraryID).To(Equal(2))
+ })
+
+ It("should handle equivalent matches correctly", func() {
+ scanStartTime := time.Now().Add(-1 * time.Hour)
+ missingTrack := model.MediaFile{
+ ID: "missing5",
+ LibraryID: 1,
+ MbzReleaseTrackID: "",
+ Title: "Test Track 5",
+ Size: 5000,
+ Suffix: "mp3",
+ Path: "/lib1/path/track5.mp3",
+ Missing: true,
+ CreatedAt: scanStartTime.Add(-30 * time.Minute),
+ }
+
+ // Equivalent match (same filename, different directory)
+ equivalentTrack := model.MediaFile{
+ ID: "equiv1",
+ LibraryID: 2,
+ MbzReleaseTrackID: "",
+ Title: "Test Track 5",
+ Size: 5000,
+ Suffix: "mp3",
+ Path: "/lib2/different/track5.mp3",
+ Missing: false,
+ CreatedAt: scanStartTime.Add(-10 * time.Minute),
+ }
+
+ _ = ds.MediaFile(ctx).Put(&missingTrack)
+ _ = ds.MediaFile(ctx).Put(&equivalentTrack)
+
+ in := &missingTracks{
+ lib: model.Library{ID: 1, Name: "Library 1"},
+ missing: []model.MediaFile{missingTrack},
+ }
+
+ result, err := phase.processCrossLibraryMoves(in)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(Equal(in))
+ Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
+ Expect(state.changesDetected.Load()).To(BeTrue())
+
+ // Verify the equivalent match was accepted
+ updatedTrack, _ := ds.MediaFile(ctx).Get("missing5")
+ Expect(updatedTrack.Path).To(Equal("/lib2/different/track5.mp3"))
+ Expect(updatedTrack.LibraryID).To(Equal(2))
+ })
+
+ It("should skip matching when multiple matches are found but none are exact", func() {
+ scanStartTime := time.Now().Add(-1 * time.Hour)
+ missingTrack := model.MediaFile{
+ ID: "missing6",
+ LibraryID: 1,
+ MbzReleaseTrackID: "",
+ Title: "Test Track 6",
+ Size: 6000,
+ Suffix: "mp3",
+ DiscNumber: 1,
+ TrackNumber: 1,
+ Album: "Test Album",
+ Path: "/lib1/track6.mp3",
+ Missing: true,
+ CreatedAt: scanStartTime.Add(-30 * time.Minute),
+ }
+
+ // Multiple matches with different metadata (not exact matches)
+ match1 := model.MediaFile{
+ ID: "match1",
+ LibraryID: 2,
+ MbzReleaseTrackID: "",
+ Title: "Test Track 6",
+ Size: 6000,
+ Suffix: "mp3",
+ DiscNumber: 1,
+ TrackNumber: 1,
+ Album: "Test Album",
+ Path: "/lib2/different_track.mp3",
+ Artist: "Different Artist", // This makes it non-exact
+ Missing: false,
+ CreatedAt: scanStartTime.Add(-10 * time.Minute),
+ }
+
+ match2 := model.MediaFile{
+ ID: "match2",
+ LibraryID: 3,
+ MbzReleaseTrackID: "",
+ Title: "Test Track 6",
+ Size: 6000,
+ Suffix: "mp3",
+ DiscNumber: 1,
+ TrackNumber: 1,
+ Album: "Test Album",
+ Path: "/lib3/another_track.mp3",
+ Artist: "Another Artist", // This makes it non-exact
+ Missing: false,
+ CreatedAt: scanStartTime.Add(-5 * time.Minute),
+ }
+
+ _ = ds.MediaFile(ctx).Put(&missingTrack)
+ _ = ds.MediaFile(ctx).Put(&match1)
+ _ = ds.MediaFile(ctx).Put(&match2)
+
+ in := &missingTracks{
+ lib: model.Library{ID: 1, Name: "Library 1"},
+ missing: []model.MediaFile{missingTrack},
+ }
+
+ result, err := phase.processCrossLibraryMoves(in)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(Equal(in))
+ Expect(phase.totalMatched.Load()).To(Equal(uint32(0)))
+ Expect(state.changesDetected.Load()).To(BeFalse())
+
+ // Verify no move was performed
+ unchangedTrack, _ := ds.MediaFile(ctx).Get("missing6")
+ Expect(unchangedTrack.Path).To(Equal("/lib1/track6.mp3"))
+ Expect(unchangedTrack.LibraryID).To(Equal(1))
+ })
+
+ It("should handle errors gracefully", func() {
+ // Set up mock to return error
+ mr.Err = true
+
+ missingTrack := model.MediaFile{
+ ID: "missing7",
+ LibraryID: 1,
+ MbzReleaseTrackID: "mbz-track-error",
+ Title: "Test Track 7",
+ Size: 7000,
+ Suffix: "mp3",
+ Path: "/lib1/track7.mp3",
+ Missing: true,
+ CreatedAt: time.Now().Add(-30 * time.Minute),
+ }
+
+ in := &missingTracks{
+ lib: model.Library{ID: 1, Name: "Library 1"},
+ missing: []model.MediaFile{missingTrack},
+ }
+
+ // Should not fail completely, just skip the problematic file
+ result, err := phase.processCrossLibraryMoves(in)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(result).To(Equal(in))
+ Expect(phase.totalMatched.Load()).To(Equal(uint32(0)))
+ Expect(state.changesDetected.Load()).To(BeFalse())
+ })
+ })
+
+ Describe("Album Annotation Reassignment", func() {
+ var (
+ albumRepo *tests.MockAlbumRepo
+ missingTrack model.MediaFile
+ matchedTrack model.MediaFile
+ oldAlbumID string
+ newAlbumID string
+ )
+
+ BeforeEach(func() {
+ albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
+ albumRepo.ReassignAnnotationCalls = make(map[string]string)
+
+ oldAlbumID = "old-album-id"
+ newAlbumID = "new-album-id"
+
+ missingTrack = model.MediaFile{
+ ID: "missing-track-id",
+ PID: "same-pid",
+ Path: "old/path.mp3",
+ AlbumID: oldAlbumID,
+ LibraryID: 1,
+ Missing: true,
+ Annotations: model.Annotations{
+ PlayCount: 5,
+ Rating: 4,
+ Starred: true,
+ },
+ }
+
+ matchedTrack = model.MediaFile{
+ ID: "matched-track-id",
+ PID: "same-pid",
+ Path: "new/path.mp3",
+ AlbumID: newAlbumID,
+ LibraryID: 2, // Different library
+ Missing: false,
+ Annotations: model.Annotations{
+ PlayCount: 2,
+ Rating: 3,
+ Starred: false,
+ },
+ }
+
+ // Store both tracks in the database
+ _ = ds.MediaFile(ctx).Put(&missingTrack)
+ _ = ds.MediaFile(ctx).Put(&matchedTrack)
+ })
+
+ When("album ID changes during cross-library move", func() {
+ It("should reassign album annotations when AlbumID changes", func() {
+ err := phase.moveMatched(matchedTrack, missingTrack)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify that ReassignAnnotation was called
+ Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue(oldAlbumID, newAlbumID))
+ })
+
+ It("should not reassign annotations when AlbumID is the same", func() {
+ missingTrack.AlbumID = newAlbumID // Same album
+
+ err := phase.moveMatched(matchedTrack, missingTrack)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify that ReassignAnnotation was NOT called
+ Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty())
+ })
+ })
+
+ When("error handling", func() {
+ It("should handle ReassignAnnotation errors gracefully", func() {
+ // Make the album repo return an error
+ albumRepo.SetError(true)
+
+ // The move should still succeed even if annotation reassignment fails
+ err := phase.moveMatched(matchedTrack, missingTrack)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify that the track was still moved (ID should be updated)
+ movedTrack, err := ds.MediaFile(ctx).Get(missingTrack.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(movedTrack.Path).To(Equal(matchedTrack.Path))
+ })
+ })
+ })
})
diff --git a/scanner/phase_3_refresh_albums.go b/scanner/phase_3_refresh_albums.go
index f51aa8f4b..33e0fed01 100644
--- a/scanner/phase_3_refresh_albums.go
+++ b/scanner/phase_3_refresh_albums.go
@@ -27,14 +27,13 @@ import (
type phaseRefreshAlbums struct {
ds model.DataStore
ctx context.Context
- libs model.Libraries
refreshed atomic.Uint32
skipped atomic.Uint32
state *scanState
}
-func createPhaseRefreshAlbums(ctx context.Context, state *scanState, ds model.DataStore, libs model.Libraries) *phaseRefreshAlbums {
- return &phaseRefreshAlbums{ctx: ctx, ds: ds, libs: libs, state: state}
+func createPhaseRefreshAlbums(ctx context.Context, state *scanState, ds model.DataStore) *phaseRefreshAlbums {
+ return &phaseRefreshAlbums{ctx: ctx, ds: ds, state: state}
}
func (p *phaseRefreshAlbums) description() string {
@@ -47,7 +46,7 @@ func (p *phaseRefreshAlbums) producer() ppl.Producer[*model.Album] {
func (p *phaseRefreshAlbums) produce(put func(album *model.Album)) error {
count := 0
- for _, lib := range p.libs {
+ for _, lib := range p.state.libraries {
cursor, err := p.ds.Album(p.ctx).GetTouchedAlbums(lib.ID)
if err != nil {
return fmt.Errorf("loading touched albums: %w", err)
diff --git a/scanner/phase_3_refresh_albums_test.go b/scanner/phase_3_refresh_albums_test.go
index dea2556f0..1f0baf428 100644
--- a/scanner/phase_3_refresh_albums_test.go
+++ b/scanner/phase_3_refresh_albums_test.go
@@ -32,8 +32,8 @@ var _ = Describe("phaseRefreshAlbums", func() {
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
}
- state = &scanState{}
- phase = createPhaseRefreshAlbums(ctx, state, ds, libs)
+ state = &scanState{libraries: libs}
+ phase = createPhaseRefreshAlbums(ctx, state, ds)
})
Describe("description", func() {
diff --git a/scanner/scanner.go b/scanner/scanner.go
index 7ddc78b17..20f3f5da8 100644
--- a/scanner/scanner.go
+++ b/scanner/scanner.go
@@ -3,6 +3,8 @@ package scanner
import (
"context"
"fmt"
+ "maps"
+ "slices"
"sync/atomic"
"time"
@@ -15,6 +17,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/run"
+ "github.com/navidrome/navidrome/utils/slice"
)
type scannerImpl struct {
@@ -28,6 +31,8 @@ type scanState struct {
progress chan<- *ProgressInfo
fullScan bool
changesDetected atomic.Bool
+ libraries model.Libraries // Store libraries list for consistency across phases
+ targets map[int][]string // Optional: map[libraryID][]folderPaths for selective scans
}
func (s *scanState) sendProgress(info *ProgressInfo) {
@@ -36,6 +41,10 @@ func (s *scanState) sendProgress(info *ProgressInfo) {
}
}
+func (s *scanState) isSelectiveScan() bool {
+ return len(s.targets) > 0
+}
+
func (s *scanState) sendWarning(msg string) {
s.sendProgress(&ProgressInfo{Warning: msg})
}
@@ -44,7 +53,7 @@ func (s *scanState) sendError(err error) {
s.sendProgress(&ProgressInfo{Error: err.Error()})
}
-func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo) {
+func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
startTime := time.Now()
state := scanState{
@@ -58,37 +67,75 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
state.changesDetected.Store(true)
}
- libs, err := s.ds.Library(ctx).GetAll()
+ // Get libraries and optionally filter by targets
+ allLibs, err := s.ds.Library(ctx).GetAll()
if err != nil {
state.sendWarning(fmt.Sprintf("getting libraries: %s", err))
return
}
- log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(libs))
+ if len(targets) > 0 {
+ // Selective scan: filter libraries and build targets map
+ state.targets = make(map[int][]string)
+
+ for _, target := range targets {
+ folderPath := target.FolderPath
+ if folderPath == "" {
+ folderPath = "."
+ }
+ state.targets[target.LibraryID] = append(state.targets[target.LibraryID], folderPath)
+ }
+
+ // Filter libraries to only those in targets
+ state.libraries = slice.Filter(allLibs, func(lib model.Library) bool {
+ return len(state.targets[lib.ID]) > 0
+ })
+
+ log.Info(ctx, "Scanner: Starting selective scan", "fullScan", state.fullScan, "numLibraries", len(state.libraries), "numTargets", len(targets))
+ } else {
+ // Full library scan
+ state.libraries = allLibs
+ log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(state.libraries))
+ }
// Store scan type and start time
scanType := "quick"
if state.fullScan {
scanType = "full"
}
+ if state.isSelectiveScan() {
+ scanType += "-selective"
+ }
_ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, scanType)
_ = s.ds.Property(ctx).Put(consts.LastScanStartTimeKey, startTime.Format(time.RFC3339))
// if there was a full scan in progress, force a full scan
if !state.fullScan {
- for _, lib := range libs {
+ for _, lib := range state.libraries {
if lib.FullScanInProgress {
log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name)
state.fullScan = true
- _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full")
+ if state.isSelectiveScan() {
+ _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full-selective")
+ } else {
+ _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full")
+ }
break
}
}
}
+ // Prepare libraries for scanning (initialize LastScanStartedAt if needed)
+ err = s.prepareLibrariesForScan(ctx, &state)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error preparing libraries for scan", err)
+ state.sendError(err)
+ return
+ }
+
err = run.Sequentially(
// Phase 1: Scan all libraries and import new/updated files
- runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds, s.cw, libs)),
+ runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds, s.cw)),
// Phase 2: Process missing files, checking for moves
runPhase[*missingTracks](ctx, 2, createPhaseMissingTracks(ctx, &state, s.ds)),
@@ -96,7 +143,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
// Phases 3 and 4 can be run in parallel
run.Parallel(
// Phase 3: Refresh all new/changed albums and update artists
- runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds, libs)),
+ runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds)),
// Phase 4: Import/update playlists
runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls, s.cw)),
@@ -111,7 +158,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
s.runRefreshStats(ctx, &state),
// Update last_scan_completed_at for all libraries
- s.runUpdateLibraries(ctx, libs, &state),
+ s.runUpdateLibraries(ctx, &state),
// Optimize DB
s.runOptimize(ctx),
@@ -129,7 +176,53 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
state.sendProgress(&ProgressInfo{ChangesDetected: true})
}
- log.Info(ctx, "Scanner: Finished scanning all libraries", "duration", time.Since(startTime))
+ if state.isSelectiveScan() {
+ log.Info(ctx, "Scanner: Finished scanning selected folders", "duration", time.Since(startTime), "numTargets", len(targets))
+ } else {
+ log.Info(ctx, "Scanner: Finished scanning all libraries", "duration", time.Since(startTime))
+ }
+}
+
+// prepareLibrariesForScan initializes the scan for all libraries in the state.
+// It calls ScanBegin for libraries that haven't started scanning yet (LastScanStartedAt is zero),
+// reloads them to get the updated state, and filters out any libraries that fail to initialize.
+func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanState) error {
+ var successfulLibs []model.Library
+
+ for _, lib := range state.libraries {
+ if lib.LastScanStartedAt.IsZero() {
+ // This is a new scan - mark it as started
+ err := s.ds.Library(ctx).ScanBegin(lib.ID, state.fullScan)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err)
+ state.sendWarning(err.Error())
+ continue
+ }
+
+ // Reload library to get updated state (timestamps, etc.)
+ reloadedLib, err := s.ds.Library(ctx).Get(lib.ID)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error reloading library", "lib", lib.Name, err)
+ state.sendWarning(err.Error())
+ continue
+ }
+ lib = *reloadedLib
+ } else {
+ // This is a resumed scan
+ log.Debug(ctx, "Scanner: Resuming previous scan", "lib", lib.Name,
+ "lastScanStartedAt", lib.LastScanStartedAt, "fullScan", lib.FullScanInProgress)
+ }
+
+ successfulLibs = append(successfulLibs, lib)
+ }
+
+ if len(successfulLibs) == 0 {
+ return fmt.Errorf("no libraries available for scanning")
+ }
+
+ // Update state with only successfully initialized libraries
+ state.libraries = successfulLibs
+ return nil
}
func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error {
@@ -138,7 +231,15 @@ func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error
return s.ds.WithTx(func(tx model.DataStore) error {
if state.changesDetected.Load() {
start := time.Now()
- err := tx.GC(ctx)
+
+ // For selective scans, extract library IDs to scope GC operations
+ var libraryIDs []int
+ if state.isSelectiveScan() {
+ libraryIDs = slices.Collect(maps.Keys(state.targets))
+ log.Debug(ctx, "Scanner: Running selective GC", "libraryIDs", libraryIDs)
+ }
+
+ err := tx.GC(ctx, libraryIDs...)
if err != nil {
log.Error(ctx, "Scanner: Error running GC", err)
return fmt.Errorf("running GC: %w", err)
@@ -186,11 +287,11 @@ func (s *scannerImpl) runOptimize(ctx context.Context) func() error {
}
}
-func (s *scannerImpl) runUpdateLibraries(ctx context.Context, libs model.Libraries, state *scanState) func() error {
+func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error {
return func() error {
start := time.Now()
return s.ds.WithTx(func(tx model.DataStore) error {
- for _, lib := range libs {
+ for _, lib := range state.libraries {
err := tx.Library(ctx).ScanEnd(lib.ID)
if err != nil {
log.Error(ctx, "Scanner: Error updating last scan completed", "lib", lib.Name, err)
@@ -216,7 +317,7 @@ func (s *scannerImpl) runUpdateLibraries(ctx context.Context, libs model.Librari
log.Debug(ctx, "Scanner: No changes detected, skipping library stats refresh", "lib", lib.Name)
}
}
- log.Debug(ctx, "Scanner: Updated libraries after scan", "elapsed", time.Since(start), "numLibraries", len(libs))
+ log.Debug(ctx, "Scanner: Updated libraries after scan", "elapsed", time.Since(start), "numLibraries", len(state.libraries))
return nil
}, "scanner: update libraries")
}
diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go
new file mode 100644
index 000000000..66db62edf
--- /dev/null
+++ b/scanner/scanner_multilibrary_test.go
@@ -0,0 +1,831 @@
+package scanner_test
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "testing/fstest"
+ "time"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/core"
+ "github.com/navidrome/navidrome/core/artwork"
+ "github.com/navidrome/navidrome/core/metrics"
+ "github.com/navidrome/navidrome/core/storage/storagetest"
+ "github.com/navidrome/navidrome/db"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/persistence"
+ "github.com/navidrome/navidrome/scanner"
+ "github.com/navidrome/navidrome/server/events"
+ "github.com/navidrome/navidrome/tests"
+ "github.com/navidrome/navidrome/utils/slice"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Scanner - Multi-Library", Ordered, func() {
+ var ctx context.Context
+ var lib1, lib2 model.Library
+ var ds *tests.MockDataStore
+ var s model.Scanner
+
+ createFS := func(path string, files fstest.MapFS) storagetest.FakeFS {
+ fs := storagetest.FakeFS{}
+ fs.SetFiles(files)
+ storagetest.Register(path, &fs)
+ return fs
+ }
+
+ BeforeAll(func() {
+ ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
+ tmpDir := GinkgoT().TempDir()
+ conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner-multilibrary.db?_journal_mode=WAL")
+ log.Warn("Using DB at " + conf.Server.DbPath)
+ db.Db().SetMaxOpenConns(1)
+ })
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.DevExternalScanner = false
+
+ db.Init(ctx)
+ DeferCleanup(func() {
+ Expect(tests.ClearDB()).To(Succeed())
+ })
+
+ ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
+
+ // Create the admin user in the database to match the context
+ adminUser := model.User{
+ ID: "123",
+ UserName: "admin",
+ Name: "Admin User",
+ IsAdmin: true,
+ NewPassword: "password",
+ }
+ Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
+
+ s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
+ core.NewPlaylists(ds), metrics.NewNoopInstance())
+
+ // Create two test libraries (let DB auto-assign IDs)
+ lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"}
+ lib2 = model.Library{Name: "Jazz Collection", Path: "jazz:///music"}
+ Expect(ds.Library(ctx).Put(&lib1)).To(Succeed())
+ Expect(ds.Library(ctx).Put(&lib2)).To(Succeed())
+ })
+
+ runScanner := func(ctx context.Context, fullScan bool) error {
+ _, err := s.ScanAll(ctx, fullScan)
+ return err
+ }
+
+ Context("Two Libraries with Different Content", func() {
+ BeforeEach(func() {
+ // Rock library content
+ beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"})
+ zeppelin := template(_t{"albumartist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"})
+
+ _ = createFS("rock", fstest.MapFS{
+ "The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")),
+ "The Beatles/Abbey Road/02 - Something.mp3": beatles(track(2, "Something")),
+ "Led Zeppelin/IV/01 - Black Dog.mp3": zeppelin(track(1, "Black Dog")),
+ "Led Zeppelin/IV/02 - Rock and Roll.mp3": zeppelin(track(2, "Rock and Roll")),
+ })
+
+ // Jazz library content
+ miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})
+ coltrane := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960, "genre": "Jazz"})
+
+ _ = createFS("jazz", fstest.MapFS{
+ "Miles Davis/Kind of Blue/01 - So What.mp3": miles(track(1, "So What")),
+ "Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": miles(track(2, "Freddie Freeloader")),
+ "John Coltrane/Giant Steps/01 - Giant Steps.mp3": coltrane(track(1, "Giant Steps")),
+ "John Coltrane/Giant Steps/02 - Cousin Mary.mp3": coltrane(track(2, "Cousin Mary")),
+ })
+ })
+
+ When("scanning both libraries", func() {
+ It("should import files with correct library_id", func() {
+ Expect(runScanner(ctx, true)).To(Succeed())
+
+ // Check Rock library media files
+ rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib1.ID},
+ Sort: "title",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(rockFiles).To(HaveLen(4))
+
+ rockTitles := slice.Map(rockFiles, func(f model.MediaFile) string { return f.Title })
+ Expect(rockTitles).To(ContainElements("Come Together", "Something", "Black Dog", "Rock and Roll"))
+
+ // Verify all rock files have correct library_id
+ for _, mf := range rockFiles {
+ Expect(mf.LibraryID).To(Equal(lib1.ID), "Rock file %s should have library_id %d", mf.Title, lib1.ID)
+ }
+
+ // Check Jazz library media files
+ jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib2.ID},
+ Sort: "title",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzFiles).To(HaveLen(4))
+
+ jazzTitles := slice.Map(jazzFiles, func(f model.MediaFile) string { return f.Title })
+ Expect(jazzTitles).To(ContainElements("So What", "Freddie Freeloader", "Giant Steps", "Cousin Mary"))
+
+ // Verify all jazz files have correct library_id
+ for _, mf := range jazzFiles {
+ Expect(mf.LibraryID).To(Equal(lib2.ID), "Jazz file %s should have library_id %d", mf.Title, lib2.ID)
+ }
+ })
+
+ It("should create albums with correct library_id", func() {
+ Expect(runScanner(ctx, true)).To(Succeed())
+
+ // Check Rock library albums
+ rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib1.ID},
+ Sort: "name",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(rockAlbums).To(HaveLen(2))
+ Expect(rockAlbums[0].Name).To(Equal("Abbey Road"))
+ Expect(rockAlbums[0].LibraryID).To(Equal(lib1.ID))
+ Expect(rockAlbums[0].SongCount).To(Equal(2))
+ Expect(rockAlbums[1].Name).To(Equal("IV"))
+ Expect(rockAlbums[1].LibraryID).To(Equal(lib1.ID))
+ Expect(rockAlbums[1].SongCount).To(Equal(2))
+
+ // Check Jazz library albums
+ jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib2.ID},
+ Sort: "name",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzAlbums).To(HaveLen(2))
+ Expect(jazzAlbums[0].Name).To(Equal("Giant Steps"))
+ Expect(jazzAlbums[0].LibraryID).To(Equal(lib2.ID))
+ Expect(jazzAlbums[0].SongCount).To(Equal(2))
+ Expect(jazzAlbums[1].Name).To(Equal("Kind of Blue"))
+ Expect(jazzAlbums[1].LibraryID).To(Equal(lib2.ID))
+ Expect(jazzAlbums[1].SongCount).To(Equal(2))
+ })
+
+ It("should create folders with correct library_id", func() {
+ Expect(runScanner(ctx, true)).To(Succeed())
+
+ // Check Rock library folders
+ rockFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib1.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(rockFolders).To(HaveLen(5)) // ., The Beatles, Led Zeppelin, Abbey Road, IV
+
+ for _, folder := range rockFolders {
+ Expect(folder.LibraryID).To(Equal(lib1.ID), "Rock folder %s should have library_id %d", folder.Name, lib1.ID)
+ }
+
+ // Check Jazz library folders
+ jazzFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib2.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzFolders).To(HaveLen(5)) // ., Miles Davis, John Coltrane, Kind of Blue, Giant Steps
+
+ for _, folder := range jazzFolders {
+ Expect(folder.LibraryID).To(Equal(lib2.ID), "Jazz folder %s should have library_id %d", folder.Name, lib2.ID)
+ }
+ })
+
+ It("should create library-artist associations correctly", func() {
+ Expect(runScanner(ctx, true)).To(Succeed())
+
+ // Check library-artist associations
+
+ // Get all artists and check library associations
+ allArtists, err := ds.Artist(ctx).GetAll()
+ Expect(err).ToNot(HaveOccurred())
+
+ rockArtistNames := []string{}
+ jazzArtistNames := []string{}
+
+ for _, artist := range allArtists {
+ // Check if artist is associated with rock library
+ var count int64
+ err := db.Db().QueryRow(
+ "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?",
+ lib1.ID, artist.ID,
+ ).Scan(&count)
+ Expect(err).ToNot(HaveOccurred())
+ if count > 0 {
+ rockArtistNames = append(rockArtistNames, artist.Name)
+ }
+
+ // Check if artist is associated with jazz library
+ err = db.Db().QueryRow(
+ "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?",
+ lib2.ID, artist.ID,
+ ).Scan(&count)
+ Expect(err).ToNot(HaveOccurred())
+ if count > 0 {
+ jazzArtistNames = append(jazzArtistNames, artist.Name)
+ }
+ }
+
+ Expect(rockArtistNames).To(ContainElements("The Beatles", "Led Zeppelin"))
+ Expect(jazzArtistNames).To(ContainElements("Miles Davis", "John Coltrane"))
+
+ // Artists should not be shared between libraries (except [Unknown Artist])
+ for _, name := range rockArtistNames {
+ if name != "[Unknown Artist]" {
+ Expect(jazzArtistNames).ToNot(ContainElement(name))
+ }
+ }
+ })
+
+ It("should update library statistics correctly", func() {
+ Expect(runScanner(ctx, true)).To(Succeed())
+
+ // Check Rock library stats
+ rockLib, err := ds.Library(ctx).Get(lib1.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(rockLib.TotalSongs).To(Equal(4))
+ Expect(rockLib.TotalAlbums).To(Equal(2))
+
+ Expect(rockLib.TotalArtists).To(Equal(3)) // The Beatles, Led Zeppelin, [Unknown Artist]
+ Expect(rockLib.TotalFolders).To(Equal(2)) // Abbey Road, IV (only folders with audio files)
+
+ // Check Jazz library stats
+ jazzLib, err := ds.Library(ctx).Get(lib2.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzLib.TotalSongs).To(Equal(4))
+ Expect(jazzLib.TotalAlbums).To(Equal(2))
+ Expect(jazzLib.TotalArtists).To(Equal(3)) // Miles Davis, John Coltrane, [Unknown Artist]
+ Expect(jazzLib.TotalFolders).To(Equal(2)) // Kind of Blue, Giant Steps (only folders with audio files)
+ })
+ })
+
+ When("libraries have different content", func() {
+ It("should maintain separate statistics per library", func() {
+ Expect(runScanner(ctx, true)).To(Succeed())
+
+ // Verify rock library stats
+ rockLib, err := ds.Library(ctx).Get(lib1.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(rockLib.TotalSongs).To(Equal(4))
+ Expect(rockLib.TotalAlbums).To(Equal(2))
+
+ // Verify jazz library stats
+ jazzLib, err := ds.Library(ctx).Get(lib2.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzLib.TotalSongs).To(Equal(4))
+ Expect(jazzLib.TotalAlbums).To(Equal(2))
+
+ // Verify that libraries don't interfere with each other
+ rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib1.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(rockFiles).To(HaveLen(4))
+
+ jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib2.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzFiles).To(HaveLen(4))
+ })
+ })
+
+ When("verifying library isolation", func() {
+ It("should keep library data completely separate", func() {
+ Expect(runScanner(ctx, true)).To(Succeed())
+
+ // Verify that rock library only contains rock content
+ rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib1.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ rockAlbumNames := slice.Map(rockAlbums, func(a model.Album) string { return a.Name })
+ Expect(rockAlbumNames).To(ContainElements("Abbey Road", "IV"))
+ Expect(rockAlbumNames).ToNot(ContainElements("Kind of Blue", "Giant Steps"))
+
+ // Verify that jazz library only contains jazz content
+ jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib2.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ jazzAlbumNames := slice.Map(jazzAlbums, func(a model.Album) string { return a.Name })
+ Expect(jazzAlbumNames).To(ContainElements("Kind of Blue", "Giant Steps"))
+ Expect(jazzAlbumNames).ToNot(ContainElements("Abbey Road", "IV"))
+ })
+ })
+
+ When("same artist appears in different libraries", func() {
+ It("should associate artist with both libraries correctly", func() {
+ // Create libraries with Jeff Beck albums in both
+ jeffRock := template(_t{"albumartist": "Jeff Beck", "album": "Truth", "year": 1968, "genre": "Rock"})
+ jeffJazz := template(_t{"albumartist": "Jeff Beck", "album": "Blow by Blow", "year": 1975, "genre": "Jazz"})
+ beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"})
+ miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})
+
+ // Create rock library with Jeff Beck's Truth album
+ _ = createFS("rock", fstest.MapFS{
+ "The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")),
+ "The Beatles/Abbey Road/02 - Something.mp3": beatles(track(2, "Something")),
+ "Jeff Beck/Truth/01 - Beck's Bolero.mp3": jeffRock(track(1, "Beck's Bolero")),
+ "Jeff Beck/Truth/02 - Ol' Man River.mp3": jeffRock(track(2, "Ol' Man River")),
+ })
+
+ // Create jazz library with Jeff Beck's Blow by Blow album
+ _ = createFS("jazz", fstest.MapFS{
+ "Miles Davis/Kind of Blue/01 - So What.mp3": miles(track(1, "So What")),
+ "Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": miles(track(2, "Freddie Freeloader")),
+ "Jeff Beck/Blow by Blow/01 - You Know What I Mean.mp3": jeffJazz(track(1, "You Know What I Mean")),
+ "Jeff Beck/Blow by Blow/02 - She's a Woman.mp3": jeffJazz(track(2, "She's a Woman")),
+ })
+
+ Expect(runScanner(ctx, true)).To(Succeed())
+
+ // Jeff Beck should be associated with both libraries
+ var rockCount, jazzCount int64
+
+ // Get Jeff Beck artist ID
+ jeffArtists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"name": "Jeff Beck"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jeffArtists).To(HaveLen(1))
+ jeffID := jeffArtists[0].ID
+
+ // Check rock library association
+ err = db.Db().QueryRow(
+ "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?",
+ lib1.ID, jeffID,
+ ).Scan(&rockCount)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(rockCount).To(Equal(int64(1)))
+
+ // Check jazz library association
+ err = db.Db().QueryRow(
+ "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?",
+ lib2.ID, jeffID,
+ ).Scan(&jazzCount)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzCount).To(Equal(int64(1)))
+
+ // Verify Jeff Beck albums are in correct libraries
+ rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib1.ID, "album_artist": "Jeff Beck"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(rockAlbums).To(HaveLen(1))
+ Expect(rockAlbums[0].Name).To(Equal("Truth"))
+
+ jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib2.ID, "album_artist": "Jeff Beck"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzAlbums).To(HaveLen(1))
+ Expect(jazzAlbums[0].Name).To(Equal("Blow by Blow"))
+ })
+ })
+ })
+
+ Context("Incremental Scan Behavior", func() {
+ BeforeEach(func() {
+ // Start with minimal content in both libraries
+ rock := template(_t{"albumartist": "Queen", "album": "News of the World", "year": 1977, "genre": "Rock"})
+ jazz := template(_t{"albumartist": "Bill Evans", "album": "Waltz for Debby", "year": 1961, "genre": "Jazz"})
+
+ createFS("rock", fstest.MapFS{
+ "Queen/News of the World/01 - We Will Rock You.mp3": rock(track(1, "We Will Rock You")),
+ })
+
+ createFS("jazz", fstest.MapFS{
+ "Bill Evans/Waltz for Debby/01 - My Foolish Heart.mp3": jazz(track(1, "My Foolish Heart")),
+ })
+ })
+
+ It("should handle incremental scans per library correctly", func() {
+ // Initial full scan
+ Expect(runScanner(ctx, true)).To(Succeed())
+
+ // Verify initial state
+ rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib1.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(rockFiles).To(HaveLen(1))
+
+ jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib2.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzFiles).To(HaveLen(1))
+
+ // Incremental scan should not duplicate existing files
+ Expect(runScanner(ctx, false)).To(Succeed())
+
+ // Verify counts remain the same
+ rockFiles, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib1.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(rockFiles).To(HaveLen(1))
+
+ jazzFiles, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib2.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzFiles).To(HaveLen(1))
+ })
+ })
+
+ Context("Missing Files Handling", func() {
+ var rockFS storagetest.FakeFS
+
+ BeforeEach(func() {
+ rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"})
+
+ rockFS = createFS("rock", fstest.MapFS{
+ "AC-DC/Back in Black/01 - Hells Bells.mp3": rock(track(1, "Hells Bells")),
+ "AC-DC/Back in Black/02 - Shoot to Thrill.mp3": rock(track(2, "Shoot to Thrill")),
+ })
+
+ createFS("jazz", fstest.MapFS{
+ "Herbie Hancock/Head Hunters/01 - Chameleon.mp3": template(_t{
+ "albumartist": "Herbie Hancock", "album": "Head Hunters", "year": 1973, "genre": "Jazz",
+ })(track(1, "Chameleon")),
+ })
+ })
+
+ It("should mark missing files correctly per library", func() {
+ // Initial scan
+ Expect(runScanner(ctx, true)).To(Succeed())
+
+ // Remove one file from rock library only
+ rockFS.Remove("AC-DC/Back in Black/02 - Shoot to Thrill.mp3")
+
+ // Rescan
+ Expect(runScanner(ctx, false)).To(Succeed())
+
+ // Check that only the rock library file is marked as missing
+ missingRockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.And{
+ squirrel.Eq{"library_id": lib1.ID},
+ squirrel.Eq{"missing": true},
+ },
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(missingRockFiles).To(HaveLen(1))
+ Expect(missingRockFiles[0].Title).To(Equal("Shoot to Thrill"))
+
+ // Check that jazz library files are not affected
+ missingJazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.And{
+ squirrel.Eq{"library_id": lib2.ID},
+ squirrel.Eq{"missing": true},
+ },
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(missingJazzFiles).To(HaveLen(0))
+
+ // Verify non-missing files
+ presentRockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.And{
+ squirrel.Eq{"library_id": lib1.ID},
+ squirrel.Eq{"missing": false},
+ },
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(presentRockFiles).To(HaveLen(1))
+ Expect(presentRockFiles[0].Title).To(Equal("Hells Bells"))
+ })
+ })
+
+ Context("Error Handling - Multi-Library", func() {
+ Context("Filesystem errors affecting one library", func() {
+ var rockFS storagetest.FakeFS
+
+ BeforeEach(func() {
+ // Set up content for both libraries
+ rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"})
+ jazz := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})
+
+ rockFS = createFS("rock", fstest.MapFS{
+ "AC-DC/Back in Black/01 - Hells Bells.mp3": rock(track(1, "Hells Bells")),
+ "AC-DC/Back in Black/02 - Shoot to Thrill.mp3": rock(track(2, "Shoot to Thrill")),
+ })
+
+ createFS("jazz", fstest.MapFS{
+ "Miles Davis/Kind of Blue/01 - So What.mp3": jazz(track(1, "So What")),
+ "Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": jazz(track(2, "Freddie Freeloader")),
+ })
+ })
+
+ It("should not affect scanning of other libraries", func() {
+ // Inject filesystem read error in rock library only
+ rockFS.SetError("AC-DC/Back in Black/01 - Hells Bells.mp3", errors.New("filesystem read error"))
+
+ // Scan should succeed overall and return warnings
+ warnings, err := s.ScanAll(ctx, true)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(warnings).ToNot(BeEmpty(), "Should have warnings for filesystem errors")
+
+ // Jazz library should have been scanned successfully
+ jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib2.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzFiles).To(HaveLen(2))
+ Expect(jazzFiles[0].Title).To(BeElementOf("So What", "Freddie Freeloader"))
+ Expect(jazzFiles[1].Title).To(BeElementOf("So What", "Freddie Freeloader"))
+
+ // Rock library may have partial content (depending on scanner implementation)
+ rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib1.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ // No specific expectation - some files may have been imported despite errors
+ _ = rockFiles
+
+ // Verify jazz library stats are correct
+ jazzLib, err := ds.Library(ctx).Get(lib2.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzLib.TotalSongs).To(Equal(2))
+
+ // Error should be empty (warnings don't count as scan errors)
+ lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lastError).To(BeEmpty())
+ })
+
+ It("should continue with warnings for affected library", func() {
+ // Inject read errors on multiple files in rock library
+ rockFS.SetError("AC-DC/Back in Black/01 - Hells Bells.mp3", errors.New("read error 1"))
+ rockFS.SetError("AC-DC/Back in Black/02 - Shoot to Thrill.mp3", errors.New("read error 2"))
+
+ // Scan should complete with warnings for multiple filesystem errors
+ warnings, err := s.ScanAll(ctx, true)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(warnings).ToNot(BeEmpty(), "Should have warnings for multiple filesystem errors")
+
+ // Jazz library should be completely unaffected
+ jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib2.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzFiles).To(HaveLen(2))
+
+ // Jazz library statistics should be accurate
+ jazzLib, err := ds.Library(ctx).Get(lib2.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzLib.TotalSongs).To(Equal(2))
+ Expect(jazzLib.TotalAlbums).To(Equal(1))
+
+ // Error should be empty (warnings don't count as scan errors)
+ lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lastError).To(BeEmpty())
+ })
+ })
+
+ Context("Database errors during multi-library scanning", func() {
+ BeforeEach(func() {
+ // Set up content for both libraries
+ rock := template(_t{"albumartist": "Queen", "album": "News of the World", "year": 1977, "genre": "Rock"})
+ jazz := template(_t{"albumartist": "Bill Evans", "album": "Waltz for Debby", "year": 1961, "genre": "Jazz"})
+
+ createFS("rock", fstest.MapFS{
+ "Queen/News of the World/01 - We Will Rock You.mp3": rock(track(1, "We Will Rock You")),
+ })
+
+ createFS("jazz", fstest.MapFS{
+ "Bill Evans/Waltz for Debby/01 - My Foolish Heart.mp3": jazz(track(1, "My Foolish Heart")),
+ })
+ })
+
+ It("should propagate database errors and stop scanning", func() {
+ // Install mock repo that injects DB error
+ mfRepo := &mockMediaFileRepo{
+ MediaFileRepository: ds.RealDS.MediaFile(ctx),
+ GetMissingAndMatchingError: errors.New("database connection failed"),
+ }
+ ds.MockedMediaFile = mfRepo
+
+ // Scan should return the database error
+ Expect(runScanner(ctx, false)).To(MatchError(ContainSubstring("database connection failed")))
+
+ // Error should be recorded in scanner properties
+ lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lastError).To(ContainSubstring("database connection failed"))
+ })
+
+ It("should preserve error information in scanner properties", func() {
+ // Install mock repo that injects DB error
+ mfRepo := &mockMediaFileRepo{
+ MediaFileRepository: ds.RealDS.MediaFile(ctx),
+ GetMissingAndMatchingError: errors.New("critical database error"),
+ }
+ ds.MockedMediaFile = mfRepo
+
+ // Attempt scan (should fail)
+ Expect(runScanner(ctx, false)).To(HaveOccurred())
+
+ // Check that error is recorded in scanner properties
+ lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lastError).To(ContainSubstring("critical database error"))
+
+ // Scan type should still be recorded
+ scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "")
+ Expect(scanType).To(BeElementOf("incremental", "quick"))
+ })
+ })
+
+ Context("Mixed error scenarios", func() {
+ var rockFS storagetest.FakeFS
+
+ BeforeEach(func() {
+ // Set up rock library with filesystem that can error
+ rock := template(_t{"albumartist": "Metallica", "album": "Master of Puppets", "year": 1986, "genre": "Metal"})
+ rockFS = createFS("rock", fstest.MapFS{
+ "Metallica/Master of Puppets/01 - Battery.mp3": rock(track(1, "Battery")),
+ "Metallica/Master of Puppets/02 - Master of Puppets.mp3": rock(track(2, "Master of Puppets")),
+ })
+
+ // Set up jazz library normally
+ jazz := template(_t{"albumartist": "Herbie Hancock", "album": "Head Hunters", "year": 1973, "genre": "Jazz"})
+ createFS("jazz", fstest.MapFS{
+ "Herbie Hancock/Head Hunters/01 - Chameleon.mp3": jazz(track(1, "Chameleon")),
+ })
+ })
+
+ It("should handle filesystem errors in one library while other succeeds", func() {
+ // Inject filesystem error in rock library
+ rockFS.SetError("Metallica/Master of Puppets/01 - Battery.mp3", errors.New("disk read error"))
+
+ // Scan should complete with warnings (not hard error)
+ warnings, err := s.ScanAll(ctx, true)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(warnings).ToNot(BeEmpty(), "Should have warnings for filesystem error")
+
+ // Jazz library should scan completely successfully
+ jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib2.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzFiles).To(HaveLen(1))
+ Expect(jazzFiles[0].Title).To(Equal("Chameleon"))
+
+ // Jazz library statistics should be accurate
+ jazzLib, err := ds.Library(ctx).Get(lib2.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzLib.TotalSongs).To(Equal(1))
+ Expect(jazzLib.TotalAlbums).To(Equal(1))
+
+ // Rock library may have partial content (depending on scanner implementation)
+ rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib1.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ // No specific expectation - some files may have been imported despite errors
+ _ = rockFiles
+
+ // Error should be empty (warnings don't count as scan errors)
+ lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lastError).To(BeEmpty())
+ })
+
+ It("should handle partial failures gracefully", func() {
+ // Create a scenario where rock has filesystem issues and jazz has normal content
+ rockFS.SetError("Metallica/Master of Puppets/01 - Battery.mp3", errors.New("file corruption"))
+
+ // Do an initial scan with filesystem error
+ warnings, err := s.ScanAll(ctx, true)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(warnings).ToNot(BeEmpty(), "Should have warnings for file corruption")
+
+ // Verify that the working parts completed successfully
+ jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib2.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzFiles).To(HaveLen(1))
+
+ // Scanner properties should reflect successful completion despite warnings
+ scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "")
+ Expect(scanType).To(Equal("full"))
+
+ // Start time should be recorded
+ startTimeStr, _ := ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
+ Expect(startTimeStr).ToNot(BeEmpty())
+
+ // Error should be empty (warnings don't count as scan errors)
+ lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lastError).To(BeEmpty())
+ })
+ })
+
+ Context("Error recovery in multi-library context", func() {
+ It("should recover from previous library-specific errors", func() {
+ // Set up initial content
+ rock := template(_t{"albumartist": "Iron Maiden", "album": "The Number of the Beast", "year": 1982, "genre": "Metal"})
+ jazz := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960, "genre": "Jazz"})
+
+ rockFS := createFS("rock", fstest.MapFS{
+ "Iron Maiden/The Number of the Beast/01 - Invaders.mp3": rock(track(1, "Invaders")),
+ })
+
+ createFS("jazz", fstest.MapFS{
+ "John Coltrane/Giant Steps/01 - Giant Steps.mp3": jazz(track(1, "Giant Steps")),
+ })
+
+ // First scan with filesystem error in rock
+ rockFS.SetError("Iron Maiden/The Number of the Beast/01 - Invaders.mp3", errors.New("temporary disk error"))
+ warnings, err := s.ScanAll(ctx, true)
+ Expect(err).ToNot(HaveOccurred()) // Should succeed with warnings
+ Expect(warnings).ToNot(BeEmpty(), "Should have warnings for temporary disk error")
+
+ // Clear the error and add more content - recreate the filesystem completely
+ rockFS.ClearError("Iron Maiden/The Number of the Beast/01 - Invaders.mp3")
+
+ // Create a new filesystem with both files
+ createFS("rock", fstest.MapFS{
+ "Iron Maiden/The Number of the Beast/01 - Invaders.mp3": rock(track(1, "Invaders")),
+ "Iron Maiden/The Number of the Beast/02 - Children of the Damned.mp3": rock(track(2, "Children of the Damned")),
+ })
+
+ // Second scan should recover and import all rock content
+ warnings, err = s.ScanAll(ctx, true)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(warnings).ToNot(BeEmpty(), "Should have warnings for temporary disk error")
+
+ // Verify both libraries now have content (at least jazz should work)
+ rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib1.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ // The scanner should recover and import both rock files
+ Expect(len(rockFiles)).To(Equal(2))
+
+ jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"library_id": lib2.ID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzFiles).To(HaveLen(1))
+
+ // Both libraries should have correct content counts
+ rockLib, err := ds.Library(ctx).Get(lib1.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(rockLib.TotalSongs).To(Equal(2))
+
+ jazzLib, err := ds.Library(ctx).Get(lib2.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(jazzLib.TotalSongs).To(Equal(1))
+
+ // Error should be empty (successful recovery)
+ lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lastError).To(BeEmpty())
+ })
+ })
+ })
+
+ Context("Scanner Properties", func() {
+ It("should persist last scan type, start time and error properties", func() {
+ // trivial FS setup
+ rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"})
+ _ = createFS("rock", fstest.MapFS{
+ "AC-DC/Back in Black/01 - Hells Bells.mp3": rock(track(1, "Hells Bells")),
+ })
+
+ // Run a full scan
+ Expect(runScanner(ctx, true)).To(Succeed())
+
+ // Validate properties
+ scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "")
+ Expect(scanType).To(Equal("full"))
+
+ startTimeStr, _ := ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
+ Expect(startTimeStr).ToNot(BeEmpty())
+ _, err := time.Parse(time.RFC3339, startTimeStr)
+ Expect(err).ToNot(HaveOccurred())
+
+ lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lastError).To(BeEmpty())
+ })
+ })
+})
diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go
new file mode 100644
index 000000000..629826db4
--- /dev/null
+++ b/scanner/scanner_selective_test.go
@@ -0,0 +1,293 @@
+package scanner_test
+
+import (
+ "context"
+ "path/filepath"
+ "testing/fstest"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core"
+ "github.com/navidrome/navidrome/core/artwork"
+ "github.com/navidrome/navidrome/core/metrics"
+ "github.com/navidrome/navidrome/core/storage/storagetest"
+ "github.com/navidrome/navidrome/db"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/persistence"
+ "github.com/navidrome/navidrome/scanner"
+ "github.com/navidrome/navidrome/server/events"
+ "github.com/navidrome/navidrome/tests"
+ "github.com/navidrome/navidrome/utils/slice"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("ScanFolders", Ordered, func() {
+ var ctx context.Context
+ var lib model.Library
+ var ds model.DataStore
+ var s model.Scanner
+ var fsys storagetest.FakeFS
+
+ BeforeAll(func() {
+ ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
+ tmpDir := GinkgoT().TempDir()
+ conf.Server.DbPath = filepath.Join(tmpDir, "test-selective-scan.db?_journal_mode=WAL")
+ log.Warn("Using DB at " + conf.Server.DbPath)
+ db.Db().SetMaxOpenConns(1)
+ })
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.MusicFolder = "fake:///music"
+ conf.Server.DevExternalScanner = false
+
+ db.Init(ctx)
+ DeferCleanup(func() {
+ Expect(tests.ClearDB()).To(Succeed())
+ })
+
+ ds = persistence.New(db.Db())
+
+ // Create the admin user in the database to match the context
+ adminUser := model.User{
+ ID: "123",
+ UserName: "admin",
+ Name: "Admin User",
+ IsAdmin: true,
+ NewPassword: "password",
+ }
+ Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
+
+ s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
+ core.NewPlaylists(ds), metrics.NewNoopInstance())
+
+ lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
+ Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
+
+ // Initialize fake filesystem
+ fsys = storagetest.FakeFS{}
+ storagetest.Register("fake", &fsys)
+ })
+
+ Describe("Adding tracks to the library", func() {
+ It("scans specified folders recursively including all subdirectories", func() {
+ rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"})
+ jazz := template(_t{"albumartist": "Jazz Artist", "album": "Jazz Album"})
+ pop := template(_t{"albumartist": "Pop Artist", "album": "Pop Album"})
+ createFS(fstest.MapFS{
+ "rock/track1.mp3": rock(track(1, "Rock Track 1")),
+ "rock/track2.mp3": rock(track(2, "Rock Track 2")),
+ "rock/subdir/track3.mp3": rock(track(3, "Rock Track 3")),
+ "jazz/track4.mp3": jazz(track(1, "Jazz Track 1")),
+ "jazz/subdir/track5.mp3": jazz(track(2, "Jazz Track 2")),
+ "pop/track6.mp3": pop(track(1, "Pop Track 1")),
+ })
+
+ // Scan only the "rock" and "jazz" folders (including their subdirectories)
+ targets := []model.ScanTarget{
+ {LibraryID: lib.ID, FolderPath: "rock"},
+ {LibraryID: lib.ID, FolderPath: "jazz"},
+ }
+
+ warnings, err := s.ScanFolders(ctx, false, targets)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(warnings).To(BeEmpty())
+
+ // Verify all tracks in rock and jazz folders (including subdirectories) were imported
+ allFiles, err := ds.MediaFile(ctx).GetAll()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should have 5 tracks (all rock and jazz tracks including subdirectories)
+ Expect(allFiles).To(HaveLen(5))
+
+ // Get the file paths
+ paths := slice.Map(allFiles, func(mf model.MediaFile) string {
+ return filepath.ToSlash(mf.Path)
+ })
+
+ // Verify the correct files were scanned (including subdirectories)
+ Expect(paths).To(ContainElements(
+ "rock/track1.mp3",
+ "rock/track2.mp3",
+ "rock/subdir/track3.mp3",
+ "jazz/track4.mp3",
+ "jazz/subdir/track5.mp3",
+ ))
+
+ // Verify files in the pop folder were NOT scanned
+ Expect(paths).ToNot(ContainElement("pop/track6.mp3"))
+ })
+ })
+
+ Describe("Deleting folders", func() {
+ Context("when a child folder is deleted", func() {
+ var (
+ revolver, help func(...map[string]any) *fstest.MapFile
+ artistFolderID string
+ album1FolderID string
+ album2FolderID string
+ album1TrackIDs []string
+ album2TrackIDs []string
+ )
+
+ BeforeEach(func() {
+ // Setup template functions for creating test files
+ revolver = storagetest.Template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966})
+ help = storagetest.Template(_t{"albumartist": "The Beatles", "album": "Help!", "year": 1965})
+
+ // Initial filesystem with nested folders
+ fsys.SetFiles(fstest.MapFS{
+ "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")),
+ "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")),
+ "The Beatles/Help!/01 - Help!.mp3": help(storagetest.Track(1, "Help!")),
+ "The Beatles/Help!/02 - The Night Before.mp3": help(storagetest.Track(2, "The Night Before")),
+ })
+
+ // First scan - import everything
+ _, err := s.ScanAll(ctx, true)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify initial state - all folders exist
+ folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(folders).To(HaveLen(4)) // root, Artist, Album1, Album2
+
+ // Store folder IDs for later verification
+ for _, f := range folders {
+ switch f.Name {
+ case "The Beatles":
+ artistFolderID = f.ID
+ case "Revolver":
+ album1FolderID = f.ID
+ case "Help!":
+ album2FolderID = f.ID
+ }
+ }
+
+ // Verify all tracks exist
+ allTracks, err := ds.MediaFile(ctx).GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(allTracks).To(HaveLen(4))
+
+ // Store track IDs for later verification
+ for _, t := range allTracks {
+ if t.Album == "Revolver" {
+ album1TrackIDs = append(album1TrackIDs, t.ID)
+ } else if t.Album == "Help!" {
+ album2TrackIDs = append(album2TrackIDs, t.ID)
+ }
+ }
+
+ // Verify no tracks are missing initially
+ for _, t := range allTracks {
+ Expect(t.Missing).To(BeFalse())
+ }
+ })
+
+ It("should mark child folder and its tracks as missing when parent is scanned", func() {
+ // Delete the child folder (Help!) from the filesystem
+ fsys.SetFiles(fstest.MapFS{
+ "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")),
+ "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")),
+ // "The Beatles/Help!" folder and its contents are DELETED
+ })
+
+ // Run selective scan on the parent folder (Artist)
+ // This simulates what the watcher does when a child folder is deleted
+ _, err := s.ScanFolders(ctx, false, []model.ScanTarget{
+ {LibraryID: lib.ID, FolderPath: "The Beatles"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify the deleted child folder is now marked as missing
+ deletedFolder, err := ds.Folder(ctx).Get(album2FolderID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(deletedFolder.Missing).To(BeTrue(), "Deleted child folder should be marked as missing")
+
+ // Verify the deleted folder's tracks are marked as missing
+ for _, trackID := range album2TrackIDs {
+ track, err := ds.MediaFile(ctx).Get(trackID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.Missing).To(BeTrue(), "Track in deleted folder should be marked as missing")
+ }
+
+ // Verify the parent folder is still present and not marked as missing
+ parentFolder, err := ds.Folder(ctx).Get(artistFolderID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(parentFolder.Missing).To(BeFalse(), "Parent folder should not be marked as missing")
+
+ // Verify the sibling folder and its tracks are still present and not missing
+ siblingFolder, err := ds.Folder(ctx).Get(album1FolderID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(siblingFolder.Missing).To(BeFalse(), "Sibling folder should not be marked as missing")
+
+ for _, trackID := range album1TrackIDs {
+ track, err := ds.MediaFile(ctx).Get(trackID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.Missing).To(BeFalse(), "Track in sibling folder should not be marked as missing")
+ }
+ })
+
+ It("should mark deeply nested child folders as missing", func() {
+ // Add a deeply nested folder structure
+ fsys.SetFiles(fstest.MapFS{
+ "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")),
+ "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")),
+ "The Beatles/Help!/01 - Help!.mp3": help(storagetest.Track(1, "Help!")),
+ "The Beatles/Help!/02 - The Night Before.mp3": help(storagetest.Track(2, "The Night Before")),
+ "The Beatles/Help!/Bonus/01 - Bonus Track.mp3": help(storagetest.Track(99, "Bonus Track")),
+ "The Beatles/Help!/Bonus/Nested/01 - Deep Track.mp3": help(storagetest.Track(100, "Deep Track")),
+ })
+
+ // Rescan to import the new nested structure
+ _, err := s.ScanAll(ctx, true)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify nested folders were created
+ allFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(allFolders)).To(BeNumerically(">", 4), "Should have more folders with nested structure")
+
+ // Now delete the entire Help! folder including nested children
+ fsys.SetFiles(fstest.MapFS{
+ "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")),
+ "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")),
+ // All Help! subfolders are deleted
+ })
+
+ // Run selective scan on parent
+ _, err = s.ScanFolders(ctx, false, []model.ScanTarget{
+ {LibraryID: lib.ID, FolderPath: "The Beatles"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify all Help! folders (including nested ones) are marked as missing
+ missingFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.And{
+ squirrel.Eq{"library_id": lib.ID},
+ squirrel.Eq{"missing": true},
+ },
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(missingFolders)).To(BeNumerically(">", 0), "At least one folder should be marked as missing")
+
+ // Verify all tracks in deleted folders are marked as missing
+ allTracks, err := ds.MediaFile(ctx).GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(allTracks).To(HaveLen(6))
+
+ for _, track := range allTracks {
+ if track.Album == "Help!" {
+ Expect(track.Missing).To(BeTrue(), "All tracks in deleted Help! folder should be marked as missing")
+ } else if track.Album == "Revolver" {
+ Expect(track.Missing).To(BeFalse(), "Tracks in Revolver folder should not be marked as missing")
+ }
+ }
+ })
+ })
+ })
+})
diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go
index 6bb74997f..873065aa3 100644
--- a/scanner/scanner_test.go
+++ b/scanner/scanner_test.go
@@ -34,19 +34,19 @@ type _t = map[string]any
var template = storagetest.Template
var track = storagetest.Track
+func createFS(files fstest.MapFS) storagetest.FakeFS {
+ fs := storagetest.FakeFS{}
+ fs.SetFiles(files)
+ storagetest.Register("fake", &fs)
+ return fs
+}
+
var _ = Describe("Scanner", Ordered, func() {
var ctx context.Context
var lib model.Library
var ds *tests.MockDataStore
var mfRepo *mockMediaFileRepo
- var s scanner.Scanner
-
- createFS := func(files fstest.MapFS) storagetest.FakeFS {
- fs := storagetest.FakeFS{}
- fs.SetFiles(files)
- storagetest.Register("fake", &fs)
- return fs
- }
+ var s model.Scanner
BeforeAll(func() {
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
@@ -58,12 +58,14 @@ var _ = Describe("Scanner", Ordered, func() {
})
BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.MusicFolder = "fake:///music" // Set to match test library path
+ conf.Server.DevExternalScanner = false
+
db.Init(ctx)
DeferCleanup(func() {
Expect(tests.ClearDB()).To(Succeed())
})
- DeferCleanup(configtest.SetupConfig())
- conf.Server.DevExternalScanner = false
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
mfRepo = &mockMediaFileRepo{
@@ -71,6 +73,16 @@ var _ = Describe("Scanner", Ordered, func() {
}
ds.MockedMediaFile = mfRepo
+ // Create the admin user in the database to match the context
+ adminUser := model.User{
+ ID: "123",
+ UserName: "admin",
+ Name: "Admin User",
+ IsAdmin: true,
+ NewPassword: "password",
+ }
+ Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
+
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
core.NewPlaylists(ds), metrics.NewNoopInstance())
@@ -466,6 +478,56 @@ var _ = Describe("Scanner", Ordered, func() {
Expect(mf.Missing).To(BeFalse())
})
+ It("marks tracks as missing when scanning a deleted folder with ScanFolders", func() {
+ By("Adding a third track to Revolver to have more test data")
+ fsys.Add("The Beatles/Revolver/03 - I'm Only Sleeping.mp3", revolver(track(3, "I'm Only Sleeping")))
+ Expect(runScanner(ctx, false)).To(Succeed())
+
+ By("Verifying initial state has 5 tracks")
+ Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{
+ Filters: squirrel.Eq{"missing": false},
+ })).To(Equal(int64(5)))
+
+ By("Removing the entire Revolver folder from filesystem")
+ fsys.Remove("The Beatles/Revolver/01 - Taxman.mp3")
+ fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
+ fsys.Remove("The Beatles/Revolver/03 - I'm Only Sleeping.mp3")
+
+ By("Scanning the parent folder (simulating watcher behavior)")
+ targets := []model.ScanTarget{
+ {LibraryID: lib.ID, FolderPath: "The Beatles"},
+ }
+ _, err := s.ScanFolders(ctx, false, targets)
+ Expect(err).To(Succeed())
+
+ By("Checking all Revolver tracks are marked as missing")
+ mf, err := findByPath("The Beatles/Revolver/01 - Taxman.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeTrue())
+
+ mf, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeTrue())
+
+ mf, err = findByPath("The Beatles/Revolver/03 - I'm Only Sleeping.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeTrue())
+
+ By("Checking the Help! tracks are not affected")
+ mf, err = findByPath("The Beatles/Help!/01 - Help!.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeFalse())
+
+ mf, err = findByPath("The Beatles/Help!/02 - The Night Before.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeFalse())
+
+ By("Verifying only 2 non-missing tracks remain (Help! tracks)")
+ Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{
+ Filters: squirrel.Eq{"missing": false},
+ })).To(Equal(int64(2)))
+ })
+
It("does not override artist fields when importing an undertagged file", func() {
By("Making sure artist in the DB contains MBID and sort name")
aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{
diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go
index 63854d262..e6a694f2b 100644
--- a/scanner/walk_dir_tree.go
+++ b/scanner/walk_dir_tree.go
@@ -1,7 +1,6 @@
package scanner
import (
- "bufio"
"context"
"io/fs"
"maps"
@@ -11,37 +10,69 @@ import (
"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/utils"
- ignore "github.com/sabhiram/go-gitignore"
)
-func walkDirTree(ctx context.Context, job *scanJob) (<-chan *folderEntry, error) {
+// walkDirTree recursively walks the directory tree starting from the given targetFolders.
+// If no targetFolders are provided, it starts from the root folder (".").
+// It returns a channel of folderEntry pointers representing each folder found.
+func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-chan *folderEntry, error) {
results := make(chan *folderEntry)
+ folders := targetFolders
+ if len(targetFolders) == 0 {
+ // No specific folders provided, scan the root folder
+ folders = []string{"."}
+ }
go func() {
defer close(results)
- err := walkFolder(ctx, job, ".", nil, results)
- if err != nil {
- log.Error(ctx, "Scanner: There were errors reading directories from filesystem", "path", job.lib.Path, err)
- return
+ for _, folderPath := range folders {
+ if utils.IsCtxDone(ctx) {
+ return
+ }
+
+ // Check if target folder exists before walking it
+ // If it doesn't exist (e.g., deleted between watcher detection and scan execution),
+ // skip it so it remains in job.lastUpdates and gets handled in following steps
+ _, err := fs.Stat(job.fs, folderPath)
+ if err != nil {
+ log.Warn(ctx, "Scanner: Target folder does not exist.", "path", folderPath, err)
+ continue
+ }
+
+ // Create checker and push patterns from root to this folder
+ checker := newIgnoreChecker(job.fs)
+ err = checker.PushAllParents(ctx, folderPath)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error pushing ignore patterns for target folder", "path", folderPath, err)
+ continue
+ }
+
+ // Recursively walk this folder and all its children
+ err = walkFolder(ctx, job, folderPath, checker, results)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error walking target folder", "path", folderPath, err)
+ continue
+ }
}
- log.Debug(ctx, "Scanner: Finished reading folders", "lib", job.lib.Name, "path", job.lib.Path, "numFolders", job.numFolders.Load())
+ log.Debug(ctx, "Scanner: Finished reading target folders", "lib", job.lib.Name, "path", job.lib.Path, "numFolders", job.numFolders.Load())
}()
return results, nil
}
-func walkFolder(ctx context.Context, job *scanJob, currentFolder string, ignorePatterns []string, results chan<- *folderEntry) error {
- ignorePatterns = loadIgnoredPatterns(ctx, job.fs, currentFolder, ignorePatterns)
+func walkFolder(ctx context.Context, job *scanJob, currentFolder string, checker *IgnoreChecker, results chan<- *folderEntry) error {
+ // Push patterns for this folder onto the stack
+ _ = checker.Push(ctx, currentFolder)
+ defer checker.Pop() // Pop patterns when leaving this folder
- folder, children, err := loadDir(ctx, job, currentFolder, ignorePatterns)
+ folder, children, err := loadDir(ctx, job, currentFolder, checker)
if err != nil {
log.Warn(ctx, "Scanner: Error loading dir. Skipping", "path", currentFolder, err)
return nil
}
for _, c := range children {
- err := walkFolder(ctx, job, c, ignorePatterns, results)
+ err := walkFolder(ctx, job, c, checker, results)
if err != nil {
return err
}
@@ -59,50 +90,17 @@ func walkFolder(ctx context.Context, job *scanJob, currentFolder string, ignoreP
return nil
}
-func loadIgnoredPatterns(ctx context.Context, fsys fs.FS, currentFolder string, currentPatterns []string) []string {
- ignoreFilePath := path.Join(currentFolder, consts.ScanIgnoreFile)
- var newPatterns []string
- if _, err := fs.Stat(fsys, ignoreFilePath); err == nil {
- // Read and parse the .ndignore file
- ignoreFile, err := fsys.Open(ignoreFilePath)
- if err != nil {
- log.Warn(ctx, "Scanner: Error opening .ndignore file", "path", ignoreFilePath, err)
- // Continue with previous patterns
- } else {
- defer ignoreFile.Close()
- scanner := bufio.NewScanner(ignoreFile)
- for scanner.Scan() {
- line := scanner.Text()
- if line == "" || strings.HasPrefix(line, "#") {
- continue // Skip empty lines and comments
- }
- newPatterns = append(newPatterns, line)
- }
- if err := scanner.Err(); err != nil {
- log.Warn(ctx, "Scanner: Error reading .ignore file", "path", ignoreFilePath, err)
- }
- }
- // If the .ndignore file is empty, mimic the current behavior and ignore everything
- if len(newPatterns) == 0 {
- log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", currentFolder)
- newPatterns = []string{"**/*"}
- } else {
- log.Trace(ctx, "Scanner: .ndignore file found ", "path", ignoreFilePath, "patterns", newPatterns)
- }
- }
- // Combine the patterns from the .ndignore file with the ones passed as argument
- combinedPatterns := append([]string{}, currentPatterns...)
- return append(combinedPatterns, newPatterns...)
-}
-
-func loadDir(ctx context.Context, job *scanJob, dirPath string, ignorePatterns []string) (folder *folderEntry, children []string, err error) {
- folder = newFolderEntry(job, dirPath)
-
+func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreChecker) (folder *folderEntry, children []string, err error) {
+ // Check if directory exists before creating the folder entry
+ // This is important to avoid removing the folder from lastUpdates if it doesn't exist
dirInfo, err := fs.Stat(job.fs, dirPath)
if err != nil {
log.Warn(ctx, "Scanner: Error stating dir", "path", dirPath, err)
return nil, nil, err
}
+
+ // Now that we know the folder exists, create the entry (which removes it from lastUpdates)
+ folder = job.createFolderEntry(dirPath)
folder.modTime = dirInfo.ModTime()
dir, err := job.fs.Open(dirPath)
@@ -117,12 +115,11 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, ignorePatterns [
return folder, children, err
}
- ignoreMatcher := ignore.CompileIgnoreLines(ignorePatterns...)
entries := fullReadDir(ctx, dirFile)
children = make([]string, 0, len(entries))
for _, entry := range entries {
entryPath := path.Join(dirPath, entry.Name())
- if len(ignorePatterns) > 0 && isScanIgnored(ctx, ignoreMatcher, entryPath) {
+ if checker.ShouldIgnore(ctx, entryPath) {
log.Trace(ctx, "Scanner: Ignoring entry", "path", entryPath)
continue
}
@@ -234,6 +231,7 @@ func isDirReadable(ctx context.Context, fsys fs.FS, dirPath string) bool {
var ignoredDirs = []string{
"$RECYCLE.BIN",
"#snapshot",
+ "@Recycle",
"@Recently-Snapshot",
".streams",
"lost+found",
@@ -254,11 +252,3 @@ func isDirIgnored(name string) bool {
func isEntryIgnored(name string) bool {
return strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..")
}
-
-func isScanIgnored(ctx context.Context, matcher *ignore.GitIgnore, entryPath string) bool {
- matches := matcher.MatchesPath(entryPath)
- if matches {
- log.Trace(ctx, "Scanner: Ignoring entry matching .ndignore: ", "path", entryPath)
- }
- return matches
-}
diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go
index c4278ef82..c9add0bd1 100644
--- a/scanner/walk_dir_tree_test.go
+++ b/scanner/walk_dir_tree_test.go
@@ -25,82 +25,196 @@ var _ = Describe("walk_dir_tree", func() {
ctx context.Context
)
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- ctx = GinkgoT().Context()
- fsys = &mockMusicFS{
- FS: fstest.MapFS{
- "root/a/.ndignore": {Data: []byte("ignored/*")},
- "root/a/f1.mp3": {},
- "root/a/f2.mp3": {},
- "root/a/ignored/bad.mp3": {},
- "root/b/cover.jpg": {},
- "root/c/f3": {},
- "root/d": {},
- "root/d/.ndignore": {},
- "root/d/f1.mp3": {},
- "root/d/f2.mp3": {},
- "root/d/f3.mp3": {},
- "root/e/original/f1.mp3": {},
- "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("root/e/original")},
+ Context("full library", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ ctx = GinkgoT().Context()
+ fsys = &mockMusicFS{
+ FS: fstest.MapFS{
+ "root/a/.ndignore": {Data: []byte("ignored/*")},
+ "root/a/f1.mp3": {},
+ "root/a/f2.mp3": {},
+ "root/a/ignored/bad.mp3": {},
+ "root/b/cover.jpg": {},
+ "root/c/f3": {},
+ "root/d": {},
+ "root/d/.ndignore": {},
+ "root/d/f1.mp3": {},
+ "root/d/f2.mp3": {},
+ "root/d/f3.mp3": {},
+ "root/e/original/f1.mp3": {},
+ "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")},
+ },
+ }
+ job = &scanJob{
+ fs: fsys,
+ lib: model.Library{Path: "/music"},
+ }
+ })
+
+ // Helper function to call walkDirTree and collect folders from the results channel
+ getFolders := func() map[string]*folderEntry {
+ results, err := walkDirTree(ctx, job)
+ Expect(err).ToNot(HaveOccurred())
+
+ folders := map[string]*folderEntry{}
+ g := errgroup.Group{}
+ g.Go(func() error {
+ for folder := range results {
+ folders[folder.path] = folder
+ }
+ return nil
+ })
+ _ = g.Wait()
+ return folders
+ }
+
+ DescribeTable("symlink handling",
+ func(followSymlinks bool, expectedFolderCount int) {
+ conf.Server.Scanner.FollowSymlinks = followSymlinks
+ folders := getFolders()
+
+ Expect(folders).To(HaveLen(expectedFolderCount + 2)) // +2 for `.` and `root`
+
+ // Basic folder structure checks
+ Expect(folders["root/a"].audioFiles).To(SatisfyAll(
+ HaveLen(2),
+ HaveKey("f1.mp3"),
+ HaveKey("f2.mp3"),
+ ))
+ Expect(folders["root/a"].imageFiles).To(BeEmpty())
+ Expect(folders["root/b"].audioFiles).To(BeEmpty())
+ Expect(folders["root/b"].imageFiles).To(SatisfyAll(
+ HaveLen(1),
+ HaveKey("cover.jpg"),
+ ))
+ Expect(folders["root/c"].audioFiles).To(BeEmpty())
+ Expect(folders["root/c"].imageFiles).To(BeEmpty())
+ Expect(folders).ToNot(HaveKey("root/d"))
+
+ // Symlink specific checks
+ if followSymlinks {
+ Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1))
+ } else {
+ Expect(folders).ToNot(HaveKey("root/e/symlink"))
+ }
},
- }
- job = &scanJob{
- fs: fsys,
- lib: model.Library{Path: "/music"},
- }
+ Entry("with symlinks enabled", true, 7),
+ Entry("with symlinks disabled", false, 6),
+ )
})
- // Helper function to call walkDirTree and collect folders from the results channel
- getFolders := func() map[string]*folderEntry {
- results, err := walkDirTree(ctx, job)
- Expect(err).ToNot(HaveOccurred())
-
- folders := map[string]*folderEntry{}
- g := errgroup.Group{}
- g.Go(func() error {
- for folder := range results {
- folders[folder.path] = folder
+ Context("with target folders", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ ctx = GinkgoT().Context()
+ fsys = &mockMusicFS{
+ FS: fstest.MapFS{
+ "Artist/Album1/track1.mp3": {},
+ "Artist/Album1/track2.mp3": {},
+ "Artist/Album2/track1.mp3": {},
+ "Artist/Album2/track2.mp3": {},
+ "Artist/Album2/Sub/track3.mp3": {},
+ "OtherArtist/Album3/track1.mp3": {},
+ },
+ }
+ job = &scanJob{
+ fs: fsys,
+ lib: model.Library{Path: "/music"},
}
- return nil
})
- _ = g.Wait()
- return folders
- }
- DescribeTable("symlink handling",
- func(followSymlinks bool, expectedFolderCount int) {
- conf.Server.Scanner.FollowSymlinks = followSymlinks
- folders := getFolders()
+ It("should recursively walk all subdirectories of target folders", func() {
+ results, err := walkDirTree(ctx, job, "Artist")
+ Expect(err).ToNot(HaveOccurred())
- Expect(folders).To(HaveLen(expectedFolderCount + 2)) // +2 for `.` and `root`
+ folders := map[string]*folderEntry{}
+ g := errgroup.Group{}
+ g.Go(func() error {
+ for folder := range results {
+ folders[folder.path] = folder
+ }
+ return nil
+ })
+ _ = g.Wait()
- // Basic folder structure checks
- Expect(folders["root/a"].audioFiles).To(SatisfyAll(
- HaveLen(2),
- HaveKey("f1.mp3"),
- HaveKey("f2.mp3"),
+ // Should include the target folder and all its descendants
+ Expect(folders).To(SatisfyAll(
+ HaveKey("Artist"),
+ HaveKey("Artist/Album1"),
+ HaveKey("Artist/Album2"),
+ HaveKey("Artist/Album2/Sub"),
))
- Expect(folders["root/a"].imageFiles).To(BeEmpty())
- Expect(folders["root/b"].audioFiles).To(BeEmpty())
- Expect(folders["root/b"].imageFiles).To(SatisfyAll(
- HaveLen(1),
- HaveKey("cover.jpg"),
- ))
- Expect(folders["root/c"].audioFiles).To(BeEmpty())
- Expect(folders["root/c"].imageFiles).To(BeEmpty())
- Expect(folders).ToNot(HaveKey("root/d"))
- // Symlink specific checks
- if followSymlinks {
- Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1))
- } else {
- Expect(folders).ToNot(HaveKey("root/e/symlink"))
+ // Should not include folders outside the target
+ Expect(folders).ToNot(HaveKey("OtherArtist"))
+ Expect(folders).ToNot(HaveKey("OtherArtist/Album3"))
+
+ // Verify audio files are present
+ Expect(folders["Artist/Album1"].audioFiles).To(HaveLen(2))
+ Expect(folders["Artist/Album2"].audioFiles).To(HaveLen(2))
+ Expect(folders["Artist/Album2/Sub"].audioFiles).To(HaveLen(1))
+ })
+
+ It("should handle multiple target folders", func() {
+ results, err := walkDirTree(ctx, job, "Artist/Album1", "OtherArtist")
+ Expect(err).ToNot(HaveOccurred())
+
+ folders := map[string]*folderEntry{}
+ g := errgroup.Group{}
+ g.Go(func() error {
+ for folder := range results {
+ folders[folder.path] = folder
+ }
+ return nil
+ })
+ _ = g.Wait()
+
+ // Should include both target folders and their descendants
+ Expect(folders).To(SatisfyAll(
+ HaveKey("Artist/Album1"),
+ HaveKey("OtherArtist"),
+ HaveKey("OtherArtist/Album3"),
+ ))
+
+ // Should not include other folders
+ Expect(folders).ToNot(HaveKey("Artist"))
+ Expect(folders).ToNot(HaveKey("Artist/Album2"))
+ Expect(folders).ToNot(HaveKey("Artist/Album2/Sub"))
+ })
+
+ It("should skip non-existent target folders and preserve them in lastUpdates", func() {
+ // Setup job with lastUpdates for both existing and non-existing folders
+ job.lastUpdates = map[string]model.FolderUpdateInfo{
+ model.FolderID(job.lib, "Artist/Album1"): {},
+ model.FolderID(job.lib, "NonExistent/DeletedFolder"): {},
+ model.FolderID(job.lib, "OtherArtist/Album3"): {},
}
- },
- Entry("with symlinks enabled", true, 7),
- Entry("with symlinks disabled", false, 6),
- )
+
+ // Try to scan existing folder and non-existing folder
+ results, err := walkDirTree(ctx, job, "Artist/Album1", "NonExistent/DeletedFolder")
+ Expect(err).ToNot(HaveOccurred())
+
+ // Collect results
+ folders := map[string]struct{}{}
+ for folder := range results {
+ folders[folder.path] = struct{}{}
+ }
+
+ // Should only include the existing folder
+ Expect(folders).To(HaveKey("Artist/Album1"))
+ Expect(folders).ToNot(HaveKey("NonExistent/DeletedFolder"))
+
+ // The non-existent folder should still be in lastUpdates (not removed by popLastUpdate)
+ Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "NonExistent/DeletedFolder")))
+
+ // The existing folder should have been removed from lastUpdates
+ Expect(job.lastUpdates).ToNot(HaveKey(model.FolderID(job.lib, "Artist/Album1")))
+
+ // Folders not in targets should remain in lastUpdates
+ Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3")))
+ })
+ })
})
Describe("helper functions", func() {
diff --git a/scanner/watcher.go b/scanner/watcher.go
index bf4f7f9d0..3efebaacc 100644
--- a/scanner/watcher.go
+++ b/scanner/watcher.go
@@ -5,49 +5,79 @@ import (
"fmt"
"io/fs"
"path/filepath"
+ "sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils/singleton"
)
type Watcher interface {
Run(ctx context.Context) error
+ Watch(ctx context.Context, lib *model.Library) error
+ StopWatching(ctx context.Context, libraryID int) error
}
type watcher struct {
- ds model.DataStore
- scanner Scanner
- triggerWait time.Duration
+ mainCtx context.Context
+ ds model.DataStore
+ scanner model.Scanner
+ triggerWait time.Duration
+ watcherNotify chan scanNotification
+ libraryWatchers map[int]*libraryWatcherInstance
+ mu sync.RWMutex
}
-func NewWatcher(ds model.DataStore, s Scanner) Watcher {
- return &watcher{ds: ds, scanner: s, triggerWait: conf.Server.Scanner.WatcherWait}
+type libraryWatcherInstance struct {
+ library *model.Library
+ cancel context.CancelFunc
+}
+
+type scanNotification struct {
+ Library *model.Library
+ FolderPath string
+}
+
+// GetWatcher returns the watcher singleton
+func GetWatcher(ds model.DataStore, s model.Scanner) Watcher {
+ return singleton.GetInstance(func() *watcher {
+ return &watcher{
+ ds: ds,
+ scanner: s,
+ triggerWait: conf.Server.Scanner.WatcherWait,
+ watcherNotify: make(chan scanNotification, 1),
+ libraryWatchers: make(map[int]*libraryWatcherInstance),
+ }
+ })
}
func (w *watcher) Run(ctx context.Context) error {
+ // Keep the main context to be used in all watchers added later
+ w.mainCtx = ctx
+
+ // Start watchers for all existing libraries
libs, err := w.ds.Library(ctx).GetAll()
if err != nil {
return fmt.Errorf("getting libraries: %w", err)
}
- watcherChan := make(chan struct{})
- defer close(watcherChan)
-
- // Start a watcher for each library
for _, lib := range libs {
- go watchLib(ctx, lib, watcherChan)
+ if err := w.Watch(ctx, &lib); err != nil {
+ log.Warn(ctx, "Failed to start watcher for existing library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
+ }
}
+ // Main scan triggering loop
trigger := time.NewTimer(w.triggerWait)
trigger.Stop()
- waiting := false
+ targets := make(map[model.ScanTarget]struct{})
for {
select {
case <-trigger.C:
- log.Info("Watcher: Triggering scan")
+ log.Info("Watcher: Triggering scan for changed folders", "numTargets", len(targets))
status, err := w.scanner.Status(ctx)
if err != nil {
log.Error(ctx, "Watcher: Error retrieving Scanner status", err)
@@ -58,9 +88,23 @@ func (w *watcher) Run(ctx context.Context) error {
trigger.Reset(w.triggerWait * 3)
continue
}
- waiting = false
+
+ // Convert targets map to slice
+ targetSlice := make([]model.ScanTarget, 0, len(targets))
+ for target := range targets {
+ targetSlice = append(targetSlice, target)
+ }
+
+ // Clear targets for next batch
+ targets = make(map[model.ScanTarget]struct{})
+
go func() {
- _, err := w.scanner.ScanAll(ctx, false)
+ var err error
+ if conf.Server.DevSelectiveWatcher {
+ _, err = w.scanner.ScanFolders(ctx, false, targetSlice)
+ } else {
+ _, err = w.scanner.ScanAll(ctx, false)
+ }
if err != nil {
log.Error(ctx, "Watcher: Error scanning", err)
} else {
@@ -68,65 +112,211 @@ func (w *watcher) Run(ctx context.Context) error {
}
}()
case <-ctx.Done():
- return nil
- case <-watcherChan:
- if !waiting {
- log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan")
- waiting = true
+ // Stop all library watchers
+ w.mu.Lock()
+ for libraryID, instance := range w.libraryWatchers {
+ log.Debug(ctx, "Stopping library watcher due to context cancellation", "libraryID", libraryID)
+ instance.cancel()
}
-
+ w.libraryWatchers = make(map[int]*libraryWatcherInstance)
+ w.mu.Unlock()
+ return nil
+ case notification := <-w.watcherNotify:
+ // Reset the trigger timer for debounce
trigger.Reset(w.triggerWait)
+
+ lib := notification.Library
+ folderPath := notification.FolderPath
+
+ // If already scheduled for scan, skip
+ target := model.ScanTarget{LibraryID: lib.ID, FolderPath: folderPath}
+ if _, exists := targets[target]; exists {
+ continue
+ }
+ targets[target] = struct{}{}
+
+ log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan",
+ "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "folderPath", folderPath)
}
}
}
-func watchLib(ctx context.Context, lib model.Library, watchChan chan struct{}) {
+func (w *watcher) Watch(ctx context.Context, lib *model.Library) error {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ // Stop existing watcher if any
+ if existingInstance, exists := w.libraryWatchers[lib.ID]; exists {
+ log.Debug(ctx, "Stopping existing watcher before starting new one", "libraryID", lib.ID, "name", lib.Name)
+ existingInstance.cancel()
+ }
+
+ // Start new watcher
+ watcherCtx, cancel := context.WithCancel(w.mainCtx)
+ instance := &libraryWatcherInstance{
+ library: lib,
+ cancel: cancel,
+ }
+
+ w.libraryWatchers[lib.ID] = instance
+
+ // Start watching in a goroutine
+ go func() {
+ defer func() {
+ w.mu.Lock()
+ if currentInstance, exists := w.libraryWatchers[lib.ID]; exists && currentInstance == instance {
+ delete(w.libraryWatchers, lib.ID)
+ }
+ w.mu.Unlock()
+ }()
+
+ err := w.watchLibrary(watcherCtx, lib)
+ if err != nil && watcherCtx.Err() == nil { // Only log error if not due to cancellation
+ log.Error(ctx, "Watcher error", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
+ }
+ }()
+
+ log.Info(ctx, "Started watcher for library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
+ return nil
+}
+
+func (w *watcher) StopWatching(ctx context.Context, libraryID int) error {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ instance, exists := w.libraryWatchers[libraryID]
+ if !exists {
+ log.Debug(ctx, "No watcher found to stop", "libraryID", libraryID)
+ return nil
+ }
+
+ instance.cancel()
+ delete(w.libraryWatchers, libraryID)
+
+ log.Info(ctx, "Stopped watcher for library", "libraryID", libraryID, "name", instance.library.Name)
+ return nil
+}
+
+// watchLibrary implements the core watching logic for a single library (extracted from old watchLib function)
+func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error {
s, err := storage.For(lib.Path)
if err != nil {
- log.Error(ctx, "Watcher: Error creating storage", "library", lib.ID, "path", lib.Path, err)
- return
+ return fmt.Errorf("creating storage: %w", err)
}
+
fsys, err := s.FS()
if err != nil {
- log.Error(ctx, "Watcher: Error getting FS", "library", lib.ID, "path", lib.Path, err)
- return
+ return fmt.Errorf("getting FS: %w", err)
}
+
watcher, ok := s.(storage.Watcher)
if !ok {
- log.Info(ctx, "Watcher not supported", "library", lib.ID, "path", lib.Path)
- return
+ log.Info(ctx, "Watcher not supported for storage type", "libraryID", lib.ID, "path", lib.Path)
+ return nil
}
+
c, err := watcher.Start(ctx)
if err != nil {
- log.Error(ctx, "Watcher: Error watching library", "library", lib.ID, "path", lib.Path, err)
- return
+ return fmt.Errorf("starting watcher: %w", err)
}
+
absLibPath, err := filepath.Abs(lib.Path)
if err != nil {
- log.Error(ctx, "Watcher: Error converting lib.Path to absolute", "library", lib.ID, "path", lib.Path, err)
- return
+ return fmt.Errorf("converting to absolute path: %w", err)
}
- log.Info(ctx, "Watcher started", "library", lib.ID, "libPath", lib.Path, "absoluteLibPath", absLibPath)
+
+ log.Info(ctx, "Watcher started for library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "absoluteLibPath", absLibPath)
+
+ return w.processLibraryEvents(ctx, lib, fsys, c, absLibPath)
+}
+
+// processLibraryEvents processes filesystem events for a library.
+func (w *watcher) processLibraryEvents(ctx context.Context, lib *model.Library, fsys storage.MusicFS, events <-chan string, absLibPath string) error {
for {
select {
case <-ctx.Done():
- return
- case path := <-c:
- path, err = filepath.Rel(absLibPath, path)
+ log.Debug(ctx, "Watcher stopped due to context cancellation", "libraryID", lib.ID, "name", lib.Name)
+ return nil
+ case path := <-events:
+ path, err := filepath.Rel(absLibPath, path)
if err != nil {
- log.Error(ctx, "Watcher: Error getting relative path", "library", lib.ID, "libPath", absLibPath, "path", path, err)
+ log.Error(ctx, "Error getting relative path", "libraryID", lib.ID, "absolutePath", absLibPath, "path", path, err)
continue
}
+
if isIgnoredPath(ctx, fsys, path) {
- log.Trace(ctx, "Watcher: Ignoring change", "library", lib.ID, "path", path)
+ log.Trace(ctx, "Ignoring change", "libraryID", lib.ID, "path", path)
continue
}
- log.Trace(ctx, "Watcher: Detected change", "library", lib.ID, "path", path, "libPath", absLibPath)
- watchChan <- struct{}{}
+ log.Trace(ctx, "Detected change", "libraryID", lib.ID, "path", path, "absoluteLibPath", absLibPath)
+
+ // Check if the original path (before resolution) matches .ndignore patterns
+ // This is crucial for deleted folders - if a deleted folder matches .ndignore,
+ // we should ignore it BEFORE resolveFolderPath walks up to the parent
+ if w.shouldIgnoreFolderPath(ctx, fsys, path) {
+ log.Debug(ctx, "Ignoring change matching .ndignore pattern", "libraryID", lib.ID, "path", path)
+ continue
+ }
+
+ // Find the folder to scan - validate path exists as directory, walk up if needed
+ folderPath := resolveFolderPath(fsys, path)
+ // Double-check after resolution in case the resolved path is different and also matches patterns
+ if folderPath != path && w.shouldIgnoreFolderPath(ctx, fsys, folderPath) {
+ log.Trace(ctx, "Ignoring change in folder matching .ndignore pattern", "libraryID", lib.ID, "folderPath", folderPath)
+ continue
+ }
+
+ // Notify the main watcher of changes
+ select {
+ case w.watcherNotify <- scanNotification{Library: lib, FolderPath: folderPath}:
+ default:
+ // Channel is full, notification already pending
+ }
}
}
}
+// resolveFolderPath takes a path (which may be a file or directory) and returns
+// the folder path to scan. If the path is a file, it walks up to find the parent
+// directory. Returns empty string if the path should scan the library root.
+func resolveFolderPath(fsys fs.FS, path string) string {
+ // Handle root paths immediately
+ if path == "." || path == "" {
+ return ""
+ }
+
+ folderPath := path
+ for {
+ info, err := fs.Stat(fsys, folderPath)
+ if err == nil && info.IsDir() {
+ // Found a valid directory
+ return folderPath
+ }
+ if folderPath == "." || folderPath == "" {
+ // Reached root, scan entire library
+ return ""
+ }
+ // Walk up the tree
+ dir, _ := filepath.Split(folderPath)
+ if dir == "" || dir == "." {
+ return ""
+ }
+ // Remove trailing slash
+ folderPath = filepath.Clean(dir)
+ }
+}
+
+// shouldIgnoreFolderPath checks if the given folderPath should be ignored based on .ndignore patterns
+// in the library. It pushes all parent folders onto the IgnoreChecker stack before checking.
+func (w *watcher) shouldIgnoreFolderPath(ctx context.Context, fsys storage.MusicFS, folderPath string) bool {
+ checker := newIgnoreChecker(fsys)
+ err := checker.PushAllParents(ctx, folderPath)
+ if err != nil {
+ log.Warn(ctx, "Watcher: Error pushing ignore patterns for folder", "path", folderPath, err)
+ }
+ return checker.ShouldIgnore(ctx, folderPath)
+}
+
func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool {
baseDir, name := filepath.Split(path)
switch {
diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go
new file mode 100644
index 000000000..01bfb2491
--- /dev/null
+++ b/scanner/watcher_test.go
@@ -0,0 +1,491 @@
+package scanner
+
+import (
+ "context"
+ "io/fs"
+ "path/filepath"
+ "testing/fstest"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Watcher", func() {
+ var ctx context.Context
+ var cancel context.CancelFunc
+ var mockScanner *tests.MockScanner
+ var mockDS *tests.MockDataStore
+ var w *watcher
+ var lib *model.Library
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Scanner.WatcherWait = 50 * time.Millisecond // Short wait for tests
+
+ ctx, cancel = context.WithCancel(context.Background())
+ DeferCleanup(cancel)
+
+ lib = &model.Library{
+ ID: 1,
+ Name: "Test Library",
+ Path: "/test/library",
+ }
+
+ // Set up mocks
+ mockScanner = tests.NewMockScanner()
+ mockDS = &tests.MockDataStore{}
+ mockLibRepo := &tests.MockLibraryRepo{}
+ mockLibRepo.SetData(model.Libraries{*lib})
+ mockDS.MockedLibrary = mockLibRepo
+
+ // Create a new watcher instance (not singleton) for testing
+ w = &watcher{
+ ds: mockDS,
+ scanner: mockScanner,
+ triggerWait: conf.Server.Scanner.WatcherWait,
+ watcherNotify: make(chan scanNotification, 10),
+ libraryWatchers: make(map[int]*libraryWatcherInstance),
+ mainCtx: ctx,
+ }
+ })
+
+ Describe("Target Collection and Deduplication", func() {
+ BeforeEach(func() {
+ // Start watcher in background
+ go func() {
+ _ = w.Run(ctx)
+ }()
+
+ // Give watcher time to initialize
+ time.Sleep(10 * time.Millisecond)
+ })
+
+ It("creates separate targets for different folders", func() {
+ // Send notifications for different folders
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
+ time.Sleep(10 * time.Millisecond)
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist2"}
+
+ // Wait for watcher to process and trigger scan
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+
+ // Verify two targets
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(2))
+
+ // Extract folder paths
+ folderPaths := make(map[string]bool)
+ for _, target := range calls[0].Targets {
+ Expect(target.LibraryID).To(Equal(1))
+ folderPaths[target.FolderPath] = true
+ }
+ Expect(folderPaths).To(HaveKey("artist1"))
+ Expect(folderPaths).To(HaveKey("artist2"))
+ })
+
+ It("handles different folder paths correctly", func() {
+ // Send notification for nested folder
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
+
+ // Wait for watcher to process and trigger scan
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+
+ // Verify the target
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(1))
+ Expect(calls[0].Targets[0].FolderPath).To(Equal("artist1/album1"))
+ })
+
+ It("deduplicates folder and file within same folder", func() {
+ // Send notification for a folder
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
+ time.Sleep(10 * time.Millisecond)
+ // Send notification for same folder (as if file change was detected there)
+ // In practice, watchLibrary() would walk up from file path to folder
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
+ time.Sleep(10 * time.Millisecond)
+ // Send another for same folder
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
+
+ // Wait for watcher to process and trigger scan
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+
+ // Verify only one target despite multiple file/folder changes
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(1))
+ Expect(calls[0].Targets[0].FolderPath).To(Equal("artist1/album1"))
+ })
+ })
+
+ Describe("Timer Behavior", func() {
+ BeforeEach(func() {
+ // Start watcher in background
+ go func() {
+ _ = w.Run(ctx)
+ }()
+
+ // Give watcher time to initialize
+ time.Sleep(10 * time.Millisecond)
+ })
+
+ It("resets timer on each change (debouncing)", func() {
+ // Send first notification
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
+
+ // Wait a bit less than half the watcher wait time to ensure timer doesn't fire
+ time.Sleep(20 * time.Millisecond)
+
+ // No scan should have been triggered yet
+ Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
+
+ // Send another notification (resets timer)
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
+
+ // Wait a bit less than half the watcher wait time again
+ time.Sleep(20 * time.Millisecond)
+
+ // Still no scan
+ Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
+
+ // Wait for full timer to expire after last notification (plus margin)
+ time.Sleep(60 * time.Millisecond)
+
+ // Now scan should have been triggered
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 100*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+ })
+
+ It("triggers scan after quiet period", func() {
+ // Send notification
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
+
+ // No scan immediately
+ Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
+
+ // Wait for quiet period
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+ })
+ })
+
+ Describe("Empty and Root Paths", func() {
+ BeforeEach(func() {
+ // Start watcher in background
+ go func() {
+ _ = w.Run(ctx)
+ }()
+
+ // Give watcher time to initialize
+ time.Sleep(10 * time.Millisecond)
+ })
+
+ It("handles empty folder path (library root)", func() {
+ // Send notification with empty folder path
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
+
+ // Wait for scan
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+
+ // Should scan the library root
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(1))
+ Expect(calls[0].Targets[0].FolderPath).To(Equal(""))
+ })
+
+ It("deduplicates empty and dot paths", func() {
+ // Send notifications with empty and dot paths
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
+ time.Sleep(10 * time.Millisecond)
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
+
+ // Wait for scan
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+
+ // Should have only one target
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(1))
+ })
+ })
+
+ Describe("Multiple Libraries", func() {
+ var lib2 *model.Library
+
+ BeforeEach(func() {
+ // Create second library
+ lib2 = &model.Library{
+ ID: 2,
+ Name: "Test Library 2",
+ Path: "/test/library2",
+ }
+
+ mockLibRepo := mockDS.MockedLibrary.(*tests.MockLibraryRepo)
+ mockLibRepo.SetData(model.Libraries{*lib, *lib2})
+
+ // Start watcher in background
+ go func() {
+ _ = w.Run(ctx)
+ }()
+
+ // Give watcher time to initialize
+ time.Sleep(10 * time.Millisecond)
+ })
+
+ It("creates separate targets for different libraries", func() {
+ // Send notifications for both libraries
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
+ time.Sleep(10 * time.Millisecond)
+ w.watcherNotify <- scanNotification{Library: lib2, FolderPath: "artist2"}
+
+ // Wait for scan
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+
+ // Verify two targets for different libraries
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(2))
+
+ // Verify library IDs are different
+ libraryIDs := make(map[int]bool)
+ for _, target := range calls[0].Targets {
+ libraryIDs[target.LibraryID] = true
+ }
+ Expect(libraryIDs).To(HaveKey(1))
+ Expect(libraryIDs).To(HaveKey(2))
+ })
+ })
+
+ Describe(".ndignore handling", func() {
+ var ctx context.Context
+ var cancel context.CancelFunc
+ var w *watcher
+ var mockFS *mockMusicFS
+ var lib *model.Library
+ var eventChan chan string
+ var absLibPath string
+
+ BeforeEach(func() {
+ ctx, cancel = context.WithCancel(GinkgoT().Context())
+ DeferCleanup(cancel)
+
+ // Set up library
+ var err error
+ absLibPath, err = filepath.Abs(".")
+ Expect(err).NotTo(HaveOccurred())
+
+ lib = &model.Library{
+ ID: 1,
+ Name: "Test Library",
+ Path: absLibPath,
+ }
+
+ // Create watcher with notification channel
+ w = &watcher{
+ watcherNotify: make(chan scanNotification, 10),
+ }
+
+ eventChan = make(chan string, 10)
+ })
+
+ // Helper to send an event - converts relative path to absolute
+ sendEvent := func(relativePath string) {
+ path := filepath.Join(absLibPath, relativePath)
+ eventChan <- path
+ }
+
+ // Helper to start the real event processing loop
+ startEventProcessing := func() {
+ go func() {
+ defer GinkgoRecover()
+ // Call the actual processLibraryEvents method - testing the real implementation!
+ _ = w.processLibraryEvents(ctx, lib, mockFS, eventChan, absLibPath)
+ }()
+ }
+
+ Context("when a folder matching .ndignore is deleted", func() {
+ BeforeEach(func() {
+ // Create filesystem with .ndignore containing _TEMP pattern
+ // The deleted folder (_TEMP) will NOT exist in the filesystem
+ mockFS = &mockMusicFS{
+ FS: fstest.MapFS{
+ "rock": &fstest.MapFile{Mode: fs.ModeDir},
+ "rock/.ndignore": &fstest.MapFile{Data: []byte("_TEMP\n")},
+ "rock/valid_album": &fstest.MapFile{Mode: fs.ModeDir},
+ "rock/valid_album/track.mp3": &fstest.MapFile{Data: []byte("audio")},
+ },
+ }
+ })
+
+ It("should NOT send scan notification when deleted folder matches .ndignore", func() {
+ startEventProcessing()
+
+ // Simulate deletion event for rock/_TEMP
+ sendEvent("rock/_TEMP")
+
+ // Wait a bit to ensure event is processed
+ time.Sleep(50 * time.Millisecond)
+
+ // No notification should have been sent
+ Consistently(eventChan, 100*time.Millisecond).Should(BeEmpty())
+ })
+
+ It("should send scan notification for valid folder deletion", func() {
+ startEventProcessing()
+
+ // Simulate deletion event for rock/other_folder (not in .ndignore and doesn't exist)
+ // Since it doesn't exist in mockFS, resolveFolderPath will walk up to "rock"
+ sendEvent("rock/other_folder")
+
+ // Should receive notification for parent folder
+ Eventually(w.watcherNotify, 200*time.Millisecond).Should(Receive(Equal(scanNotification{
+ Library: lib,
+ FolderPath: "rock",
+ })))
+ })
+ })
+
+ Context("with nested folder patterns", func() {
+ BeforeEach(func() {
+ mockFS = &mockMusicFS{
+ FS: fstest.MapFS{
+ "music": &fstest.MapFile{Mode: fs.ModeDir},
+ "music/.ndignore": &fstest.MapFile{Data: []byte("**/temp\n**/cache\n")},
+ "music/rock": &fstest.MapFile{Mode: fs.ModeDir},
+ "music/rock/artist": &fstest.MapFile{Mode: fs.ModeDir},
+ },
+ }
+ })
+
+ It("should NOT send notification when nested ignored folder is deleted", func() {
+ startEventProcessing()
+
+ // Simulate deletion of music/rock/artist/temp (matches **/temp)
+ sendEvent("music/rock/artist/temp")
+
+ // Wait to ensure event is processed
+ time.Sleep(50 * time.Millisecond)
+
+ // No notification should be sent
+ Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for nested ignored folder")
+ })
+
+ It("should send notification for non-ignored nested folder", func() {
+ startEventProcessing()
+
+ // Simulate change in music/rock/artist (doesn't match any pattern)
+ sendEvent("music/rock/artist")
+
+ // Should receive notification
+ Eventually(w.watcherNotify, 200*time.Millisecond).Should(Receive(Equal(scanNotification{
+ Library: lib,
+ FolderPath: "music/rock/artist",
+ })))
+ })
+ })
+
+ Context("with file events in ignored folders", func() {
+ BeforeEach(func() {
+ mockFS = &mockMusicFS{
+ FS: fstest.MapFS{
+ "rock": &fstest.MapFile{Mode: fs.ModeDir},
+ "rock/.ndignore": &fstest.MapFile{Data: []byte("_TEMP\n")},
+ },
+ }
+ })
+
+ It("should NOT send notification for file changes in ignored folders", func() {
+ startEventProcessing()
+
+ // Simulate file change in rock/_TEMP/file.mp3
+ sendEvent("rock/_TEMP/file.mp3")
+
+ // Wait to ensure event is processed
+ time.Sleep(50 * time.Millisecond)
+
+ // No notification should be sent
+ Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for file in ignored folder")
+ })
+ })
+ })
+})
+
+var _ = Describe("resolveFolderPath", func() {
+ var mockFS fs.FS
+
+ BeforeEach(func() {
+ // Create a mock filesystem with some directories and files
+ mockFS = fstest.MapFS{
+ "artist1": &fstest.MapFile{Mode: fs.ModeDir},
+ "artist1/album1": &fstest.MapFile{Mode: fs.ModeDir},
+ "artist1/album1/track1.mp3": &fstest.MapFile{Data: []byte("audio")},
+ "artist1/album1/track2.mp3": &fstest.MapFile{Data: []byte("audio")},
+ "artist1/album2": &fstest.MapFile{Mode: fs.ModeDir},
+ "artist1/album2/song.flac": &fstest.MapFile{Data: []byte("audio")},
+ "artist2": &fstest.MapFile{Mode: fs.ModeDir},
+ "artist2/cover.jpg": &fstest.MapFile{Data: []byte("image")},
+ }
+ })
+
+ It("returns directory path when given a directory", func() {
+ result := resolveFolderPath(mockFS, "artist1/album1")
+ Expect(result).To(Equal("artist1/album1"))
+ })
+
+ It("walks up to parent directory when given a file path", func() {
+ result := resolveFolderPath(mockFS, "artist1/album1/track1.mp3")
+ Expect(result).To(Equal("artist1/album1"))
+ })
+
+ It("walks up multiple levels if needed", func() {
+ result := resolveFolderPath(mockFS, "artist1/album1/nonexistent/file.mp3")
+ Expect(result).To(Equal("artist1/album1"))
+ })
+
+ It("returns empty string for non-existent paths at root", func() {
+ result := resolveFolderPath(mockFS, "nonexistent/path/file.mp3")
+ Expect(result).To(Equal(""))
+ })
+
+ It("returns empty string for dot path", func() {
+ result := resolveFolderPath(mockFS, ".")
+ Expect(result).To(Equal(""))
+ })
+
+ It("returns empty string for empty path", func() {
+ result := resolveFolderPath(mockFS, "")
+ Expect(result).To(Equal(""))
+ })
+
+ It("handles nested file paths correctly", func() {
+ result := resolveFolderPath(mockFS, "artist1/album2/song.flac")
+ Expect(result).To(Equal("artist1/album2"))
+ })
+
+ It("resolves to top-level directory", func() {
+ result := resolveFolderPath(mockFS, "artist2/cover.jpg")
+ Expect(result).To(Equal("artist2"))
+ })
+})
diff --git a/server/auth.go b/server/auth.go
index ed43974dd..8588549ab 100644
--- a/server/auth.go
+++ b/server/auth.go
@@ -193,24 +193,24 @@ func UsernameFromToken(r *http.Request) string {
return token.Subject()
}
-func UsernameFromReverseProxyHeader(r *http.Request) string {
- if conf.Server.ReverseProxyWhitelist == "" {
+func UsernameFromExtAuthHeader(r *http.Request) string {
+ if conf.Server.ExtAuth.TrustedSources == "" {
return ""
}
reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context())
if !ok {
- log.Error("ReverseProxyWhitelist enabled but no proxy IP found in request context. Please report this error.")
+ log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.")
return ""
}
- if !validateIPAgainstList(reverseProxyIp, conf.Server.ReverseProxyWhitelist) {
- log.Warn(r.Context(), "IP is not whitelisted for reverse proxy login", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
+ if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
+ log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
return ""
}
- username := r.Header.Get(conf.Server.ReverseProxyUserHeader)
+ username := r.Header.Get(conf.Server.ExtAuth.UserHeader)
if username == "" {
return ""
}
- log.Trace(r, "Found username in ReverseProxyUserHeader", "username", username)
+ log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username)
return username
}
@@ -256,7 +256,7 @@ func authenticateRequest(ds model.DataStore, r *http.Request, findUsernameFns ..
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromReverseProxyHeader)
+ ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader)
if err != nil {
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
@@ -291,7 +291,7 @@ func JWTRefresher(next http.Handler) http.Handler {
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} {
username := UsernameFromConfig(r)
if username == "" {
- username = UsernameFromReverseProxyHeader(r)
+ username = UsernameFromExtAuthHeader(r)
if username == "" {
return nil
}
diff --git a/server/auth_test.go b/server/auth_test.go
index 06ca2ea39..633299096 100644
--- a/server/auth_test.go
+++ b/server/auth_test.go
@@ -80,7 +80,7 @@ var _ = Describe("Auth", func() {
req.Header.Add("Remote-User", "janedoe")
resp = httptest.NewRecorder()
conf.Server.UILoginBackgroundURL = ""
- conf.Server.ReverseProxyWhitelist = "192.168.0.0/16,2001:4860:4860::/48"
+ conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16,2001:4860:4860::/48"
})
It("sets auth data if IPv4 matches whitelist", func() {
@@ -155,7 +155,7 @@ var _ = Describe("Auth", func() {
It("does not set auth data when listening on unix socket without whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test"
- conf.Server.ReverseProxyWhitelist = ""
+ conf.Server.ExtAuth.TrustedSources = ""
// No ReverseProxyIp in request context
serveIndex(ds, fs, nil)(resp, req)
@@ -176,7 +176,7 @@ var _ = Describe("Auth", func() {
It("sets auth data when listening on unix socket with correct whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test"
- conf.Server.ReverseProxyWhitelist = conf.Server.ReverseProxyWhitelist + ",@"
+ conf.Server.ExtAuth.TrustedSources = conf.Server.ExtAuth.TrustedSources + ",@"
req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@"))
serveIndex(ds, fs, nil)(resp, req)
@@ -302,8 +302,8 @@ var _ = Describe("Auth", func() {
ds = &tests.MockDataStore{}
req = httptest.NewRequest("GET", "/", nil)
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP))
- conf.Server.ReverseProxyWhitelist = "192.168.0.0/16"
- conf.Server.ReverseProxyUserHeader = "Remote-User"
+ conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16"
+ conf.Server.ExtAuth.UserHeader = "Remote-User"
})
It("makes the first user an admin", func() {
diff --git a/server/events/events.go b/server/events/events.go
index e8dcd81f0..ff0a8a40a 100644
--- a/server/events/events.go
+++ b/server/events/events.go
@@ -13,8 +13,8 @@ type eventCtxKey string
const broadcastToAllKey eventCtxKey = "broadcastToAll"
-// BroadcastToAll is a context key that can be used to broadcast an event to all clients
-func BroadcastToAll(ctx context.Context) context.Context {
+// broadcastToAll is a context key that can be used to broadcast an event to all clients
+func broadcastToAll(ctx context.Context) context.Context {
return context.WithValue(ctx, broadcastToAllKey, true)
}
diff --git a/server/events/sse.go b/server/events/sse.go
index 690c79937..54a602985 100644
--- a/server/events/sse.go
+++ b/server/events/sse.go
@@ -19,6 +19,7 @@ import (
type Broker interface {
http.Handler
SendMessage(ctx context.Context, event Event)
+ SendBroadcastMessage(ctx context.Context, event Event)
}
const (
@@ -77,6 +78,11 @@ func GetBroker() Broker {
})
}
+func (b *broker) SendBroadcastMessage(ctx context.Context, evt Event) {
+ ctx = broadcastToAll(ctx)
+ b.SendMessage(ctx, evt)
+}
+
func (b *broker) SendMessage(ctx context.Context, evt Event) {
msg := b.prepareMessage(ctx, evt)
log.Trace("Broker received new event", "type", msg.event, "data", msg.data)
@@ -280,4 +286,6 @@ type noopBroker struct {
http.Handler
}
+func (b noopBroker) SendBroadcastMessage(context.Context, Event) {}
+
func (noopBroker) SendMessage(context.Context, Event) {}
diff --git a/server/middlewares.go b/server/middlewares.go
index 2afe09a5a..21f897931 100644
--- a/server/middlewares.go
+++ b/server/middlewares.go
@@ -168,7 +168,7 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler {
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
// context if navidrome is behind a trusted reverse proxy.
func realIPMiddleware(next http.Handler) http.Handler {
- if conf.Server.ReverseProxyWhitelist != "" {
+ if conf.Server.ExtAuth.TrustedSources != "" {
return chi.Chain(
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
middleware.RealIP,
diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go
index d708d72f9..9a86a9add 100644
--- a/server/nativeapi/config.go
+++ b/server/nativeapi/config.go
@@ -9,7 +9,6 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model/request"
)
// sensitiveFieldsPartialMask contains configuration field names that should be redacted
@@ -99,11 +98,6 @@ func applySensitiveFieldMasking(ctx context.Context, config map[string]interface
func getConfig(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- user, _ := request.UserFrom(ctx)
- if !user.IsAdmin {
- http.Error(w, "Config endpoint is only available to admin users", http.StatusUnauthorized)
- return
- }
// Marshal the actual configuration struct to preserve original field names
configBytes, err := json.Marshal(*conf.Server)
diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go
index 52baef83a..d9c722955 100644
--- a/server/nativeapi/config_test.go
+++ b/server/nativeapi/config_test.go
@@ -1,109 +1,171 @@
package nativeapi
import (
+ "bytes"
+ "context"
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/core"
+ "github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/server"
+ "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
-var _ = Describe("getConfig", func() {
+var _ = Describe("Config API", func() {
+ var ds model.DataStore
+ var router http.Handler
+ var adminUser, regularUser model.User
+
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
+ conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
+ ds = &tests.MockDataStore{}
+ auth.Init(ds)
+ nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
+ router = server.JWTVerifier(nativeRouter)
+
+ // Create test users
+ adminUser = model.User{
+ ID: "admin-1",
+ UserName: "admin",
+ Name: "Admin User",
+ IsAdmin: true,
+ NewPassword: "adminpass",
+ }
+ regularUser = model.User{
+ ID: "user-1",
+ UserName: "regular",
+ Name: "Regular User",
+ IsAdmin: false,
+ NewPassword: "userpass",
+ }
+
+ // Store in mock datastore
+ Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
+ Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed())
})
- Context("when user is not admin", func() {
- It("returns unauthorized", func() {
- req := httptest.NewRequest("GET", "/config", nil)
- w := httptest.NewRecorder()
- ctx := request.WithUser(req.Context(), model.User{IsAdmin: false})
+ Describe("GET /api/config", func() {
+ Context("as admin user", func() {
+ var adminToken string
- getConfig(w, req.WithContext(ctx))
+ BeforeEach(func() {
+ var err error
+ adminToken, err = auth.CreateToken(&adminUser)
+ Expect(err).ToNot(HaveOccurred())
+ })
- Expect(w.Code).To(Equal(http.StatusUnauthorized))
- })
- })
+ It("returns config successfully", func() {
+ req := createAuthenticatedConfigRequest(adminToken)
+ w := httptest.NewRecorder()
- Context("when user is admin", func() {
- It("returns config successfully", func() {
- req := httptest.NewRequest("GET", "/config", nil)
- w := httptest.NewRecorder()
- ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
+ router.ServeHTTP(w, req)
- getConfig(w, req.WithContext(ctx))
+ Expect(w.Code).To(Equal(http.StatusOK))
+ var resp configResponse
+ Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
+ Expect(resp.ID).To(Equal("config"))
+ Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile))
+ Expect(resp.Config).ToNot(BeEmpty())
+ })
- Expect(w.Code).To(Equal(http.StatusOK))
- var resp configResponse
- Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
- Expect(resp.ID).To(Equal("config"))
- Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile))
- Expect(resp.Config).ToNot(BeEmpty())
+ It("redacts sensitive fields", func() {
+ conf.Server.LastFM.ApiKey = "secretapikey123"
+ conf.Server.Spotify.Secret = "spotifysecret456"
+ conf.Server.PasswordEncryptionKey = "encryptionkey789"
+ conf.Server.DevAutoCreateAdminPassword = "adminpassword123"
+ conf.Server.Prometheus.Password = "prometheuspass"
+
+ req := createAuthenticatedConfigRequest(adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+ var resp configResponse
+ Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
+
+ // Check LastFM.ApiKey (partially masked)
+ lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
+ Expect(ok).To(BeTrue())
+ Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
+
+ // Check Spotify.Secret (partially masked)
+ spotify, ok := resp.Config["Spotify"].(map[string]interface{})
+ Expect(ok).To(BeTrue())
+ Expect(spotify["Secret"]).To(Equal("s**************6"))
+
+ // Check PasswordEncryptionKey (fully masked)
+ Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****"))
+
+ // Check DevAutoCreateAdminPassword (fully masked)
+ Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****"))
+
+ // Check Prometheus.Password (fully masked)
+ prometheus, ok := resp.Config["Prometheus"].(map[string]interface{})
+ Expect(ok).To(BeTrue())
+ Expect(prometheus["Password"]).To(Equal("****"))
+ })
+
+ It("handles empty sensitive values", func() {
+ conf.Server.LastFM.ApiKey = ""
+ conf.Server.PasswordEncryptionKey = ""
+
+ req := createAuthenticatedConfigRequest(adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+ var resp configResponse
+ Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
+
+ // Check LastFM.ApiKey - should be preserved because it's sensitive
+ lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
+ Expect(ok).To(BeTrue())
+ Expect(lastfm["ApiKey"]).To(Equal(""))
+
+ // Empty sensitive values should remain empty - should be preserved because it's sensitive
+ Expect(resp.Config["PasswordEncryptionKey"]).To(Equal(""))
+ })
})
- It("redacts sensitive fields", func() {
- conf.Server.LastFM.ApiKey = "secretapikey123"
- conf.Server.Spotify.Secret = "spotifysecret456"
- conf.Server.PasswordEncryptionKey = "encryptionkey789"
- conf.Server.DevAutoCreateAdminPassword = "adminpassword123"
- conf.Server.Prometheus.Password = "prometheuspass"
+ Context("as regular user", func() {
+ var userToken string
- req := httptest.NewRequest("GET", "/config", nil)
- w := httptest.NewRecorder()
- ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
- getConfig(w, req.WithContext(ctx))
+ BeforeEach(func() {
+ var err error
+ userToken, err = auth.CreateToken(®ularUser)
+ Expect(err).ToNot(HaveOccurred())
+ })
- Expect(w.Code).To(Equal(http.StatusOK))
- var resp configResponse
- Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
+ It("denies access with forbidden status", func() {
+ req := createAuthenticatedConfigRequest(userToken)
+ w := httptest.NewRecorder()
- // Check LastFM.ApiKey (partially masked)
- lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
- Expect(ok).To(BeTrue())
- Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
+ router.ServeHTTP(w, req)
- // Check Spotify.Secret (partially masked)
- spotify, ok := resp.Config["Spotify"].(map[string]interface{})
- Expect(ok).To(BeTrue())
- Expect(spotify["Secret"]).To(Equal("s**************6"))
-
- // Check PasswordEncryptionKey (fully masked)
- Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****"))
-
- // Check DevAutoCreateAdminPassword (fully masked)
- Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****"))
-
- // Check Prometheus.Password (fully masked)
- prometheus, ok := resp.Config["Prometheus"].(map[string]interface{})
- Expect(ok).To(BeTrue())
- Expect(prometheus["Password"]).To(Equal("****"))
+ Expect(w.Code).To(Equal(http.StatusForbidden))
+ })
})
- It("handles empty sensitive values", func() {
- conf.Server.LastFM.ApiKey = ""
- conf.Server.PasswordEncryptionKey = ""
+ Context("without authentication", func() {
+ It("denies access with unauthorized status", func() {
+ req := createUnauthenticatedConfigRequest("GET", "/config/", nil)
+ w := httptest.NewRecorder()
- req := httptest.NewRequest("GET", "/config", nil)
- w := httptest.NewRecorder()
- ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
- getConfig(w, req.WithContext(ctx))
+ router.ServeHTTP(w, req)
- Expect(w.Code).To(Equal(http.StatusOK))
- var resp configResponse
- Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
-
- // Check LastFM.ApiKey - should be preserved because it's sensitive
- lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
- Expect(ok).To(BeTrue())
- Expect(lastfm["ApiKey"]).To(Equal(""))
-
- // Empty sensitive values should remain empty - should be preserved because it's sensitive
- Expect(resp.Config["PasswordEncryptionKey"]).To(Equal(""))
+ Expect(w.Code).To(Equal(http.StatusUnauthorized))
+ })
})
})
})
@@ -145,3 +207,21 @@ var _ = Describe("redactValue function", func() {
Expect(redactValue("LastFM.ApiKey", "abcdefg")).To(Equal("a*****g"))
})
})
+
+// Helper functions
+
+func createAuthenticatedConfigRequest(token string) *http.Request {
+ req := httptest.NewRequest(http.MethodGet, "/config/config", nil)
+ req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ return req
+}
+
+func createUnauthenticatedConfigRequest(method, path string, body *bytes.Buffer) *http.Request {
+ if body == nil {
+ body = &bytes.Buffer{}
+ }
+ req := httptest.NewRequest(method, path, body)
+ req.Header.Set("Content-Type", "application/json")
+ return req
+}
diff --git a/server/nativeapi/inspect.go b/server/nativeapi/inspect.go
index e74dc99c0..3178395ce 100644
--- a/server/nativeapi/inspect.go
+++ b/server/nativeapi/inspect.go
@@ -9,7 +9,6 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/req"
)
@@ -30,11 +29,6 @@ func inspect(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- user, _ := request.UserFrom(ctx)
- if !user.IsAdmin {
- http.Error(w, "Inspect is only available to admin users", http.StatusUnauthorized)
- }
-
p := req.Params(r)
id, err := p.String("id")
diff --git a/server/nativeapi/library.go b/server/nativeapi/library.go
new file mode 100644
index 000000000..1636e1dbb
--- /dev/null
+++ b/server/nativeapi/library.go
@@ -0,0 +1,101 @@
+package nativeapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/navidrome/navidrome/core"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+)
+
+// User-library association endpoints (admin only)
+func (api *Router) addUserLibraryRoute(r chi.Router) {
+ r.Route("/user/{id}/library", func(r chi.Router) {
+ r.Use(parseUserIDMiddleware)
+ r.Get("/", getUserLibraries(api.libs))
+ r.Put("/", setUserLibraries(api.libs))
+ })
+}
+
+// Middleware to parse user ID from URL
+func parseUserIDMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ userID := chi.URLParam(r, "id")
+ if userID == "" {
+ http.Error(w, "Invalid user ID", http.StatusBadRequest)
+ return
+ }
+ ctx := context.WithValue(r.Context(), "userID", userID)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// User-library association handlers
+
+func getUserLibraries(service core.Library) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ userID := r.Context().Value("userID").(string)
+
+ libraries, err := service.GetUserLibraries(r.Context(), userID)
+ if err != nil {
+ if errors.Is(err, model.ErrNotFound) {
+ http.Error(w, "User not found", http.StatusNotFound)
+ return
+ }
+ log.Error(r.Context(), "Error getting user libraries", "userID", userID, err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(libraries); err != nil {
+ log.Error(r.Context(), "Error encoding user libraries response", err)
+ }
+ }
+}
+
+func setUserLibraries(service core.Library) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ userID := r.Context().Value("userID").(string)
+
+ var request struct {
+ LibraryIDs []int `json:"libraryIds"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+ log.Error(r.Context(), "Error decoding request", err)
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if err := service.SetUserLibraries(r.Context(), userID, request.LibraryIDs); err != nil {
+ log.Error(r.Context(), "Error setting user libraries", "userID", userID, err)
+ if errors.Is(err, model.ErrNotFound) {
+ http.Error(w, "User not found", http.StatusNotFound)
+ return
+ }
+ if errors.Is(err, model.ErrValidation) {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ http.Error(w, "Failed to set user libraries", http.StatusInternalServerError)
+ return
+ }
+
+ // Return updated user libraries
+ libraries, err := service.GetUserLibraries(r.Context(), userID)
+ if err != nil {
+ log.Error(r.Context(), "Error getting updated user libraries", "userID", userID, err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(libraries); err != nil {
+ log.Error(r.Context(), "Error encoding user libraries response", err)
+ }
+ }
+}
diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go
new file mode 100644
index 000000000..950338492
--- /dev/null
+++ b/server/nativeapi/library_test.go
@@ -0,0 +1,424 @@
+package nativeapi
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/core"
+ "github.com/navidrome/navidrome/core/auth"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/server"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Library API", func() {
+ var ds model.DataStore
+ var router http.Handler
+ var adminUser, regularUser model.User
+ var library1, library2 model.Library
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ ds = &tests.MockDataStore{}
+ auth.Init(ds)
+ nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
+ router = server.JWTVerifier(nativeRouter)
+
+ // Create test users
+ adminUser = model.User{
+ ID: "admin-1",
+ UserName: "admin",
+ Name: "Admin User",
+ IsAdmin: true,
+ NewPassword: "adminpass",
+ }
+ regularUser = model.User{
+ ID: "user-1",
+ UserName: "regular",
+ Name: "Regular User",
+ IsAdmin: false,
+ NewPassword: "userpass",
+ }
+
+ // Create test libraries
+ library1 = model.Library{
+ ID: 1,
+ Name: "Test Library 1",
+ Path: "/music/library1",
+ }
+ library2 = model.Library{
+ ID: 2,
+ Name: "Test Library 2",
+ Path: "/music/library2",
+ }
+
+ // Store in mock datastore
+ Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
+ Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed())
+ Expect(ds.Library(context.TODO()).Put(&library1)).To(Succeed())
+ Expect(ds.Library(context.TODO()).Put(&library2)).To(Succeed())
+ })
+
+ Describe("Library CRUD Operations", func() {
+ Context("as admin user", func() {
+ var adminToken string
+
+ BeforeEach(func() {
+ var err error
+ adminToken, err = auth.CreateToken(&adminUser)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ Describe("GET /api/library", func() {
+ It("returns all libraries", func() {
+ req := createAuthenticatedRequest("GET", "/library", nil, adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+
+ var libraries []model.Library
+ err := json.Unmarshal(w.Body.Bytes(), &libraries)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libraries).To(HaveLen(2))
+ Expect(libraries[0].Name).To(Equal("Test Library 1"))
+ Expect(libraries[1].Name).To(Equal("Test Library 2"))
+ })
+ })
+
+ Describe("GET /api/library/{id}", func() {
+ It("returns a specific library", func() {
+ req := createAuthenticatedRequest("GET", "/library/1", nil, adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+
+ var library model.Library
+ err := json.Unmarshal(w.Body.Bytes(), &library)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(library.Name).To(Equal("Test Library 1"))
+ Expect(library.Path).To(Equal("/music/library1"))
+ })
+
+ It("returns 404 for non-existent library", func() {
+ req := createAuthenticatedRequest("GET", "/library/999", nil, adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusNotFound))
+ })
+
+ It("returns 400 for invalid library ID", func() {
+ req := createAuthenticatedRequest("GET", "/library/invalid", nil, adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusNotFound))
+ })
+ })
+
+ Describe("POST /api/library", func() {
+ It("creates a new library", func() {
+ newLibrary := model.Library{
+ Name: "New Library",
+ Path: "/music/new",
+ }
+ body, _ := json.Marshal(newLibrary)
+ req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+ })
+
+ It("validates required fields", func() {
+ invalidLibrary := model.Library{
+ Name: "", // Missing name
+ Path: "/music/invalid",
+ }
+ body, _ := json.Marshal(invalidLibrary)
+ req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusBadRequest))
+ Expect(w.Body.String()).To(ContainSubstring("library name is required"))
+ })
+
+ It("validates path field", func() {
+ invalidLibrary := model.Library{
+ Name: "Valid Name",
+ Path: "", // Missing path
+ }
+ body, _ := json.Marshal(invalidLibrary)
+ req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusBadRequest))
+ Expect(w.Body.String()).To(ContainSubstring("library path is required"))
+ })
+ })
+
+ Describe("PUT /api/library/{id}", func() {
+ It("updates an existing library", func() {
+ updatedLibrary := model.Library{
+ Name: "Updated Library 1",
+ Path: "/music/updated",
+ }
+ body, _ := json.Marshal(updatedLibrary)
+ req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+
+ var updated model.Library
+ err := json.Unmarshal(w.Body.Bytes(), &updated)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updated.ID).To(Equal(1))
+ Expect(updated.Name).To(Equal("Updated Library 1"))
+ Expect(updated.Path).To(Equal("/music/updated"))
+ })
+
+ It("validates required fields on update", func() {
+ invalidLibrary := model.Library{
+ Name: "",
+ Path: "/music/path",
+ }
+ body, _ := json.Marshal(invalidLibrary)
+ req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusBadRequest))
+ })
+ })
+
+ Describe("DELETE /api/library/{id}", func() {
+ It("deletes an existing library", func() {
+ req := createAuthenticatedRequest("DELETE", "/library/1", nil, adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+ })
+
+ It("returns 404 for non-existent library", func() {
+ req := createAuthenticatedRequest("DELETE", "/library/999", nil, adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusNotFound))
+ })
+ })
+ })
+
+ Context("as regular user", func() {
+ var userToken string
+
+ BeforeEach(func() {
+ var err error
+ userToken, err = auth.CreateToken(®ularUser)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("denies access to library management endpoints", func() {
+ endpoints := []string{
+ "GET /library",
+ "POST /library",
+ "GET /library/1",
+ "PUT /library/1",
+ "DELETE /library/1",
+ }
+
+ for _, endpoint := range endpoints {
+ parts := strings.Split(endpoint, " ")
+ method, path := parts[0], parts[1]
+
+ req := createAuthenticatedRequest(method, path, nil, userToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusForbidden))
+ }
+ })
+ })
+
+ Context("without authentication", func() {
+ It("denies access to library management endpoints", func() {
+ req := createUnauthenticatedRequest("GET", "/library", nil)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusUnauthorized))
+ })
+ })
+ })
+
+ Describe("User-Library Association Operations", func() {
+ Context("as admin user", func() {
+ var adminToken string
+
+ BeforeEach(func() {
+ var err error
+ adminToken, err = auth.CreateToken(&adminUser)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ Describe("GET /api/user/{id}/library", func() {
+ It("returns user's libraries", func() {
+ // Set up user libraries
+ err := ds.User(context.TODO()).SetUserLibraries(regularUser.ID, []int{1, 2})
+ Expect(err).ToNot(HaveOccurred())
+
+ req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+
+ var libraries []model.Library
+ err = json.Unmarshal(w.Body.Bytes(), &libraries)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libraries).To(HaveLen(2))
+ })
+
+ It("returns 404 for non-existent user", func() {
+ req := createAuthenticatedRequest("GET", "/user/non-existent/library", nil, adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusNotFound))
+ })
+ })
+
+ Describe("PUT /api/user/{id}/library", func() {
+ It("sets user's libraries", func() {
+ request := map[string][]int{
+ "libraryIds": {1, 2},
+ }
+ body, _ := json.Marshal(request)
+ req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+
+ var libraries []model.Library
+ err := json.Unmarshal(w.Body.Bytes(), &libraries)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libraries).To(HaveLen(2))
+ })
+
+ It("validates library IDs exist", func() {
+ request := map[string][]int{
+ "libraryIds": {999}, // Non-existent library
+ }
+ body, _ := json.Marshal(request)
+ req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusBadRequest))
+ Expect(w.Body.String()).To(ContainSubstring("library ID 999 does not exist"))
+ })
+
+ It("requires at least one library for regular users", func() {
+ request := map[string][]int{
+ "libraryIds": {}, // Empty libraries
+ }
+ body, _ := json.Marshal(request)
+ req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusBadRequest))
+ Expect(w.Body.String()).To(ContainSubstring("at least one library must be assigned"))
+ })
+
+ It("prevents manual assignment to admin users", func() {
+ request := map[string][]int{
+ "libraryIds": {1},
+ }
+ body, _ := json.Marshal(request)
+ req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", adminUser.ID), bytes.NewBuffer(body), adminToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusBadRequest))
+ Expect(w.Body.String()).To(ContainSubstring("cannot manually assign libraries to admin users"))
+ })
+ })
+ })
+
+ Context("as regular user", func() {
+ var userToken string
+
+ BeforeEach(func() {
+ var err error
+ userToken, err = auth.CreateToken(®ularUser)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("denies access to user-library association endpoints", func() {
+ req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, userToken)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ Expect(w.Code).To(Equal(http.StatusForbidden))
+ })
+ })
+ })
+})
+
+// Helper functions
+
+func createAuthenticatedRequest(method, path string, body *bytes.Buffer, token string) *http.Request {
+ if body == nil {
+ body = &bytes.Buffer{}
+ }
+ req := httptest.NewRequest(method, path, body)
+ req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ return req
+}
+
+func createUnauthenticatedRequest(method, path string, body *bytes.Buffer) *http.Request {
+ if body == nil {
+ body = &bytes.Buffer{}
+ }
+ req := httptest.NewRequest(method, path, body)
+ req.Header.Set("Content-Type", "application/json")
+ return req
+}
diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go
index 0d311f492..2b455e622 100644
--- a/server/nativeapi/missing.go
+++ b/server/nativeapi/missing.go
@@ -8,9 +8,9 @@ import (
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
+ "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/req"
)
@@ -63,45 +63,32 @@ func (r *missingRepository) EntityName() string {
return "missing_files"
}
-func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) {
- ctx := r.Context()
- p := req.Params(r)
- ids, _ := p.Strings("id")
- err := ds.WithTx(func(tx model.DataStore) error {
+func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ p := req.Params(r)
+ ids, _ := p.Strings("id")
+
+ var err error
if len(ids) == 0 {
- _, err := tx.MediaFile(ctx).DeleteAllMissing()
- return err
- }
- return tx.MediaFile(ctx).DeleteMissing(ids)
- })
- if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
- log.Warn(ctx, "Missing file not found", "id", ids[0])
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- if err != nil {
- log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- err = ds.GC(ctx)
- if err != nil {
- log.Error(ctx, "Error running GC after deleting missing tracks", err)
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
-
- // Refresh artist stats in background after deleting missing files
- go func() {
- bgCtx := request.AddValues(context.Background(), r.Context())
- if _, err := ds.Artist(bgCtx).RefreshStats(true); err != nil {
- log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
+ err = maintenance.DeleteAllMissingFiles(ctx)
} else {
- log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
+ err = maintenance.DeleteMissingFiles(ctx, ids)
}
- }()
- writeDeleteManyResponse(w, r, ids)
+ if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
+ log.Warn(ctx, "Missing file not found", "id", ids[0])
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ if err != nil {
+ http.Error(w, "failed to delete missing files", http.StatusInternalServerError)
+ return
+ }
+
+ writeDeleteManyResponse(w, r, ids)
+ }
}
var _ model.ResourceRepository = &missingRepository{}
diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go
index aed24e963..969650e0a 100644
--- a/server/nativeapi/native_api.go
+++ b/server/nativeapi/native_api.go
@@ -16,69 +16,77 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
)
type Router struct {
http.Handler
- ds model.DataStore
- share core.Share
- playlists core.Playlists
- insights metrics.Insights
+ ds model.DataStore
+ share core.Share
+ playlists core.Playlists
+ insights metrics.Insights
+ libs core.Library
+ maintenance core.Maintenance
}
-func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights) *Router {
- r := &Router{ds: ds, share: share, playlists: playlists, insights: insights}
+func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, maintenance core.Maintenance) *Router {
+ r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, maintenance: maintenance}
r.Handler = r.routes()
return r
}
-func (n *Router) routes() http.Handler {
+func (api *Router) routes() http.Handler {
r := chi.NewRouter()
// Public
- n.RX(r, "/translation", newTranslationRepository, false)
+ api.RX(r, "/translation", newTranslationRepository, false)
// Protected
r.Group(func(r chi.Router) {
- r.Use(server.Authenticator(n.ds))
+ r.Use(server.Authenticator(api.ds))
r.Use(server.JWTRefresher)
- r.Use(server.UpdateLastAccessMiddleware(n.ds))
- n.R(r, "/user", model.User{}, true)
- n.R(r, "/song", model.MediaFile{}, false)
- n.R(r, "/album", model.Album{}, false)
- n.R(r, "/artist", model.Artist{}, false)
- n.R(r, "/genre", model.Genre{}, false)
- n.R(r, "/player", model.Player{}, true)
- n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
- n.R(r, "/radio", model.Radio{}, true)
- n.R(r, "/tag", model.Tag{}, true)
+ r.Use(server.UpdateLastAccessMiddleware(api.ds))
+ api.R(r, "/user", model.User{}, true)
+ api.R(r, "/song", model.MediaFile{}, false)
+ api.R(r, "/album", model.Album{}, false)
+ api.R(r, "/artist", model.Artist{}, false)
+ api.R(r, "/genre", model.Genre{}, false)
+ api.R(r, "/player", model.Player{}, true)
+ api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
+ api.R(r, "/radio", model.Radio{}, true)
+ api.R(r, "/tag", model.Tag{}, true)
if conf.Server.EnableSharing {
- n.RX(r, "/share", n.share.NewRepository, true)
+ api.RX(r, "/share", api.share.NewRepository, true)
}
- n.addPlaylistRoute(r)
- n.addPlaylistTrackRoute(r)
- n.addSongPlaylistsRoute(r)
- n.addQueueRoute(r)
- n.addMissingFilesRoute(r)
- n.addInspectRoute(r)
- n.addConfigRoute(r)
- n.addKeepAliveRoute(r)
- n.addInsightsRoute(r)
+ api.addPlaylistRoute(r)
+ api.addPlaylistTrackRoute(r)
+ api.addSongPlaylistsRoute(r)
+ api.addQueueRoute(r)
+ api.addMissingFilesRoute(r)
+ api.addKeepAliveRoute(r)
+ api.addInsightsRoute(r)
+
+ r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
+ api.addInspectRoute(r)
+ api.addConfigRoute(r)
+ api.addUserLibraryRoute(r)
+ api.RX(r, "/library", api.libs.NewRepository, true)
+ })
})
return r
}
-func (n *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
+func (api *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
constructor := func(ctx context.Context) rest.Repository {
- return n.ds.Resource(ctx, model)
+ return api.ds.Resource(ctx, model)
}
- n.RX(r, pathPrefix, constructor, persistable)
+ api.RX(r, pathPrefix, constructor, persistable)
}
-func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
+func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
r.Route(pathPrefix, func(r chi.Router) {
r.Get("/", rest.GetAll(constructor))
if persistable {
@@ -95,9 +103,9 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository
})
}
-func (n *Router) addPlaylistRoute(r chi.Router) {
+func (api *Router) addPlaylistRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
- return n.ds.Resource(ctx, model.Playlist{})
+ return api.ds.Resource(ctx, model.Playlist{})
}
r.Route("/playlist", func(r chi.Router) {
@@ -107,7 +115,7 @@ func (n *Router) addPlaylistRoute(r chi.Router) {
rest.Post(constructor)(w, r)
return
}
- createPlaylistFromM3U(n.playlists)(w, r)
+ createPlaylistFromM3U(api.playlists)(w, r)
})
r.Route("/{id}", func(r chi.Router) {
@@ -119,55 +127,53 @@ func (n *Router) addPlaylistRoute(r chi.Router) {
})
}
-func (n *Router) addPlaylistTrackRoute(r chi.Router) {
+func (api *Router) addPlaylistTrackRoute(r chi.Router) {
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
- getPlaylist(n.ds)(w, r)
+ getPlaylist(api.ds)(w, r)
})
r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) {
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
- deleteFromPlaylist(n.ds)(w, r)
+ deleteFromPlaylist(api.ds)(w, r)
})
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
- addToPlaylist(n.ds)(w, r)
+ addToPlaylist(api.ds)(w, r)
})
})
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
- getPlaylistTrack(n.ds)(w, r)
+ getPlaylistTrack(api.ds)(w, r)
})
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
- reorderItem(n.ds)(w, r)
+ reorderItem(api.ds)(w, r)
})
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
- deleteFromPlaylist(n.ds)(w, r)
+ deleteFromPlaylist(api.ds)(w, r)
})
})
})
}
-func (n *Router) addSongPlaylistsRoute(r chi.Router) {
+func (api *Router) addSongPlaylistsRoute(r chi.Router) {
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
- getSongPlaylists(n.ds)(w, r)
+ getSongPlaylists(api.ds)(w, r)
})
}
-func (n *Router) addQueueRoute(r chi.Router) {
+func (api *Router) addQueueRoute(r chi.Router) {
r.Route("/queue", func(r chi.Router) {
- r.Get("/", getQueue(n.ds))
- r.Post("/", saveQueue(n.ds))
- r.Put("/", updateQueue(n.ds))
- r.Delete("/", clearQueue(n.ds))
+ r.Get("/", getQueue(api.ds))
+ r.Post("/", saveQueue(api.ds))
+ r.Put("/", updateQueue(api.ds))
+ r.Delete("/", clearQueue(api.ds))
})
}
-func (n *Router) addMissingFilesRoute(r chi.Router) {
+func (api *Router) addMissingFilesRoute(r chi.Router) {
r.Route("/missing", func(r chi.Router) {
- n.RX(r, "/", newMissingRepository(n.ds), false)
- r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
- deleteMissingFiles(n.ds, w, r)
- })
+ api.RX(r, "/", newMissingRepository(api.ds), false)
+ r.Delete("/", deleteMissingFiles(api.maintenance))
})
}
@@ -191,7 +197,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
}
}
-func (n *Router) addInspectRoute(r chi.Router) {
+func (api *Router) addInspectRoute(r chi.Router) {
if conf.Server.Inspect.Enabled {
r.Group(func(r chi.Router) {
if conf.Server.Inspect.MaxRequests > 0 {
@@ -200,26 +206,26 @@ func (n *Router) addInspectRoute(r chi.Router) {
conf.Server.Inspect.BacklogTimeout)
r.Use(middleware.ThrottleBacklog(conf.Server.Inspect.MaxRequests, conf.Server.Inspect.BacklogLimit, time.Duration(conf.Server.Inspect.BacklogTimeout)))
}
- r.Get("/inspect", inspect(n.ds))
+ r.Get("/inspect", inspect(api.ds))
})
}
}
-func (n *Router) addConfigRoute(r chi.Router) {
+func (api *Router) addConfigRoute(r chi.Router) {
if conf.Server.DevUIShowConfig {
r.Get("/config/*", getConfig)
}
}
-func (n *Router) addKeepAliveRoute(r chi.Router) {
+func (api *Router) addKeepAliveRoute(r chi.Router) {
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
})
}
-func (n *Router) addInsightsRoute(r chi.Router) {
+func (api *Router) addInsightsRoute(r chi.Router) {
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
- last, success := n.insights.LastRun(r.Context())
+ last, success := api.insights.LastRun(r.Context())
if conf.Server.EnableInsightsCollector {
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
} else {
@@ -227,3 +233,15 @@ func (n *Router) addInsightsRoute(r chi.Router) {
}
})
}
+
+// Middleware to ensure only admin users can access endpoints
+func adminOnlyMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ user, ok := request.UserFrom(r.Context())
+ if !ok || !user.IsAdmin {
+ http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go
index 0b183c1d9..b52042643 100644
--- a/server/nativeapi/native_api_song_test.go
+++ b/server/nativeapi/native_api_song_test.go
@@ -2,20 +2,17 @@ package nativeapi
import (
"bytes"
- "context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"time"
- "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
- "github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
@@ -23,31 +20,6 @@ import (
. "github.com/onsi/gomega"
)
-// Simple mock implementations for missing types
-type mockShare struct {
- core.Share
-}
-
-func (m *mockShare) NewRepository(ctx context.Context) rest.Repository {
- return &tests.MockShareRepo{}
-}
-
-type mockPlaylists struct {
- core.Playlists
-}
-
-func (m *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
- return &model.Playlist{}, nil
-}
-
-type mockInsights struct {
- metrics.Insights
-}
-
-func (m *mockInsights) LastRun(ctx context.Context) (time.Time, bool) {
- return time.Now(), true
-}
-
var _ = Describe("Song Endpoints", func() {
var (
router http.Handler
@@ -122,13 +94,8 @@ var _ = Describe("Song Endpoints", func() {
}
mfRepo.SetData(testSongs)
- // Setup router with mocked dependencies
- mockShareImpl := &mockShare{}
- mockPlaylistsImpl := &mockPlaylists{}
- mockInsightsImpl := &mockInsights{}
-
// Create the native API router and wrap it with the JWTVerifier middleware
- nativeRouter := New(ds, mockShareImpl, mockPlaylistsImpl, mockInsightsImpl)
+ nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})
diff --git a/server/server.go b/server/server.go
index 49391e2b6..39475a225 100644
--- a/server/server.go
+++ b/server/server.go
@@ -1,8 +1,11 @@
package server
import (
+ "bytes"
"cmp"
"context"
+ "crypto/tls"
+ "encoding/pem"
"errors"
"fmt"
"net"
@@ -69,6 +72,13 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
// Determine if TLS is enabled
tlsEnabled := tlsCert != "" && tlsKey != ""
+ // Validate TLS certificates before starting the server
+ if tlsEnabled {
+ if err := validateTLSCertificates(tlsCert, tlsKey); err != nil {
+ return err
+ }
+ }
+
// Create a listener based on the address type (either Unix socket or TCP)
var listener net.Listener
var err error
@@ -89,17 +99,17 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
// Start the server in a new goroutine and send an error signal to errC if there's an error
errC := make(chan error)
go func() {
+ var err error
if tlsEnabled {
// Start the HTTPS server
log.Info("Starting server with TLS (HTTPS) enabled", "tlsCert", tlsCert, "tlsKey", tlsKey)
- if err := server.ServeTLS(listener, tlsCert, tlsKey); !errors.Is(err, http.ErrServerClosed) {
- errC <- err
- }
+ err = server.ServeTLS(listener, tlsCert, tlsKey)
} else {
// Start the HTTP server
- if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
- errC <- err
- }
+ err = server.Serve(listener)
+ }
+ if !errors.Is(err, http.ErrServerClosed) {
+ errC <- err
}
}()
@@ -249,3 +259,56 @@ func AbsoluteURL(r *http.Request, u string, params url.Values) string {
}
return buildUrl.String()
}
+
+// validateTLSCertificates validates the TLS certificate and key files before starting the server.
+// It provides detailed error messages for common issues like encrypted private keys.
+func validateTLSCertificates(certFile, keyFile string) error {
+ // Read the key file to check for encryption
+ keyData, err := os.ReadFile(keyFile)
+ if err != nil {
+ return fmt.Errorf("reading TLS key file: %w", err)
+ }
+
+ // Parse PEM blocks and check for encryption
+ block, _ := pem.Decode(keyData)
+ if block == nil {
+ return errors.New("TLS key file does not contain a valid PEM block")
+ }
+
+ // Check for encrypted private key indicators
+ if isEncryptedPEM(block, keyData) {
+ return errors.New("TLS private key is encrypted (password-protected). " +
+ "Navidrome does not support encrypted private keys. " +
+ "Please decrypt your key using: openssl pkey -in -out ")
+ }
+
+ // Try to load the certificate pair to validate it
+ _, err = tls.LoadX509KeyPair(certFile, keyFile)
+ if err != nil {
+ return fmt.Errorf("loading TLS certificate/key pair: %w", err)
+ }
+
+ return nil
+}
+
+// isEncryptedPEM checks if a PEM block represents an encrypted private key.
+func isEncryptedPEM(block *pem.Block, rawData []byte) bool {
+ // Check for PKCS#8 encrypted format (BEGIN ENCRYPTED PRIVATE KEY)
+ if block.Type == "ENCRYPTED PRIVATE KEY" {
+ return true
+ }
+
+ // Check for legacy encrypted format with Proc-Type header
+ if block.Headers != nil {
+ if procType, ok := block.Headers["Proc-Type"]; ok && strings.Contains(procType, "ENCRYPTED") {
+ return true
+ }
+ }
+
+ // Also check raw data for DEK-Info header (in case pem.Decode doesn't parse headers correctly)
+ if bytes.Contains(rawData, []byte("DEK-Info:")) || bytes.Contains(rawData, []byte("Proc-Type: 4,ENCRYPTED")) {
+ return true
+ }
+
+ return false
+}
diff --git a/server/server_test.go b/server/server_test.go
index f9a43a802..5ca03bf7e 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -1,13 +1,20 @@
package server
import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "fmt"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
+ "time"
"github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -107,3 +114,146 @@ var _ = Describe("createUnixSocketFile", func() {
})
})
})
+
+var _ = Describe("TLS support", func() {
+ Describe("validateTLSCertificates", func() {
+ const testDataDir = "server/testdata"
+
+ When("certificate and key are valid and unencrypted", func() {
+ It("returns nil", func() {
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ keyFile := filepath.Join(testDataDir, "test_key.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ When("private key is encrypted with PKCS#8 format", func() {
+ It("returns an error with helpful message", func() {
+ certFile := filepath.Join(testDataDir, "test_cert_encrypted.pem")
+ keyFile := filepath.Join(testDataDir, "test_key_encrypted.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("encrypted"))
+ Expect(err.Error()).To(ContainSubstring("openssl"))
+ })
+ })
+
+ When("private key is encrypted with legacy format (Proc-Type header)", func() {
+ It("returns an error with helpful message", func() {
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ keyFile := filepath.Join(testDataDir, "test_key_encrypted_legacy.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("encrypted"))
+ Expect(err.Error()).To(ContainSubstring("openssl"))
+ })
+ })
+
+ When("key file does not exist", func() {
+ It("returns an error", func() {
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ keyFile := filepath.Join(testDataDir, "nonexistent.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("reading TLS key file"))
+ })
+ })
+
+ When("key file does not contain valid PEM", func() {
+ It("returns an error", func() {
+ // Create a temp file with invalid PEM content
+ tmpFile, err := os.CreateTemp("", "invalid_key*.pem")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() {
+ _ = os.Remove(tmpFile.Name())
+ })
+ _, err = tmpFile.WriteString("not a valid PEM file")
+ Expect(err).ToNot(HaveOccurred())
+ _ = tmpFile.Close()
+
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ err = validateTLSCertificates(certFile, tmpFile.Name())
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("valid PEM block"))
+ })
+ })
+
+ When("certificate file does not exist", func() {
+ It("returns an error from tls.LoadX509KeyPair", func() {
+ certFile := filepath.Join(testDataDir, "nonexistent_cert.pem")
+ keyFile := filepath.Join(testDataDir, "test_key.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("loading TLS certificate/key pair"))
+ })
+ })
+ })
+
+ Describe("Server TLS", func() {
+ const testDataDir = "server/testdata"
+
+ When("server is started with valid TLS certificates", func() {
+ It("accepts HTTPS connections", func() {
+ DeferCleanup(configtest.SetupConfig())
+
+ // Create server with mock dependencies
+ ds := &tests.MockDataStore{}
+ server := New(ds, nil, nil)
+
+ // Load the test certificate to create a trusted CA pool
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ keyFile := filepath.Join(testDataDir, "test_key.pem")
+ caCert, err := os.ReadFile(certFile)
+ Expect(err).ToNot(HaveOccurred())
+
+ caCertPool := x509.NewCertPool()
+ caCertPool.AppendCertsFromPEM(caCert)
+
+ // Create an HTTPS client that trusts our test certificate
+ httpClient := &http.Client{
+ Timeout: 5 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ RootCAs: caCertPool,
+ MinVersion: tls.VersionTLS12,
+ },
+ },
+ }
+
+ // Start the server in a goroutine
+ ctx, cancel := context.WithCancel(GinkgoT().Context())
+ defer cancel()
+
+ errChan := make(chan error, 1)
+ go func() {
+ errChan <- server.Run(ctx, "127.0.0.1", 14534, certFile, keyFile)
+ }()
+
+ Eventually(func() error {
+ // Make an HTTPS request to the server
+ resp, err := httpClient.Get("https://127.0.0.1:14534/ping")
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+ return nil
+ }, 2*time.Second, 100*time.Millisecond).Should(Succeed())
+
+ // Stop the server
+ cancel()
+
+ // Wait for server to stop (with timeout)
+ select {
+ case <-errChan:
+ // Server stopped
+ case <-time.After(2 * time.Second):
+ Fail("Server did not stop in time")
+ }
+ })
+ })
+ })
+})
diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go
index 39a164500..56cf469c5 100644
--- a/server/subsonic/album_lists.go
+++ b/server/subsonic/album_lists.go
@@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/server/subsonic/filter"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
+ "github.com/navidrome/navidrome/utils/run"
"github.com/navidrome/navidrome/utils/slice"
)
@@ -61,6 +62,13 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) {
return nil, 0, newError(responses.ErrorGeneric, "type '%s' not implemented", typ)
}
+ // Get optional library IDs from musicFolderId parameter
+ musicFolderIds, err := selectedMusicFolderIds(r, false)
+ if err != nil {
+ return nil, 0, err
+ }
+ opts = filter.ApplyLibraryFilter(opts, musicFolderIds)
+
opts.Offset = p.IntOr("offset", 0)
opts.Max = min(p.IntOr("size", 10), 500)
albums, err := api.ds.Album(r.Context()).GetAll(opts)
@@ -109,57 +117,87 @@ func (api *Router) GetAlbumList2(w http.ResponseWriter, r *http.Request) (*respo
return response, nil
}
-func (api *Router) GetStarred(r *http.Request) (*responses.Subsonic, error) {
+func (api *Router) getStarredItems(r *http.Request) (model.Artists, model.Albums, model.MediaFiles, error) {
ctx := r.Context()
- artists, err := api.ds.Artist(ctx).GetAll(filter.ArtistsByStarred())
+
+ // Get optional library IDs from musicFolderId parameter
+ musicFolderIds, err := selectedMusicFolderIds(r, false)
if err != nil {
- log.Error(r, "Error retrieving starred artists", err)
- return nil, err
+ return nil, nil, nil, err
}
- options := filter.ByStarred()
- albums, err := api.ds.Album(ctx).GetAll(options)
+
+ // Prepare variables to capture results from parallel execution
+ var artists model.Artists
+ var albums model.Albums
+ var mediaFiles model.MediaFiles
+
+ // Execute all three queries in parallel for better performance
+ err = run.Parallel(
+ // Query starred artists
+ func() error {
+ artistOpts := filter.ApplyArtistLibraryFilter(filter.ArtistsByStarred(), musicFolderIds)
+ var err error
+ artists, err = api.ds.Artist(ctx).GetAll(artistOpts)
+ if err != nil {
+ log.Error(r, "Error retrieving starred artists", err)
+ }
+ return err
+ },
+ // Query starred albums
+ func() error {
+ albumOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds)
+ var err error
+ albums, err = api.ds.Album(ctx).GetAll(albumOpts)
+ if err != nil {
+ log.Error(r, "Error retrieving starred albums", err)
+ }
+ return err
+ },
+ // Query starred media files
+ func() error {
+ mediaFileOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds)
+ var err error
+ mediaFiles, err = api.ds.MediaFile(ctx).GetAll(mediaFileOpts)
+ if err != nil {
+ log.Error(r, "Error retrieving starred mediaFiles", err)
+ }
+ return err
+ },
+ )()
+
+ // Return the first error if any occurred
if err != nil {
- log.Error(r, "Error retrieving starred albums", err)
- return nil, err
+ return nil, nil, nil, err
}
- mediaFiles, err := api.ds.MediaFile(ctx).GetAll(options)
+
+ return artists, albums, mediaFiles, nil
+}
+
+func (api *Router) GetStarred(r *http.Request) (*responses.Subsonic, error) {
+ artists, albums, mediaFiles, err := api.getStarredItems(r)
if err != nil {
- log.Error(r, "Error retrieving starred mediaFiles", err)
return nil, err
}
response := newResponse()
response.Starred = &responses.Starred{}
response.Starred.Artist = slice.MapWithArg(artists, r, toArtist)
- response.Starred.Album = slice.MapWithArg(albums, ctx, childFromAlbum)
- response.Starred.Song = slice.MapWithArg(mediaFiles, ctx, childFromMediaFile)
+ response.Starred.Album = slice.MapWithArg(albums, r.Context(), childFromAlbum)
+ response.Starred.Song = slice.MapWithArg(mediaFiles, r.Context(), childFromMediaFile)
return response, nil
}
func (api *Router) GetStarred2(r *http.Request) (*responses.Subsonic, error) {
- ctx := r.Context()
- artists, err := api.ds.Artist(ctx).GetAll(filter.ArtistsByStarred())
+ artists, albums, mediaFiles, err := api.getStarredItems(r)
if err != nil {
- log.Error(r, "Error retrieving starred artists", err)
- return nil, err
- }
- options := filter.ByStarred()
- albums, err := api.ds.Album(ctx).GetAll(options)
- if err != nil {
- log.Error(r, "Error retrieving starred albums", err)
- return nil, err
- }
- mediaFiles, err := api.ds.MediaFile(ctx).GetAll(options)
- if err != nil {
- log.Error(r, "Error retrieving starred mediaFiles", err)
return nil, err
}
response := newResponse()
response.Starred2 = &responses.Starred2{}
response.Starred2.Artist = slice.MapWithArg(artists, r, toArtistID3)
- response.Starred2.Album = slice.MapWithArg(albums, ctx, buildAlbumID3)
- response.Starred2.Song = slice.MapWithArg(mediaFiles, ctx, childFromMediaFile)
+ response.Starred2.Album = slice.MapWithArg(albums, r.Context(), buildAlbumID3)
+ response.Starred2.Song = slice.MapWithArg(mediaFiles, r.Context(), childFromMediaFile)
return response, nil
}
@@ -193,7 +231,15 @@ func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error)
fromYear := p.IntOr("fromYear", 0)
toYear := p.IntOr("toYear", 0)
- songs, err := api.getSongs(r.Context(), 0, size, filter.SongsByRandom(genre, fromYear, toYear))
+ // Get optional library IDs from musicFolderId parameter
+ musicFolderIds, err := selectedMusicFolderIds(r, false)
+ if err != nil {
+ return nil, err
+ }
+ opts := filter.SongsByRandom(genre, fromYear, toYear)
+ opts = filter.ApplyLibraryFilter(opts, musicFolderIds)
+
+ songs, err := api.getSongs(r.Context(), 0, size, opts)
if err != nil {
log.Error(r, "Error retrieving random songs", err)
return nil, err
@@ -211,8 +257,16 @@ func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error)
offset := p.IntOr("offset", 0)
genre, _ := p.String("genre")
+ // Get optional library IDs from musicFolderId parameter
+ musicFolderIds, err := selectedMusicFolderIds(r, false)
+ if err != nil {
+ return nil, err
+ }
+ opts := filter.ByGenre(genre)
+ opts = filter.ApplyLibraryFilter(opts, musicFolderIds)
+
ctx := r.Context()
- songs, err := api.getSongs(ctx, offset, count, filter.ByGenre(genre))
+ songs, err := api.getSongs(ctx, offset, count, opts)
if err != nil {
log.Error(r, "Error retrieving random songs", err)
return nil, err
diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go
index ffd1803c6..63c2614cd 100644
--- a/server/subsonic/album_lists_test.go
+++ b/server/subsonic/album_lists_test.go
@@ -5,12 +5,13 @@ import (
"errors"
"net/http/httptest"
- "github.com/navidrome/navidrome/server/subsonic/responses"
- "github.com/navidrome/navidrome/utils/req"
-
+ "github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
+ "github.com/navidrome/navidrome/utils/req"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -24,6 +25,7 @@ var _ = Describe("Album Lists", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
+ auth.Init(ds)
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
@@ -63,6 +65,74 @@ var _ = Describe("Album Lists", func() {
errors.As(err, &subErr)
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
})
+
+ Context("with musicFolderId parameter", func() {
+ var user model.User
+ var ctx context.Context
+
+ BeforeEach(func() {
+ user = model.User{
+ ID: "test-user",
+ Libraries: []model.Library{
+ {ID: 1, Name: "Library 1"},
+ {ID: 2, Name: "Library 2"},
+ {ID: 3, Name: "Library 3"},
+ },
+ }
+ ctx = request.WithUser(context.Background(), user)
+ })
+
+ It("should filter albums by specific library when musicFolderId is provided", func() {
+ r := newGetRequest("type=newest", "musicFolderId=1")
+ r = r.WithContext(ctx)
+ mockRepo.SetData(model.Albums{
+ {ID: "1"}, {ID: "2"},
+ })
+
+ resp, err := router.GetAlbumList(w, r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.AlbumList.Album).To(HaveLen(2))
+ // Verify that library filter was applied
+ query, args, _ := mockRepo.Options.Filters.ToSql()
+ Expect(query).To(ContainSubstring("library_id IN (?)"))
+ Expect(args).To(ContainElement(1))
+ })
+
+ It("should filter albums by multiple libraries when multiple musicFolderId are provided", func() {
+ r := newGetRequest("type=newest", "musicFolderId=1", "musicFolderId=2")
+ r = r.WithContext(ctx)
+ mockRepo.SetData(model.Albums{
+ {ID: "1"}, {ID: "2"},
+ })
+
+ resp, err := router.GetAlbumList(w, r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.AlbumList.Album).To(HaveLen(2))
+ // Verify that library filter was applied
+ query, args, _ := mockRepo.Options.Filters.ToSql()
+ Expect(query).To(ContainSubstring("library_id IN (?,?)"))
+ Expect(args).To(ContainElements(1, 2))
+ })
+
+ It("should return all accessible albums when no musicFolderId is provided", func() {
+ r := newGetRequest("type=newest")
+ r = r.WithContext(ctx)
+ mockRepo.SetData(model.Albums{
+ {ID: "1"}, {ID: "2"},
+ })
+
+ resp, err := router.GetAlbumList(w, r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.AlbumList.Album).To(HaveLen(2))
+ // Verify that library filter was applied
+ query, args, _ := mockRepo.Options.Filters.ToSql()
+ Expect(query).To(ContainSubstring("library_id IN (?,?,?)"))
+ Expect(args).To(ContainElements(1, 2, 3))
+ })
+ })
})
Describe("GetAlbumList2", func() {
@@ -100,5 +170,373 @@ var _ = Describe("Album Lists", func() {
errors.As(err, &subErr)
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
})
+
+ Context("with musicFolderId parameter", func() {
+ var user model.User
+ var ctx context.Context
+
+ BeforeEach(func() {
+ user = model.User{
+ ID: "test-user",
+ Libraries: []model.Library{
+ {ID: 1, Name: "Library 1"},
+ {ID: 2, Name: "Library 2"},
+ {ID: 3, Name: "Library 3"},
+ },
+ }
+ ctx = request.WithUser(context.Background(), user)
+ })
+
+ It("should filter albums by specific library when musicFolderId is provided", func() {
+ r := newGetRequest("type=newest", "musicFolderId=1")
+ r = r.WithContext(ctx)
+ mockRepo.SetData(model.Albums{
+ {ID: "1"}, {ID: "2"},
+ })
+
+ resp, err := router.GetAlbumList2(w, r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.AlbumList2.Album).To(HaveLen(2))
+ // Verify that library filter was applied
+ Expect(mockRepo.Options.Filters).ToNot(BeNil())
+ })
+
+ It("should filter albums by multiple libraries when multiple musicFolderId are provided", func() {
+ r := newGetRequest("type=newest", "musicFolderId=1", "musicFolderId=2")
+ r = r.WithContext(ctx)
+ mockRepo.SetData(model.Albums{
+ {ID: "1"}, {ID: "2"},
+ })
+
+ resp, err := router.GetAlbumList2(w, r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.AlbumList2.Album).To(HaveLen(2))
+ // Verify that library filter was applied
+ Expect(mockRepo.Options.Filters).ToNot(BeNil())
+ })
+
+ It("should return all accessible albums when no musicFolderId is provided", func() {
+ r := newGetRequest("type=newest")
+ r = r.WithContext(ctx)
+ mockRepo.SetData(model.Albums{
+ {ID: "1"}, {ID: "2"},
+ })
+
+ resp, err := router.GetAlbumList2(w, r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.AlbumList2.Album).To(HaveLen(2))
+ })
+ })
+ })
+
+ Describe("GetRandomSongs", func() {
+ var mockMediaFileRepo *tests.MockMediaFileRepo
+
+ BeforeEach(func() {
+ mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
+ })
+
+ It("should return random songs", func() {
+ mockMediaFileRepo.SetData(model.MediaFiles{
+ {ID: "1", Title: "Song 1"},
+ {ID: "2", Title: "Song 2"},
+ })
+ r := newGetRequest("size=2")
+
+ resp, err := router.GetRandomSongs(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.RandomSongs.Songs).To(HaveLen(2))
+ })
+
+ Context("with musicFolderId parameter", func() {
+ var user model.User
+ var ctx context.Context
+
+ BeforeEach(func() {
+ user = model.User{
+ ID: "test-user",
+ Libraries: []model.Library{
+ {ID: 1, Name: "Library 1"},
+ {ID: 2, Name: "Library 2"},
+ {ID: 3, Name: "Library 3"},
+ },
+ }
+ ctx = request.WithUser(context.Background(), user)
+ })
+
+ It("should filter songs by specific library when musicFolderId is provided", func() {
+ mockMediaFileRepo.SetData(model.MediaFiles{
+ {ID: "1", Title: "Song 1"},
+ {ID: "2", Title: "Song 2"},
+ })
+ r := newGetRequest("size=2", "musicFolderId=1")
+ r = r.WithContext(ctx)
+
+ resp, err := router.GetRandomSongs(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.RandomSongs.Songs).To(HaveLen(2))
+ // Verify that library filter was applied
+ query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
+ Expect(query).To(ContainSubstring("library_id IN (?)"))
+ Expect(args).To(ContainElement(1))
+ })
+
+ It("should filter songs by multiple libraries when multiple musicFolderId are provided", func() {
+ mockMediaFileRepo.SetData(model.MediaFiles{
+ {ID: "1", Title: "Song 1"},
+ {ID: "2", Title: "Song 2"},
+ })
+ r := newGetRequest("size=2", "musicFolderId=1", "musicFolderId=2")
+ r = r.WithContext(ctx)
+
+ resp, err := router.GetRandomSongs(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.RandomSongs.Songs).To(HaveLen(2))
+ // Verify that library filter was applied
+ query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
+ Expect(query).To(ContainSubstring("library_id IN (?,?)"))
+ Expect(args).To(ContainElements(1, 2))
+ })
+
+ It("should return all accessible songs when no musicFolderId is provided", func() {
+ mockMediaFileRepo.SetData(model.MediaFiles{
+ {ID: "1", Title: "Song 1"},
+ {ID: "2", Title: "Song 2"},
+ })
+ r := newGetRequest("size=2")
+ r = r.WithContext(ctx)
+
+ resp, err := router.GetRandomSongs(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.RandomSongs.Songs).To(HaveLen(2))
+ // Verify that library filter was applied
+ query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
+ Expect(query).To(ContainSubstring("library_id IN (?,?,?)"))
+ Expect(args).To(ContainElements(1, 2, 3))
+ })
+ })
+ })
+
+ Describe("GetSongsByGenre", func() {
+ var mockMediaFileRepo *tests.MockMediaFileRepo
+
+ BeforeEach(func() {
+ mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
+ })
+
+ It("should return songs by genre", func() {
+ mockMediaFileRepo.SetData(model.MediaFiles{
+ {ID: "1", Title: "Song 1"},
+ {ID: "2", Title: "Song 2"},
+ })
+ r := newGetRequest("count=2", "genre=rock")
+
+ resp, err := router.GetSongsByGenre(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
+ })
+
+ Context("with musicFolderId parameter", func() {
+ var user model.User
+ var ctx context.Context
+
+ BeforeEach(func() {
+ user = model.User{
+ ID: "test-user",
+ Libraries: []model.Library{
+ {ID: 1, Name: "Library 1"},
+ {ID: 2, Name: "Library 2"},
+ {ID: 3, Name: "Library 3"},
+ },
+ }
+ ctx = request.WithUser(context.Background(), user)
+ })
+
+ It("should filter songs by specific library when musicFolderId is provided", func() {
+ mockMediaFileRepo.SetData(model.MediaFiles{
+ {ID: "1", Title: "Song 1"},
+ {ID: "2", Title: "Song 2"},
+ })
+ r := newGetRequest("count=2", "genre=rock", "musicFolderId=1")
+ r = r.WithContext(ctx)
+
+ resp, err := router.GetSongsByGenre(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
+ // Verify that library filter was applied
+ query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
+ Expect(query).To(ContainSubstring("library_id IN (?)"))
+ Expect(args).To(ContainElement(1))
+ })
+
+ It("should filter songs by multiple libraries when multiple musicFolderId are provided", func() {
+ mockMediaFileRepo.SetData(model.MediaFiles{
+ {ID: "1", Title: "Song 1"},
+ {ID: "2", Title: "Song 2"},
+ })
+ r := newGetRequest("count=2", "genre=rock", "musicFolderId=1", "musicFolderId=2")
+ r = r.WithContext(ctx)
+
+ resp, err := router.GetSongsByGenre(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
+ // Verify that library filter was applied
+ query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
+ Expect(query).To(ContainSubstring("library_id IN (?,?)"))
+ Expect(args).To(ContainElements(1, 2))
+ })
+
+ It("should return all accessible songs when no musicFolderId is provided", func() {
+ mockMediaFileRepo.SetData(model.MediaFiles{
+ {ID: "1", Title: "Song 1"},
+ {ID: "2", Title: "Song 2"},
+ })
+ r := newGetRequest("count=2", "genre=rock")
+ r = r.WithContext(ctx)
+
+ resp, err := router.GetSongsByGenre(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
+ // Verify that library filter was applied
+ query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
+ Expect(query).To(ContainSubstring("library_id IN (?,?,?)"))
+ Expect(args).To(ContainElements(1, 2, 3))
+ })
+ })
+ })
+
+ Describe("GetStarred", func() {
+ var mockArtistRepo *tests.MockArtistRepo
+ var mockAlbumRepo *tests.MockAlbumRepo
+ var mockMediaFileRepo *tests.MockMediaFileRepo
+
+ BeforeEach(func() {
+ mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
+ mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
+ mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
+ })
+
+ It("should return starred items", func() {
+ mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
+ mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
+ mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
+ r := newGetRequest()
+
+ resp, err := router.GetStarred(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.Starred.Artist).To(HaveLen(1))
+ Expect(resp.Starred.Album).To(HaveLen(1))
+ Expect(resp.Starred.Song).To(HaveLen(1))
+ })
+
+ Context("with musicFolderId parameter", func() {
+ var user model.User
+ var ctx context.Context
+
+ BeforeEach(func() {
+ user = model.User{
+ ID: "test-user",
+ Libraries: []model.Library{
+ {ID: 1, Name: "Library 1"},
+ {ID: 2, Name: "Library 2"},
+ {ID: 3, Name: "Library 3"},
+ },
+ }
+ ctx = request.WithUser(context.Background(), user)
+ })
+
+ It("should filter starred items by specific library when musicFolderId is provided", func() {
+ mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
+ mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
+ mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
+ r := newGetRequest("musicFolderId=1")
+ r = r.WithContext(ctx)
+
+ resp, err := router.GetStarred(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.Starred.Artist).To(HaveLen(1))
+ Expect(resp.Starred.Album).To(HaveLen(1))
+ Expect(resp.Starred.Song).To(HaveLen(1))
+ // Verify that library filter was applied to all types
+ artistQuery, artistArgs, _ := mockArtistRepo.Options.Filters.ToSql()
+ Expect(artistQuery).To(ContainSubstring("library_id IN (?)"))
+ Expect(artistArgs).To(ContainElement(1))
+ })
+ })
+ })
+
+ Describe("GetStarred2", func() {
+ var mockArtistRepo *tests.MockArtistRepo
+ var mockAlbumRepo *tests.MockAlbumRepo
+ var mockMediaFileRepo *tests.MockMediaFileRepo
+
+ BeforeEach(func() {
+ mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
+ mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
+ mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
+ })
+
+ It("should return starred items in ID3 format", func() {
+ mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
+ mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
+ mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
+ r := newGetRequest()
+
+ resp, err := router.GetStarred2(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.Starred2.Artist).To(HaveLen(1))
+ Expect(resp.Starred2.Album).To(HaveLen(1))
+ Expect(resp.Starred2.Song).To(HaveLen(1))
+ })
+
+ Context("with musicFolderId parameter", func() {
+ var user model.User
+ var ctx context.Context
+
+ BeforeEach(func() {
+ user = model.User{
+ ID: "test-user",
+ Libraries: []model.Library{
+ {ID: 1, Name: "Library 1"},
+ {ID: 2, Name: "Library 2"},
+ {ID: 3, Name: "Library 3"},
+ },
+ }
+ ctx = request.WithUser(context.Background(), user)
+ })
+
+ It("should filter starred items by specific library when musicFolderId is provided", func() {
+ mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
+ mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
+ mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
+ r := newGetRequest("musicFolderId=1")
+ r = r.WithContext(ctx)
+
+ resp, err := router.GetStarred2(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.Starred2.Artist).To(HaveLen(1))
+ Expect(resp.Starred2.Album).To(HaveLen(1))
+ Expect(resp.Starred2.Song).To(HaveLen(1))
+ // Verify that library filter was applied to all types
+ artistQuery, artistArgs, _ := mockArtistRepo.Options.Filters.ToSql()
+ Expect(artistQuery).To(ContainSubstring("library_id IN (?)"))
+ Expect(artistArgs).To(ContainElement(1))
+ })
+ })
})
})
diff --git a/server/subsonic/api.go b/server/subsonic/api.go
index 263fefb0c..f0e73c3d2 100644
--- a/server/subsonic/api.go
+++ b/server/subsonic/api.go
@@ -18,7 +18,6 @@ import (
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/subsonic/responses"
@@ -39,7 +38,7 @@ type Router struct {
players core.Players
provider external.Provider
playlists core.Playlists
- scanner scanner.Scanner
+ scanner model.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
share core.Share
@@ -48,7 +47,7 @@ type Router struct {
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
- players core.Players, provider external.Provider, scanner scanner.Scanner, broker events.Broker,
+ players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
metrics metrics.Metrics,
) *Router {
@@ -148,7 +147,9 @@ func (api *Router) routes() http.Handler {
h(r, "createBookmark", api.CreateBookmark)
h(r, "deleteBookmark", api.DeleteBookmark)
h(r, "getPlayQueue", api.GetPlayQueue)
+ h(r, "getPlayQueueByIndex", api.GetPlayQueueByIndex)
h(r, "savePlayQueue", api.SavePlayQueue)
+ h(r, "savePlayQueueByIndex", api.SavePlayQueueByIndex)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
@@ -333,6 +334,7 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
sendError(w, r, err)
return
}
+
if payload.Status == responses.StatusOK {
if log.IsGreaterOrEqualTo(log.LevelTrace) {
log.Debug(r.Context(), "API: Successful response", "endpoint", r.URL.Path, "status", "OK", "body", string(response))
@@ -342,6 +344,17 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
} else {
log.Warn(r.Context(), "API: Failed response", "endpoint", r.URL.Path, "error", payload.Error.Code, "message", payload.Error.Message)
}
+
+ statusPointer, ok := r.Context().Value(subsonicErrorPointer).(*int32)
+
+ if ok && statusPointer != nil {
+ if payload.Status == responses.StatusOK {
+ *statusPointer = 0
+ } else {
+ *statusPointer = payload.Error.Code
+ }
+ }
+
if _, err := w.Write(response); err != nil {
log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err)
}
diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go
index 1658f0945..eaecd7c06 100644
--- a/server/subsonic/api_test.go
+++ b/server/subsonic/api_test.go
@@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
+ "golang.org/x/net/context"
)
var _ = Describe("sendResponse", func() {
@@ -109,4 +110,18 @@ var _ = Describe("sendResponse", func() {
})
})
+ It("updates status pointer when an error occurs", func() {
+ pointer := int32(0)
+
+ ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer)
+ r = r.WithContext(ctx)
+
+ payload.Status = responses.StatusFailed
+ payload.Error = &responses.Error{Code: responses.ErrorDataNotFound}
+
+ sendResponse(w, r, payload)
+ Expect(w.Code).To(Equal(http.StatusOK))
+
+ Expect(pointer).To(Equal(responses.ErrorDataNotFound))
+ })
})
diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go
index d7286c20c..b1e71b1c7 100644
--- a/server/subsonic/bookmarks.go
+++ b/server/subsonic/bookmarks.go
@@ -91,7 +91,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
Current: currentID,
Position: pq.Position,
Username: user.UserName,
- Changed: &pq.UpdatedAt,
+ Changed: pq.UpdatedAt,
ChangedBy: pq.ChangedBy,
}
return response, nil
@@ -135,3 +135,74 @@ func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) {
}
return newResponse(), nil
}
+
+func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
+ user, _ := request.UserFrom(r.Context())
+
+ repo := api.ds.PlayQueue(r.Context())
+ pq, err := repo.RetrieveWithMediaFiles(user.ID)
+ if err != nil && !errors.Is(err, model.ErrNotFound) {
+ return nil, err
+ }
+ if pq == nil || len(pq.Items) == 0 {
+ return newResponse(), nil
+ }
+
+ response := newResponse()
+
+ var index *int
+ if len(pq.Items) > 0 {
+ index = &pq.Current
+ }
+
+ response.PlayQueueByIndex = &responses.PlayQueueByIndex{
+ Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
+ CurrentIndex: index,
+ Position: pq.Position,
+ Username: user.UserName,
+ Changed: pq.UpdatedAt,
+ ChangedBy: pq.ChangedBy,
+ }
+ return response, nil
+}
+
+func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
+ p := req.Params(r)
+ ids, _ := p.Strings("id")
+
+ position := p.Int64Or("position", 0)
+
+ var err error
+ var currentIndex int
+
+ if len(ids) > 0 {
+ currentIndex, err = p.Int("currentIndex")
+ if err != nil || currentIndex < 0 || currentIndex >= len(ids) {
+ return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err)
+ }
+ }
+
+ items := slice.Map(ids, func(id string) model.MediaFile {
+ return model.MediaFile{ID: id}
+ })
+
+ user, _ := request.UserFrom(r.Context())
+ client, _ := request.ClientFrom(r.Context())
+
+ pq := &model.PlayQueue{
+ UserID: user.ID,
+ Current: currentIndex,
+ Position: position,
+ ChangedBy: client,
+ Items: items,
+ CreatedAt: time.Time{},
+ UpdatedAt: time.Time{},
+ }
+
+ repo := api.ds.PlayQueue(r.Context())
+ err = repo.Store(pq)
+ if err != nil {
+ return nil, err
+ }
+ return newResponse(), nil
+}
diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go
index db4e6ded1..c8584543d 100644
--- a/server/subsonic/browsing.go
+++ b/server/subsonic/browsing.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/public"
@@ -17,7 +18,8 @@ import (
)
func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) {
- libraries, _ := api.ds.Library(r.Context()).GetAll()
+ libraries := getUserAccessibleLibraries(r.Context())
+
folders := make([]responses.MusicFolder, len(libraries))
for i, f := range libraries {
folders[i].Id = int32(f.ID)
@@ -28,28 +30,37 @@ func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error)
return response, nil
}
-func (api *Router) getArtist(r *http.Request, libId int, ifModifiedSince time.Time) (model.ArtistIndexes, int64, error) {
+func (api *Router) getArtist(r *http.Request, libIds []int, ifModifiedSince time.Time) (model.ArtistIndexes, int64, error) {
ctx := r.Context()
- lib, err := api.ds.Library(ctx).Get(libId)
+
+ lastScanStr, err := api.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
if err != nil {
- log.Error(ctx, "Error retrieving Library", "id", libId, err)
+ log.Error(ctx, "Error retrieving last scan start time", err)
return nil, 0, err
}
+ lastScan := time.Now()
+ if lastScanStr != "" {
+ lastScan, err = time.Parse(time.RFC3339, lastScanStr)
+ }
var indexes model.ArtistIndexes
- if lib.LastScanAt.After(ifModifiedSince) {
- indexes, err = api.ds.Artist(ctx).GetIndex(false, model.RoleAlbumArtist)
+ if lastScan.After(ifModifiedSince) {
+ indexes, err = api.ds.Artist(ctx).GetIndex(false, libIds, model.RoleAlbumArtist)
if err != nil {
log.Error(ctx, "Error retrieving Indexes", err)
return nil, 0, err
}
+ if len(indexes) == 0 {
+ log.Debug(ctx, "No artists found in library", "libId", libIds)
+ return nil, 0, newError(responses.ErrorDataNotFound, "Library not found or empty")
+ }
}
- return indexes, lib.LastScanAt.UnixMilli(), err
+ return indexes, lastScan.UnixMilli(), err
}
-func (api *Router) getArtistIndex(r *http.Request, libId int, ifModifiedSince time.Time) (*responses.Indexes, error) {
- indexes, modified, err := api.getArtist(r, libId, ifModifiedSince)
+func (api *Router) getArtistIndex(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Indexes, error) {
+ indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
if err != nil {
return nil, err
}
@@ -67,8 +78,8 @@ func (api *Router) getArtistIndex(r *http.Request, libId int, ifModifiedSince ti
return res, nil
}
-func (api *Router) getArtistIndexID3(r *http.Request, libId int, ifModifiedSince time.Time) (*responses.Artists, error) {
- indexes, modified, err := api.getArtist(r, libId, ifModifiedSince)
+func (api *Router) getArtistIndexID3(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Artists, error) {
+ indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
if err != nil {
return nil, err
}
@@ -88,10 +99,10 @@ func (api *Router) getArtistIndexID3(r *http.Request, libId int, ifModifiedSince
func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
- musicFolderId := p.IntOr("musicFolderId", 1)
+ musicFolderIds, _ := selectedMusicFolderIds(r, false)
ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{})
- res, err := api.getArtistIndex(r, musicFolderId, ifModifiedSince)
+ res, err := api.getArtistIndex(r, musicFolderIds, ifModifiedSince)
if err != nil {
return nil, err
}
@@ -102,9 +113,9 @@ func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) {
}
func (api *Router) GetArtists(r *http.Request) (*responses.Subsonic, error) {
- p := req.Params(r)
- musicFolderId := p.IntOr("musicFolderId", 1)
- res, err := api.getArtistIndexID3(r, musicFolderId, time.Time{})
+ musicFolderIds, _ := selectedMusicFolderIds(r, false)
+
+ res, err := api.getArtistIndexID3(r, musicFolderIds, time.Time{})
if err != nil {
return nil, err
}
diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go
new file mode 100644
index 000000000..b8f510aed
--- /dev/null
+++ b/server/subsonic/browsing_test.go
@@ -0,0 +1,160 @@
+package subsonic
+
+import (
+ "context"
+ "fmt"
+ "net/http/httptest"
+
+ "github.com/navidrome/navidrome/core/auth"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func contextWithUser(ctx context.Context, userID string, libraryIDs ...int) context.Context {
+ libraries := make([]model.Library, len(libraryIDs))
+ for i, id := range libraryIDs {
+ libraries[i] = model.Library{ID: id, Name: fmt.Sprintf("Test Library %d", id), Path: fmt.Sprintf("/music/library%d", id)}
+ }
+ user := model.User{
+ ID: userID,
+ Libraries: libraries,
+ }
+ return request.WithUser(ctx, user)
+}
+
+var _ = Describe("Browsing", func() {
+ var api *Router
+ var ctx context.Context
+ var ds model.DataStore
+
+ BeforeEach(func() {
+ ds = &tests.MockDataStore{}
+ auth.Init(ds)
+ api = &Router{ds: ds}
+ ctx = context.Background()
+ })
+
+ Describe("GetMusicFolders", func() {
+ It("should return all libraries the user has access", func() {
+ // Create mock user with libraries
+ ctx := contextWithUser(ctx, "user-id", 1, 2, 3)
+
+ // Create request
+ r := httptest.NewRequest("GET", "/rest/getMusicFolders", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.GetMusicFolders(r)
+
+ // Verify results
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+ Expect(response.MusicFolders).ToNot(BeNil())
+ Expect(response.MusicFolders.Folders).To(HaveLen(3))
+ Expect(response.MusicFolders.Folders[0].Name).To(Equal("Test Library 1"))
+ Expect(response.MusicFolders.Folders[1].Name).To(Equal("Test Library 2"))
+ Expect(response.MusicFolders.Folders[2].Name).To(Equal("Test Library 3"))
+ })
+ })
+
+ Describe("GetIndexes", func() {
+ It("should validate user access to the specified musicFolderId", func() {
+ // Create mock user with access to library 1 only
+ ctx = contextWithUser(ctx, "user-id", 1)
+
+ // Create request with musicFolderId=2 (not accessible)
+ r := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=2", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.GetIndexes(r)
+
+ // Should return error due to lack of access
+ Expect(err).To(HaveOccurred())
+ Expect(response).To(BeNil())
+ })
+
+ It("should default to first accessible library when no musicFolderId specified", func() {
+ // Create mock user with access to libraries 2 and 3
+ ctx = contextWithUser(ctx, "user-id", 2, 3)
+
+ // Setup minimal mock library data for working tests
+ mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
+ mockLibRepo.SetData(model.Libraries{
+ {ID: 2, Name: "Test Library 2", Path: "/music/library2"},
+ {ID: 3, Name: "Test Library 3", Path: "/music/library3"},
+ })
+
+ // Setup mock artist data
+ mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo)
+ mockArtistRepo.SetData(model.Artists{
+ {ID: "1", Name: "Test Artist 1"},
+ {ID: "2", Name: "Test Artist 2"},
+ })
+
+ // Create request without musicFolderId
+ r := httptest.NewRequest("GET", "/rest/getIndexes", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.GetIndexes(r)
+
+ // Should succeed and use first accessible library (2)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+ Expect(response.Indexes).ToNot(BeNil())
+ })
+ })
+
+ Describe("GetArtists", func() {
+ It("should validate user access to the specified musicFolderId", func() {
+ // Create mock user with access to library 1 only
+ ctx = contextWithUser(ctx, "user-id", 1)
+
+ // Create request with musicFolderId=3 (not accessible)
+ r := httptest.NewRequest("GET", "/rest/getArtists?musicFolderId=3", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.GetArtists(r)
+
+ // Should return error due to lack of access
+ Expect(err).To(HaveOccurred())
+ Expect(response).To(BeNil())
+ })
+
+ It("should default to first accessible library when no musicFolderId specified", func() {
+ // Create mock user with access to libraries 1 and 2
+ ctx = contextWithUser(ctx, "user-id", 1, 2)
+
+ // Setup minimal mock library data for working tests
+ mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
+ mockLibRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Test Library 1", Path: "/music/library1"},
+ {ID: 2, Name: "Test Library 2", Path: "/music/library2"},
+ })
+
+ // Setup mock artist data
+ mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo)
+ mockArtistRepo.SetData(model.Artists{
+ {ID: "1", Name: "Test Artist 1"},
+ {ID: "2", Name: "Test Artist 2"},
+ })
+
+ // Create request without musicFolderId
+ r := httptest.NewRequest("GET", "/rest/getArtists", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.GetArtists(r)
+
+ // Should succeed and use first accessible library (1)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+ Expect(response.Artist).ToNot(BeNil())
+ })
+ })
+})
diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go
index 656973a4b..8ba4f0ff9 100644
--- a/server/subsonic/filter/filters.go
+++ b/server/subsonic/filter/filters.go
@@ -123,6 +123,38 @@ func SongsByArtistTitleWithLyricsFirst(artist, title string) Options {
})
}
+func ApplyLibraryFilter(opts Options, musicFolderIds []int) Options {
+ if len(musicFolderIds) == 0 {
+ return opts
+ }
+
+ libraryFilter := Eq{"library_id": musicFolderIds}
+ if opts.Filters == nil {
+ opts.Filters = libraryFilter
+ } else {
+ opts.Filters = And{opts.Filters, libraryFilter}
+ }
+
+ return opts
+}
+
+// ApplyArtistLibraryFilter applies a filter to the given Options to ensure that only artists
+// that are associated with the specified music folders are included in the results.
+func ApplyArtistLibraryFilter(opts Options, musicFolderIds []int) Options {
+ if len(musicFolderIds) == 0 {
+ return opts
+ }
+
+ artistLibraryFilter := Eq{"library_artist.library_id": musicFolderIds}
+ if opts.Filters == nil {
+ opts.Filters = artistLibraryFilter
+ } else {
+ opts.Filters = And{opts.Filters, artistLibraryFilter}
+ }
+
+ return opts
+}
+
func ByGenre(genre string) Options {
return addDefaultFilters(Options{
Sort: "name",
@@ -131,7 +163,7 @@ func ByGenre(genre string) Options {
}
func filterByGenre(genre string) Sqlizer {
- return persistence.Exists("json_tree(tags)", And{
+ return persistence.Exists(`json_tree(tags, "$.genre")`, And{
Like{"value": genre},
NotEq{"atom": nil},
})
diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go
index 58834587d..f9733bb3f 100644
--- a/server/subsonic/helpers.go
+++ b/server/subsonic/helpers.go
@@ -7,6 +7,7 @@ import (
"fmt"
"mime"
"net/http"
+ "slices"
"sort"
"strings"
@@ -17,6 +18,7 @@ import (
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/number"
+ "github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
)
@@ -474,3 +476,40 @@ func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses
}
return res
}
+
+// getUserAccessibleLibraries returns the list of libraries the current user has access to.
+func getUserAccessibleLibraries(ctx context.Context) []model.Library {
+ user := getUser(ctx)
+ return user.Libraries
+}
+
+// selectedMusicFolderIds retrieves the music folder IDs from the request parameters.
+// If no IDs are provided, it returns all libraries the user has access to (based on the user found in the context).
+// If the parameter is required and not present, it returns an error.
+// If any of the provided library IDs are invalid (don't exist or user doesn't have access), returns ErrorDataNotFound.
+func selectedMusicFolderIds(r *http.Request, required bool) ([]int, error) {
+ p := req.Params(r)
+ musicFolderIds, err := p.Ints("musicFolderId")
+
+ // If the parameter is not present, it returns an error if it is required.
+ if errors.Is(err, req.ErrMissingParam) && required {
+ return nil, err
+ }
+
+ // Get user's accessible libraries for validation
+ libraries := getUserAccessibleLibraries(r.Context())
+ accessibleLibraryIds := slice.Map(libraries, func(lib model.Library) int { return lib.ID })
+
+ if len(musicFolderIds) > 0 {
+ // Validate all provided library IDs - if any are invalid, return an error
+ for _, id := range musicFolderIds {
+ if !slices.Contains(accessibleLibraryIds, id) {
+ return nil, newError(responses.ErrorDataNotFound, "Library %d not found or not accessible", id)
+ }
+ }
+ return musicFolderIds, nil
+ }
+
+ // If no musicFolderId is provided, return all libraries the user has access to.
+ return accessibleLibraryIds, nil
+}
diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go
index a4978237b..a6508d4bb 100644
--- a/server/subsonic/helpers_test.go
+++ b/server/subsonic/helpers_test.go
@@ -1,10 +1,15 @@
package subsonic
import (
+ "context"
+ "net/http/httptest"
+
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
+ "github.com/navidrome/navidrome/utils/req"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -163,4 +168,108 @@ var _ = Describe("helpers", func() {
Expect(result).To(Equal(int32(4)))
})
})
+
+ Describe("selectedMusicFolderIds", func() {
+ var user model.User
+ var ctx context.Context
+
+ BeforeEach(func() {
+ user = model.User{
+ ID: "test-user",
+ Libraries: []model.Library{
+ {ID: 1, Name: "Library 1"},
+ {ID: 2, Name: "Library 2"},
+ {ID: 3, Name: "Library 3"},
+ },
+ }
+ ctx = request.WithUser(context.Background(), user)
+ })
+
+ Context("when musicFolderId parameter is provided", func() {
+ It("should return the specified musicFolderId values", func() {
+ r := httptest.NewRequest("GET", "/test?musicFolderId=1&musicFolderId=3", nil)
+ r = r.WithContext(ctx)
+
+ ids, err := selectedMusicFolderIds(r, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ids).To(Equal([]int{1, 3}))
+ })
+
+ It("should ignore invalid musicFolderId parameter values", func() {
+ r := httptest.NewRequest("GET", "/test?musicFolderId=invalid&musicFolderId=2", nil)
+ r = r.WithContext(ctx)
+
+ ids, err := selectedMusicFolderIds(r, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ids).To(Equal([]int{2})) // Only valid ID is returned
+ })
+
+ It("should return error when any library ID is not accessible", func() {
+ r := httptest.NewRequest("GET", "/test?musicFolderId=1&musicFolderId=5&musicFolderId=2&musicFolderId=99", nil)
+ r = r.WithContext(ctx)
+
+ ids, err := selectedMusicFolderIds(r, false)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("Library 5 not found or not accessible"))
+ Expect(ids).To(BeNil())
+ })
+ })
+
+ Context("when musicFolderId parameter is not provided", func() {
+ Context("and required is false", func() {
+ It("should return all user's library IDs", func() {
+ r := httptest.NewRequest("GET", "/test", nil)
+ r = r.WithContext(ctx)
+
+ ids, err := selectedMusicFolderIds(r, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ids).To(Equal([]int{1, 2, 3}))
+ })
+
+ It("should return empty slice when user has no libraries", func() {
+ userWithoutLibs := model.User{ID: "no-libs-user", Libraries: []model.Library{}}
+ ctxWithoutLibs := request.WithUser(context.Background(), userWithoutLibs)
+ r := httptest.NewRequest("GET", "/test", nil)
+ r = r.WithContext(ctxWithoutLibs)
+
+ ids, err := selectedMusicFolderIds(r, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ids).To(Equal([]int{}))
+ })
+ })
+
+ Context("and required is true", func() {
+ It("should return ErrMissingParam error", func() {
+ r := httptest.NewRequest("GET", "/test", nil)
+ r = r.WithContext(ctx)
+
+ ids, err := selectedMusicFolderIds(r, true)
+ Expect(err).To(MatchError(req.ErrMissingParam))
+ Expect(ids).To(BeNil())
+ })
+ })
+ })
+
+ Context("when musicFolderId parameter is empty", func() {
+ It("should return all user's library IDs even when empty parameter is provided", func() {
+ r := httptest.NewRequest("GET", "/test?musicFolderId=", nil)
+ r = r.WithContext(ctx)
+
+ ids, err := selectedMusicFolderIds(r, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ids).To(Equal([]int{1, 2, 3}))
+ })
+ })
+
+ Context("when all musicFolderId parameters are invalid", func() {
+ It("should return all user libraries when all musicFolderId parameters are invalid", func() {
+ r := httptest.NewRequest("GET", "/test?musicFolderId=invalid&musicFolderId=notanumber", nil)
+ r = r.WithContext(ctx)
+
+ ids, err := selectedMusicFolderIds(r, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ids).To(Equal([]int{1, 2, 3})) // Falls back to all user libraries
+ })
+ })
+ })
})
diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go
index b6ccb9ae6..c9dd64968 100644
--- a/server/subsonic/library_scanning.go
+++ b/server/subsonic/library_scanning.go
@@ -1,10 +1,13 @@
package subsonic
import (
+ "fmt"
"net/http"
+ "slices"
"time"
"github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
@@ -44,15 +47,56 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
fullScan := p.BoolOr("fullScan", false)
+ // Parse optional target parameters for selective scanning
+ var targets []model.ScanTarget
+ if targetParams, err := p.Strings("target"); err == nil && len(targetParams) > 0 {
+ targets, err = model.ParseTargets(targetParams)
+ if err != nil {
+ return nil, newError(responses.ErrorGeneric, fmt.Sprintf("Invalid target parameter: %v", err))
+ }
+
+ // Validate all libraries in targets exist and user has access to them
+ userLibraries, err := api.ds.User(ctx).GetUserLibraries(loggedUser.ID)
+ if err != nil {
+ return nil, newError(responses.ErrorGeneric, "Internal error")
+ }
+
+ // Check each target library
+ for _, target := range targets {
+ if !slices.ContainsFunc(userLibraries, func(lib model.Library) bool { return lib.ID == target.LibraryID }) {
+ return nil, newError(responses.ErrorDataNotFound, fmt.Sprintf("Library with ID %d not found", target.LibraryID))
+ }
+ }
+
+ // Special case: if single library with empty path and it's the only library in DB, call ScanAll
+ if len(targets) == 1 && targets[0].FolderPath == "" {
+ allLibs, err := api.ds.Library(ctx).GetAll()
+ if err != nil {
+ return nil, newError(responses.ErrorGeneric, "Internal error")
+ }
+ if len(allLibs) == 1 {
+ targets = nil // This will trigger ScanAll below
+ }
+ }
+ }
+
go func() {
start := time.Now()
- log.Info(ctx, "Triggering manual scan", "fullScan", fullScan, "user", loggedUser.UserName)
- _, err := api.scanner.ScanAll(ctx, fullScan)
+ var err error
+
+ if len(targets) > 0 {
+ log.Info(ctx, "Triggering on-demand scan", "fullScan", fullScan, "targets", len(targets), "user", loggedUser.UserName)
+ _, err = api.scanner.ScanFolders(ctx, fullScan, targets)
+ } else {
+ log.Info(ctx, "Triggering on-demand scan", "fullScan", fullScan, "user", loggedUser.UserName)
+ _, err = api.scanner.ScanAll(ctx, fullScan)
+ }
+
if err != nil {
log.Error(ctx, "Error scanning", err)
return
}
- log.Info(ctx, "Manual scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start))
+ log.Info(ctx, "On-demand scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start))
}()
return api.GetScanStatus(r)
diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go
new file mode 100644
index 000000000..d8eba296b
--- /dev/null
+++ b/server/subsonic/library_scanning_test.go
@@ -0,0 +1,396 @@
+package subsonic
+
+import (
+ "context"
+ "errors"
+ "net/http/httptest"
+
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/server/subsonic/responses"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("LibraryScanning", func() {
+ var api *Router
+ var ms *tests.MockScanner
+
+ BeforeEach(func() {
+ ms = tests.NewMockScanner()
+ api = &Router{scanner: ms}
+ })
+
+ Describe("StartScan", func() {
+ It("requires admin authentication", func() {
+ // Create non-admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "user-id",
+ IsAdmin: false,
+ })
+
+ // Create request
+ r := httptest.NewRequest("GET", "/rest/startScan", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should return authorization error
+ Expect(err).To(HaveOccurred())
+ Expect(response).To(BeNil())
+ var subErr subError
+ ok := errors.As(err, &subErr)
+ Expect(ok).To(BeTrue())
+ Expect(subErr.code).To(Equal(responses.ErrorAuthorizationFail))
+ })
+
+ It("triggers a full scan with no parameters", func() {
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with no parameters
+ r := httptest.NewRequest("GET", "/rest/startScan", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanAll was called (eventually, since it's in a goroutine)
+ Eventually(func() int {
+ return ms.GetScanAllCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanAllCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].FullScan).To(BeFalse())
+ })
+
+ It("triggers a full scan with fullScan=true", func() {
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with fullScan parameter
+ r := httptest.NewRequest("GET", "/rest/startScan?fullScan=true", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanAll was called with fullScan=true
+ Eventually(func() int {
+ return ms.GetScanAllCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanAllCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].FullScan).To(BeTrue())
+ })
+
+ It("triggers a selective scan with single target parameter", func() {
+ // Setup mocks
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
+ mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with single target parameter
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Rock", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanFolders was called with correct targets
+ Eventually(func() int {
+ return ms.GetScanFoldersCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ targets := calls[0].Targets
+ Expect(targets).To(HaveLen(1))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
+ })
+
+ It("triggers a selective scan with multiple target parameters", func() {
+ // Setup mocks
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
+ mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with multiple target parameters
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Reggae&target=2:Classical/Bach", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanFolders was called with correct targets
+ Eventually(func() int {
+ return ms.GetScanFoldersCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ targets := calls[0].Targets
+ Expect(targets).To(HaveLen(2))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal("Music/Reggae"))
+ Expect(targets[1].LibraryID).To(Equal(2))
+ Expect(targets[1].FolderPath).To(Equal("Classical/Bach"))
+ })
+
+ It("triggers a selective full scan with target and fullScan parameters", func() {
+ // Setup mocks
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1})
+ mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with target and fullScan parameters
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Jazz&fullScan=true", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanFolders was called with fullScan=true
+ Eventually(func() int {
+ return ms.GetScanFoldersCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].FullScan).To(BeTrue())
+ targets := calls[0].Targets
+ Expect(targets).To(HaveLen(1))
+ })
+
+ It("returns error for invalid target format", func() {
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with invalid target format (missing colon)
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1MusicRock", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should return error
+ Expect(err).To(HaveOccurred())
+ Expect(response).To(BeNil())
+ var subErr subError
+ ok := errors.As(err, &subErr)
+ Expect(ok).To(BeTrue())
+ Expect(subErr.code).To(Equal(responses.ErrorGeneric))
+ })
+
+ It("returns error for invalid library ID in target", func() {
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with invalid library ID
+ r := httptest.NewRequest("GET", "/rest/startScan?target=0:Music/Rock", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should return error
+ Expect(err).To(HaveOccurred())
+ Expect(response).To(BeNil())
+ var subErr subError
+ ok := errors.As(err, &subErr)
+ Expect(ok).To(BeTrue())
+ Expect(subErr.code).To(Equal(responses.ErrorGeneric))
+ })
+
+ It("returns error when library does not exist", func() {
+ // Setup mocks - user has access to library 1 and 2 only
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
+ mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with library ID that doesn't exist
+ r := httptest.NewRequest("GET", "/rest/startScan?target=999:Music/Rock", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should return ErrorDataNotFound
+ Expect(err).To(HaveOccurred())
+ Expect(response).To(BeNil())
+ var subErr subError
+ ok := errors.As(err, &subErr)
+ Expect(ok).To(BeTrue())
+ Expect(subErr.code).To(Equal(responses.ErrorDataNotFound))
+ })
+
+ It("calls ScanAll when single library with empty path and only one library exists", func() {
+ // Setup mocks - single library in DB
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1})
+ mockLibraryRepo := &tests.MockLibraryRepo{}
+ mockLibraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Music Library", Path: "/music"},
+ })
+ mockDS := &tests.MockDataStore{
+ MockedUser: mockUserRepo,
+ MockedLibrary: mockLibraryRepo,
+ }
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with single library and empty path
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanAll was called instead of ScanFolders
+ Eventually(func() int {
+ return ms.GetScanAllCallCount()
+ }).Should(BeNumerically(">", 0))
+ Expect(ms.GetScanFoldersCallCount()).To(Equal(0))
+ })
+
+ It("calls ScanFolders when single library with empty path but multiple libraries exist", func() {
+ // Setup mocks - multiple libraries in DB
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
+ mockLibraryRepo := &tests.MockLibraryRepo{}
+ mockLibraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Music Library", Path: "/music"},
+ {ID: 2, Name: "Audiobooks", Path: "/audiobooks"},
+ })
+ mockDS := &tests.MockDataStore{
+ MockedUser: mockUserRepo,
+ MockedLibrary: mockLibraryRepo,
+ }
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with single library and empty path
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanFolders was called (not ScanAll)
+ Eventually(func() int {
+ return ms.GetScanFoldersCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ targets := calls[0].Targets
+ Expect(targets).To(HaveLen(1))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal(""))
+ })
+ })
+
+ Describe("GetScanStatus", func() {
+ It("returns scan status", func() {
+ // Setup mock scanner status
+ ms.SetStatusResponse(&model.ScannerStatus{
+ Scanning: false,
+ Count: 100,
+ FolderCount: 10,
+ })
+
+ // Create request
+ ctx := context.Background()
+ r := httptest.NewRequest("GET", "/rest/getScanStatus", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.GetScanStatus(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+ Expect(response.ScanStatus).ToNot(BeNil())
+ Expect(response.ScanStatus.Scanning).To(BeFalse())
+ Expect(response.ScanStatus.Count).To(Equal(int64(100)))
+ Expect(response.ScanStatus.FolderCount).To(Equal(int64(10)))
+ })
+ })
+})
diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go
index c7a8937fc..6f09f5349 100644
--- a/server/subsonic/media_annotation_test.go
+++ b/server/subsonic/media_annotation_test.go
@@ -138,4 +138,8 @@ func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
f.Events = append(f.Events, event)
}
+func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) {
+ f.Events = append(f.Events, event)
+}
+
var _ events.Broker = (*fakeEventBroker)(nil)
diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go
index 4a0f327f7..d984bac42 100644
--- a/server/subsonic/middlewares.go
+++ b/server/subsonic/middlewares.go
@@ -47,11 +47,23 @@ func postFormToQueryParams(next http.Handler) http.Handler {
})
}
+func fromInternalOrProxyAuth(r *http.Request) (string, bool) {
+ username := server.InternalAuth(r)
+
+ // If the username comes from internal auth, do not also do reverse proxy auth, as
+ // the request will have no reverse proxy IP
+ if username != "" {
+ return username, true
+ }
+
+ return server.UsernameFromExtAuthHeader(r), false
+}
+
func checkRequiredParameters(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var requiredParameters []string
- username := cmp.Or(server.InternalAuth(r), server.UsernameFromReverseProxyHeader(r))
+ username, _ := fromInternalOrProxyAuth(r)
if username != "" {
requiredParameters = []string{"v", "c"}
} else {
@@ -91,10 +103,9 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
var usr *model.User
var err error
- internalAuth := server.InternalAuth(r)
- proxyAuth := server.UsernameFromReverseProxyHeader(r)
- if username := cmp.Or(internalAuth, proxyAuth); username != "" {
- authType := If(internalAuth != "", "internal", "reverse-proxy")
+ username, isInternalAuth := fromInternalOrProxyAuth(r)
+ if username != "" {
+ authType := If(isInternalAuth, "internal", "reverse-proxy")
usr, err = ds.User(ctx).FindByUsername(username)
if errors.Is(err, context.Canceled) {
log.Debug(ctx, "API: Request canceled when authenticating", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
@@ -226,21 +237,35 @@ func playerIDCookieName(userName string) string {
return cookieName
}
+const subsonicErrorPointer = "subsonicErrorPointer"
+
func recordStats(metrics metrics.Metrics) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
+ status := int32(-1)
+ contextWithStatus := context.WithValue(r.Context(), subsonicErrorPointer, &status)
+
start := time.Now()
defer func() {
+ elapsed := time.Since(start).Milliseconds()
+
// We want to get the client name (even if not present for certain endpoints)
p := req.Params(r)
client, _ := p.String("c")
- metrics.RecordRequest(r.Context(), strings.Replace(r.URL.Path, ".view", "", 1), r.Method, client, ww.Status(), time.Since(start).Milliseconds())
+ // If there is no Subsonic status (e.g., HTTP 501 not implemented), fallback to HTTP
+ if status == -1 {
+ status = int32(ww.Status())
+ }
+
+ shortPath := strings.Replace(r.URL.Path, ".view", "", 1)
+
+ metrics.RecordRequest(r.Context(), shortPath, r.Method, client, status, elapsed)
}()
- next.ServeHTTP(ww, r)
+ next.ServeHTTP(ww, r.WithContext(contextWithStatus))
}
return http.HandlerFunc(fn)
}
diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go
index a30d5b3af..aba14a0aa 100644
--- a/server/subsonic/middlewares_test.go
+++ b/server/subsonic/middlewares_test.go
@@ -95,8 +95,8 @@ var _ = Describe("Middlewares", func() {
})
It("passes when all required params are available (reverse-proxy case)", func() {
- conf.Server.ReverseProxyWhitelist = "127.0.0.234/32"
- conf.Server.ReverseProxyUserHeader = "Remote-User"
+ conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32"
+ conf.Server.ExtAuth.UserHeader = "Remote-User"
r := newGetRequest("v=1.15", "c=test")
r.Header.Add("Remote-User", "user")
@@ -254,8 +254,8 @@ var _ = Describe("Middlewares", func() {
When("using reverse proxy authentication", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
- conf.Server.ReverseProxyWhitelist = "192.168.1.1/24"
- conf.Server.ReverseProxyUserHeader = "Remote-User"
+ conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24"
+ conf.Server.ExtAuth.UserHeader = "Remote-User"
})
It("passes authentication with correct IP and header", func() {
diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go
index 17ce3c2b0..a364651c5 100644
--- a/server/subsonic/opensubsonic.go
+++ b/server/subsonic/opensubsonic.go
@@ -12,6 +12,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
{Name: "transcodeOffset", Versions: []int32{1}},
{Name: "formPost", Versions: []int32{1}},
{Name: "songLyrics", Versions: []int32{1}},
+ {Name: "indexBasedQueue", Versions: []int32{1}},
}
return response, nil
}
diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go
index 3cc680afe..58dca682c 100644
--- a/server/subsonic/opensubsonic_test.go
+++ b/server/subsonic/opensubsonic_test.go
@@ -35,10 +35,11 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
- HaveLen(3),
+ HaveLen(4),
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
+ ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
))
})
})
diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go
index 83b0408ff..23fac6814 100644
--- a/server/subsonic/playlists.go
+++ b/server/subsonic/playlists.go
@@ -76,7 +76,7 @@ func (api *Router) create(ctx context.Context, playlistId, name string, ids []st
pls.OwnerID = owner.ID
}
pls.Tracks = nil
- pls.AddTracks(ids)
+ pls.AddMediaFilesByID(ids)
err = tx.Playlist(ctx).Put(pls)
playlistId = pls.ID
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON
index 88eebb276..70b10c059 100644
--- a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON
@@ -6,6 +6,7 @@
"openSubsonic": true,
"playQueue": {
"username": "",
+ "changed": "0001-01-01T00:00:00Z",
"changedBy": ""
}
}
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML
index 5af3d9157..597781cbd 100644
--- a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML
@@ -1,3 +1,3 @@
-
+
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON
new file mode 100644
index 000000000..efc032ca6
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON
@@ -0,0 +1,22 @@
+{
+ "status": "ok",
+ "version": "1.16.1",
+ "type": "navidrome",
+ "serverVersion": "v0.55.0",
+ "openSubsonic": true,
+ "playQueueByIndex": {
+ "entry": [
+ {
+ "id": "1",
+ "isDir": false,
+ "title": "title",
+ "isVideo": false
+ }
+ ],
+ "currentIndex": 0,
+ "position": 243,
+ "username": "user1",
+ "changed": "0001-01-01T00:00:00Z",
+ "changedBy": "a_client"
+ }
+}
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML
new file mode 100644
index 000000000..1d31b334e
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON
new file mode 100644
index 000000000..ad49a35e5
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON
@@ -0,0 +1,12 @@
+{
+ "status": "ok",
+ "version": "1.16.1",
+ "type": "navidrome",
+ "serverVersion": "v0.55.0",
+ "openSubsonic": true,
+ "playQueueByIndex": {
+ "username": "",
+ "changed": "0001-01-01T00:00:00Z",
+ "changedBy": ""
+ }
+}
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML
new file mode 100644
index 000000000..d99681f4c
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML
@@ -0,0 +1,3 @@
+
+
+
diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go
index ffda2aa43..0724d2fff 100644
--- a/server/subsonic/responses/responses.go
+++ b/server/subsonic/responses/responses.go
@@ -60,6 +60,7 @@ type Subsonic struct {
// OpenSubsonic extensions
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
+ PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
}
const (
@@ -439,12 +440,21 @@ type TopSongs struct {
}
type PlayQueue struct {
- Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
- Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
- Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
- Username string `xml:"username,attr" json:"username"`
- Changed *time.Time `xml:"changed,attr,omitempty" json:"changed,omitempty"`
- ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
+ Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
+ Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
+ Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
+ Username string `xml:"username,attr" json:"username"`
+ Changed time.Time `xml:"changed,attr" json:"changed"`
+ ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
+}
+
+type PlayQueueByIndex struct {
+ Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
+ CurrentIndex *int `xml:"currentIndex,attr,omitempty" json:"currentIndex,omitempty"`
+ Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
+ Username string `xml:"username,attr" json:"username"`
+ Changed time.Time `xml:"changed,attr" json:"changed"`
+ ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
}
type Bookmark struct {
diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go
index 7238665cf..2ee8e080d 100644
--- a/server/subsonic/responses/responses_test.go
+++ b/server/subsonic/responses/responses_test.go
@@ -768,7 +768,7 @@ var _ = Describe("Responses", func() {
response.PlayQueue.Username = "user1"
response.PlayQueue.Current = "111"
response.PlayQueue.Position = 243
- response.PlayQueue.Changed = &time.Time{}
+ response.PlayQueue.Changed = time.Time{}
response.PlayQueue.ChangedBy = "a_client"
child := make([]Child, 1)
child[0] = Child{Id: "1", Title: "title", IsDir: false}
@@ -783,6 +783,40 @@ var _ = Describe("Responses", func() {
})
})
+ Describe("PlayQueueByIndex", func() {
+ BeforeEach(func() {
+ response.PlayQueueByIndex = &PlayQueueByIndex{}
+ })
+
+ Context("without data", func() {
+ It("should match .XML", func() {
+ Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
+ })
+ It("should match .JSON", func() {
+ Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
+ })
+ })
+
+ Context("with data", func() {
+ BeforeEach(func() {
+ response.PlayQueueByIndex.Username = "user1"
+ response.PlayQueueByIndex.CurrentIndex = gg.P(0)
+ response.PlayQueueByIndex.Position = 243
+ response.PlayQueueByIndex.Changed = time.Time{}
+ response.PlayQueueByIndex.ChangedBy = "a_client"
+ child := make([]Child, 1)
+ child[0] = Child{Id: "1", Title: "title", IsDir: false}
+ response.PlayQueueByIndex.Entry = child
+ })
+ It("should match .XML", func() {
+ Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
+ })
+ It("should match .JSON", func() {
+ Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
+ })
+ })
+ })
+
Describe("Shares", func() {
BeforeEach(func() {
response.Shares = &Shares{}
diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go
index f66846f35..ba1071320 100644
--- a/server/subsonic/searching.go
+++ b/server/subsonic/searching.go
@@ -8,6 +8,7 @@ import (
"strings"
"time"
+ . "github.com/Masterminds/squirrel"
"github.com/deluan/sanitize"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -41,9 +42,9 @@ func (api *Router) getSearchParams(r *http.Request) (*searchParams, error) {
return sp, nil
}
-type searchFunc[T any] func(q string, offset int, size int, includeMissing bool) (T, error)
+type searchFunc[T any] func(q string, offset int, size int, options ...model.QueryOptions) (T, error)
-func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, size int, result *T) func() error {
+func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, size int, result *T, options ...model.QueryOptions) func() error {
return func() error {
if size == 0 {
return nil
@@ -51,7 +52,7 @@ func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, s
typ := strings.TrimPrefix(reflect.TypeOf(*result).String(), "model.")
var err error
start := time.Now()
- *result, err = s(q, offset, size, false)
+ *result, err = s(q, offset, size, options...)
if err != nil {
log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err)
} else {
@@ -61,15 +62,31 @@ func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, s
}
}
-func (api *Router) searchAll(ctx context.Context, sp *searchParams) (mediaFiles model.MediaFiles, albums model.Albums, artists model.Artists) {
+func (api *Router) searchAll(ctx context.Context, sp *searchParams, musicFolderIds []int) (mediaFiles model.MediaFiles, albums model.Albums, artists model.Artists) {
start := time.Now()
q := sanitize.Accents(strings.ToLower(strings.TrimSuffix(sp.query, "*")))
+ // Create query options for library filtering
+ var options []model.QueryOptions
+ var artistOptions []model.QueryOptions
+ if len(musicFolderIds) > 0 {
+ // For MediaFiles and Albums, use direct library_id filter
+ options = append(options, model.QueryOptions{
+ Filters: Eq{"library_id": musicFolderIds},
+ })
+ // For Artists, use the repository's built-in library filtering mechanism
+ // which properly handles the library_artist table joins
+ // TODO Revisit library filtering in sql_base_repository.go
+ artistOptions = append(artistOptions, model.QueryOptions{
+ Filters: Eq{"library_artist.library_id": musicFolderIds},
+ })
+ }
+
// Run searches in parallel
g, ctx := errgroup.WithContext(ctx)
- g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, sp.songOffset, sp.songCount, &mediaFiles))
- g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, sp.albumOffset, sp.albumCount, &albums))
- g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists))
+ g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, sp.songOffset, sp.songCount, &mediaFiles, options...))
+ g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, sp.albumOffset, sp.albumCount, &albums, options...))
+ g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists, artistOptions...))
err := g.Wait()
if err == nil {
log.Debug(ctx, fmt.Sprintf("Search resulted in %d songs, %d albums and %d artists",
@@ -86,7 +103,13 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) {
if err != nil {
return nil, err
}
- mfs, als, as := api.searchAll(ctx, sp)
+
+ // Get optional library IDs from musicFolderId parameter
+ musicFolderIds, err := selectedMusicFolderIds(r, false)
+ if err != nil {
+ return nil, err
+ }
+ mfs, als, as := api.searchAll(ctx, sp, musicFolderIds)
response := newResponse()
searchResult2 := &responses.SearchResult2{}
@@ -115,7 +138,13 @@ func (api *Router) Search3(r *http.Request) (*responses.Subsonic, error) {
if err != nil {
return nil, err
}
- mfs, als, as := api.searchAll(ctx, sp)
+
+ // Get optional library IDs from musicFolderId parameter
+ musicFolderIds, err := selectedMusicFolderIds(r, false)
+ if err != nil {
+ return nil, err
+ }
+ mfs, als, as := api.searchAll(ctx, sp, musicFolderIds)
response := newResponse()
searchResult3 := &responses.SearchResult3{}
diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go
new file mode 100644
index 000000000..dfe3a45c4
--- /dev/null
+++ b/server/subsonic/searching_test.go
@@ -0,0 +1,208 @@
+package subsonic
+
+import (
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/core/auth"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Search", func() {
+ var router *Router
+ var ds model.DataStore
+ var mockAlbumRepo *tests.MockAlbumRepo
+ var mockArtistRepo *tests.MockArtistRepo
+ var mockMediaFileRepo *tests.MockMediaFileRepo
+
+ BeforeEach(func() {
+ ds = &tests.MockDataStore{}
+ auth.Init(ds)
+
+ router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+
+ // Get references to the mock repositories so we can inspect their Options
+ mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)
+ mockArtistRepo = ds.Artist(nil).(*tests.MockArtistRepo)
+ mockMediaFileRepo = ds.MediaFile(nil).(*tests.MockMediaFileRepo)
+ })
+
+ Context("musicFolderId parameter", func() {
+ assertQueryOptions := func(filter squirrel.Sqlizer, expectedQuery string, expectedArgs ...interface{}) {
+ GinkgoHelper()
+ query, args, err := filter.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(query).To(ContainSubstring(expectedQuery))
+ Expect(args).To(ContainElements(expectedArgs...))
+ }
+
+ Describe("Search2", func() {
+ It("should accept musicFolderId parameter", func() {
+ r := newGetRequest("query=test", "musicFolderId=1")
+ ctx := request.WithUser(r.Context(), model.User{
+ ID: "user1",
+ UserName: "testuser",
+ Libraries: []model.Library{{ID: 1, Name: "Library 1"}},
+ })
+ r = r.WithContext(ctx)
+
+ resp, err := router.Search2(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.SearchResult2).ToNot(BeNil())
+
+ // Verify that library filter was applied to all repositories
+ assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1)
+ assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1)
+ assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1)
+ })
+
+ It("should return results from all accessible libraries when musicFolderId is not provided", func() {
+ r := newGetRequest("query=test")
+ ctx := request.WithUser(r.Context(), model.User{
+ ID: "user1",
+ UserName: "testuser",
+ Libraries: []model.Library{
+ {ID: 1, Name: "Library 1"},
+ {ID: 2, Name: "Library 2"},
+ {ID: 3, Name: "Library 3"},
+ },
+ })
+ r = r.WithContext(ctx)
+
+ resp, err := router.Search2(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.SearchResult2).ToNot(BeNil())
+
+ // Verify that library filter was applied to all repositories with all accessible libraries
+ assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
+ assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
+ assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
+ })
+
+ It("should return empty results when user has no accessible libraries", func() {
+ r := newGetRequest("query=test")
+ ctx := request.WithUser(r.Context(), model.User{
+ ID: "user1",
+ UserName: "testuser",
+ Libraries: []model.Library{}, // No libraries
+ })
+ r = r.WithContext(ctx)
+
+ resp, err := router.Search2(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.SearchResult2).ToNot(BeNil())
+ Expect(mockAlbumRepo.Options.Filters).To(BeNil())
+ Expect(mockArtistRepo.Options.Filters).To(BeNil())
+ Expect(mockMediaFileRepo.Options.Filters).To(BeNil())
+ })
+
+ It("should return error for inaccessible musicFolderId", func() {
+ r := newGetRequest("query=test", "musicFolderId=999")
+ ctx := request.WithUser(r.Context(), model.User{
+ ID: "user1",
+ UserName: "testuser",
+ Libraries: []model.Library{{ID: 1, Name: "Library 1"}},
+ })
+ r = r.WithContext(ctx)
+
+ resp, err := router.Search2(r)
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("Library 999 not found or not accessible"))
+ Expect(resp).To(BeNil())
+ })
+ })
+
+ Describe("Search3", func() {
+ It("should accept musicFolderId parameter", func() {
+ r := newGetRequest("query=test", "musicFolderId=1")
+ ctx := request.WithUser(r.Context(), model.User{
+ ID: "user1",
+ UserName: "testuser",
+ Libraries: []model.Library{{ID: 1, Name: "Library 1"}},
+ })
+ r = r.WithContext(ctx)
+
+ resp, err := router.Search3(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.SearchResult3).ToNot(BeNil())
+
+ // Verify that library filter was applied to all repositories
+ assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1)
+ assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1)
+ assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1)
+ })
+
+ It("should return results from all accessible libraries when musicFolderId is not provided", func() {
+ r := newGetRequest("query=test")
+ ctx := request.WithUser(r.Context(), model.User{
+ ID: "user1",
+ UserName: "testuser",
+ Libraries: []model.Library{
+ {ID: 1, Name: "Library 1"},
+ {ID: 2, Name: "Library 2"},
+ {ID: 3, Name: "Library 3"},
+ },
+ })
+ r = r.WithContext(ctx)
+
+ resp, err := router.Search3(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.SearchResult3).ToNot(BeNil())
+
+ // Verify that library filter was applied to all repositories with all accessible libraries
+ assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
+ assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
+ assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
+ })
+
+ It("should return empty results when user has no accessible libraries", func() {
+ r := newGetRequest("query=test")
+ ctx := request.WithUser(r.Context(), model.User{
+ ID: "user1",
+ UserName: "testuser",
+ Libraries: []model.Library{}, // No libraries
+ })
+ r = r.WithContext(ctx)
+
+ resp, err := router.Search3(r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp).ToNot(BeNil())
+ Expect(resp.SearchResult3).ToNot(BeNil())
+ Expect(mockAlbumRepo.Options.Filters).To(BeNil())
+ Expect(mockArtistRepo.Options.Filters).To(BeNil())
+ Expect(mockMediaFileRepo.Options.Filters).To(BeNil())
+ })
+
+ It("should return error for inaccessible musicFolderId", func() {
+ // Test that the endpoint returns an error when user tries to access a library they don't have access to
+ r := newGetRequest("query=test", "musicFolderId=999")
+ ctx := request.WithUser(r.Context(), model.User{
+ ID: "user1",
+ UserName: "testuser",
+ Libraries: []model.Library{{ID: 1, Name: "Library 1"}},
+ })
+ r = r.WithContext(ctx)
+
+ resp, err := router.Search3(r)
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("Library 999 not found or not accessible"))
+ Expect(resp).To(BeNil())
+ })
+ })
+ })
+})
diff --git a/server/subsonic/users.go b/server/subsonic/users.go
index 39214eee2..733f3fddb 100644
--- a/server/subsonic/users.go
+++ b/server/subsonic/users.go
@@ -7,6 +7,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
+ "github.com/navidrome/navidrome/utils/slice"
)
// buildUserResponse creates a User response object from a User model
@@ -19,6 +20,7 @@ func buildUserResponse(user model.User) responses.User {
ScrobblingEnabled: true,
DownloadRole: conf.Server.EnableDownloads,
ShareRole: conf.Server.EnableSharing,
+ Folder: slice.Map(user.Libraries, func(lib model.Library) int32 { return int32(lib.ID) }),
}
if conf.Server.Jukebox.Enabled {
@@ -28,7 +30,6 @@ func buildUserResponse(user model.User) responses.User {
return userResponse
}
-// TODO This is a placeholder. The real one has to read this info from a config file or the database
func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) {
loggedUser, ok := request.UserFrom(r.Context())
if !ok {
diff --git a/server/subsonic/users_test.go b/server/subsonic/users_test.go
index d08462290..e41c1af63 100644
--- a/server/subsonic/users_test.go
+++ b/server/subsonic/users_test.go
@@ -36,6 +36,12 @@ var _ = Describe("Users", func() {
conf.Server.EnableSharing = true
conf.Server.Jukebox.Enabled = false
+ // Set up user with libraries
+ testUser.Libraries = model.Libraries{
+ {ID: 10, Name: "Music"},
+ {ID: 20, Name: "Podcasts"},
+ }
+
// Create request with user in context
req := httptest.NewRequest("GET", "/rest/getUser", nil)
ctx := request.WithUser(context.Background(), testUser)
@@ -57,6 +63,7 @@ var _ = Describe("Users", func() {
Expect(userResponse.User.ScrobblingEnabled).To(BeTrue())
Expect(userResponse.User.DownloadRole).To(BeTrue())
Expect(userResponse.User.ShareRole).To(BeTrue())
+ Expect(userResponse.User.Folder).To(ContainElements(int32(10), int32(20)))
// Verify GetUsers response structure
Expect(usersResponse.Status).To(Equal(responses.StatusOK))
@@ -75,6 +82,7 @@ var _ = Describe("Users", func() {
Expect(singleUser.DownloadRole).To(Equal(userFromList.DownloadRole))
Expect(singleUser.ShareRole).To(Equal(userFromList.ShareRole))
Expect(singleUser.JukeboxRole).To(Equal(userFromList.JukeboxRole))
+ Expect(singleUser.Folder).To(Equal(userFromList.Folder))
})
})
@@ -93,4 +101,19 @@ var _ = Describe("Users", func() {
Entry("jukebox enabled, admin-only, regular user", true, true, false, false),
Entry("jukebox enabled, admin-only, admin user", true, true, true, true),
)
+
+ Describe("Folder list population", func() {
+ It("should populate Folder field with user's accessible library IDs", func() {
+ testUser.Libraries = model.Libraries{
+ {ID: 1, Name: "Music"},
+ {ID: 2, Name: "Podcasts"},
+ {ID: 5, Name: "Audiobooks"},
+ }
+
+ response := buildUserResponse(testUser)
+
+ Expect(response.Folder).To(HaveLen(3))
+ Expect(response.Folder).To(ContainElements(int32(1), int32(2), int32(5)))
+ })
+ })
})
diff --git a/server/testdata/test_cert.pem b/server/testdata/test_cert.pem
new file mode 100644
index 000000000..1dfa573d6
--- /dev/null
+++ b/server/testdata/test_cert.pem
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIIDwzCCAqugAwIBAgIUXqdUxUOo8kmsDe71iTR+Vr7btP8wDQYJKoZIhvcNAQEL
+BQAwYjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx
+EjAQBgNVBAoMCU5hdmlkcm9tZTENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJbG9j
+YWxob3N0MCAXDTI1MTEyODE5NTkxNVoYDzIxMjUxMTA0MTk1OTE1WjBiMQswCQYD
+VQQGEwJVUzENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDESMBAGA1UECgwJ
+TmF2aWRyb21lMQ0wCwYDVQQLDARUZXN0MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCkB/TQgl5ei5KRSHt5OJim8rKS
+MzRlkK4BjSEM4D9ESbebdpEVjX48QuBYACrCvgvVp7mQGF5anl8Hm89trvd8ooVQ
+x9IPQQ6gRKM+4gLrt9FHvFGGzZQS8UTQXN5oBi11E+8/Vs47HLUNXC2TRtRLCMyK
+LYXQIXbhdp9anImlt+IHUxIQUchK6Zkld/gCm56X1bbzN/Zq91PQLpx2FZ0eZTjN
+KaNgztLa+K/BDnTuk3iTTs9GEp6VCvqQE/6fk/UN/tkk2dLwKIFvPVR/YeAhVdz/
+OHC4L3B36QN3+VQ2yDjsp1PVAPX07UnzXO3Oj7uGYnMQxwprGMEubm3nADDxAgMB
+AAGjbzBtMB0GA1UdDgQWBBRAZHUVuLyzc0CfuZR9ApqMbawIqzAfBgNVHSMEGDAW
+gBRAZHUVuLyzc0CfuZR9ApqMbawIqzAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQT
+MBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAmDLXcPx9LNHs
+GxQIE6Q5BXbVO7c8qrWmJf5FK5VWaifNZ9U+IBi+VlB4jCLK/OkwsviN/jOnwRYx
+owjq0QG0YdRT4uD9fEMrAj+EwbnrQYZQvT0yGEWA+KW5TW08wt+/qnGJDwEgbjYJ
+HTdICVMhs/e8Ex48fAgO8WSsdTDekOrhuwzIfeJ1LU4ZptLsD2ePFxuzutdIuW51
+/mspQGsjXqZ1qnLsavLXh/lds2g602rTpYBNZVjV9WiOvaQS8vviOxBN6f+9vgRz
+a8SEbHqBG6jeyVqVZ7MjxcYxaIkxeBwMyMwgb+wwDfVXo2FZzX2TVeB7ZppI+IKv
+TXYurWPYsQ==
+-----END CERTIFICATE-----
diff --git a/server/testdata/test_cert_encrypted.pem b/server/testdata/test_cert_encrypted.pem
new file mode 100644
index 000000000..6f8de623a
--- /dev/null
+++ b/server/testdata/test_cert_encrypted.pem
@@ -0,0 +1,22 @@
+-----BEGIN CERTIFICATE-----
+MIIDpzCCAo+gAwIBAgIUEa7gEJYwJqYEJjTY7otQ+oUyELwwDQYJKoZIhvcNAQEL
+BQAwYjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx
+EjAQBgNVBAoMCU5hdmlkcm9tZTENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJbG9j
+YWxob3N0MCAXDTI1MTEyODE5NTI0OVoYDzIxMjUxMTA0MTk1MjQ5WjBiMQswCQYD
+VQQGEwJVUzENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDESMBAGA1UECgwJ
+TmF2aWRyb21lMQ0wCwYDVQQLDARUZXN0MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDBHgqJ1d9EnNxqoSZ6xXrIz/mV
+Y0nWJW16/qIAvCdovSeTZhG9iqG8dUqcuu2BdD9MMHndJ2oFn3iD8EJR92dH8KBA
+8xOmtZ0BEEWgXPBivywZVd1ChIflEWj6m5wwLNjb57SPpUiwaLxBQB8ByEaAAZE/
+bLqvHI3vW/4s5apky17SPIqmkmqEYlRcg97tlRXsPuwoAVM9cvLMMEqtIR1CB/72
+gboY2Gi2r/plLF/Rg3Dom6QljMWi57XXWJFwGYSXaZuM0gvn04e3oLu+1E+WMoq/
+9rExWij2DlsmXd/RiScliFp6R4H84wQUyqrAUNytvgRO+oVnRjEA0l3oCYdRAgMB
+AAGjUzBRMB0GA1UdDgQWBBQQKpB1UaKm98FnBdl8uKdRscrVTzAfBgNVHSMEGDAW
+gBQQKpB1UaKm98FnBdl8uKdRscrVTzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
+DQEBCwUAA4IBAQBP07l+2LmpFtcxqMGmsiNYwFuHpQCxJd4YRZHjLX7O+oJExMgR
+2yP4mpMKurgKOv7unTDLwvjQRa6ZTYJCsYtvC6hbyqlGc7AfNTu6DKz8r35/2/V5
+hPsG5lNb91HhvHE839mLAvpi02LoFH2Sr8BR7s6qxfNKYcP8PUOJQXltJ6yAa8YJ
+syeXQQ3RIyGsJANeaC06S3UdkBM5H5BLfIHnHu3GybJjwL51va4WCdHe8QV6GI0g
+RDiThDVkBSXAr136vnMdlrYCxMoxY56itJ0zbYg2ELQKU9o1w/ZJQo9uvmy9jCoZ
+Hy1L5a2vUDbsdONdvRkYZRHqMpG4bdD8D3j2
+-----END CERTIFICATE-----
diff --git a/server/testdata/test_key.pem b/server/testdata/test_key.pem
new file mode 100644
index 000000000..bac61f4a4
--- /dev/null
+++ b/server/testdata/test_key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCkB/TQgl5ei5KR
+SHt5OJim8rKSMzRlkK4BjSEM4D9ESbebdpEVjX48QuBYACrCvgvVp7mQGF5anl8H
+m89trvd8ooVQx9IPQQ6gRKM+4gLrt9FHvFGGzZQS8UTQXN5oBi11E+8/Vs47HLUN
+XC2TRtRLCMyKLYXQIXbhdp9anImlt+IHUxIQUchK6Zkld/gCm56X1bbzN/Zq91PQ
+Lpx2FZ0eZTjNKaNgztLa+K/BDnTuk3iTTs9GEp6VCvqQE/6fk/UN/tkk2dLwKIFv
+PVR/YeAhVdz/OHC4L3B36QN3+VQ2yDjsp1PVAPX07UnzXO3Oj7uGYnMQxwprGMEu
+bm3nADDxAgMBAAECggEABqJFvesP2v4FEvgd+kSWM+ZL34rPmy3zQ5/MDuPA20ep
+89EjQ/5hdRl1TknPcOnTu7PZVuENa9fM2xdrl7GEU9eU0bQLJE/KwiOUgJYObS8V
+eTO+DlghHXUBhfXDjux1CS+htOuTUqOyFNS+CR9Lta8o6ou1xjmcP7kW78i17mxF
+TuH5SZlS8W9PFLXHCInbMtqGFaT2ss09kvoPk2FDvHfxEdy6M9tKkguz02g+4bqI
+aAMp2N7AOfmRpC0HvVa1ZfZo5Z8/KMoNcIm3pV9DEVM369J9EzhnMNpkGben90aT
+FqO2JNsy52wmXFZUc9xe8uPdfDahALCkBGncLyLNmQKBgQDZREjocjdzOoPSlCdx
+mRNe9suHz2FpUpsHCPOCotG63hFVKpah/ZvpHSsQx5rXs/mawDTmzGY9GQiBrSvg
+OhfHIyT3NOhVaNcMxTqJX7rs7OG8D0MBacD9ASSeZ89MUn8q1EHZr5qxLtXl5Ikw
+mHtiGRdiKGFFrG9H0zncbGhy7QKBgQDBRhQ9RAasTdmUiNQly9GVFkXto4T/9UHx
+rVU44htCI2IVZUMTGlNfclfxpByDrzyA56rMzN9SAkiIp4nPpMDs5hayXaaPoojs
+CPzV7r2OjemZ6CTeQ1ODImRL8L/E3jJSgWd6YYoHSQ5hjEX4yT6ft0u0tZUfdMKd
+VENWIJ/hlQKBgQCo2hXjeOi5R8+tN3EUKwhP9HOnX7dv+D/9jqpZa5qdpPpJeyjI
+SmYCHKYci1Q+sWOaLiiu+km20B65UVFZGSzjmd+fs+GghzMifKGKo/iNK2ggFKhZ
+j8vplRrVdQ45XZ/xNDbdLEmHzEN2QE+Skd7KFYADzCgU0vdFFdbRBPuD3QKBgGIq
+fQctMRJ9LCE0akSURGwr9vKflmMHKCpfdqTAu0WZgS0K1Mm0GlqlUiPKzizYaauz
+f14sRNV7kWnPZsDPlqn8p9SKmpnj3RW97uWeMCtiyx6/+VHm8ljts/GaY1zT2s1r
+KqrPNfNDWQmU3MljNeqbh9lOTWK/xEVy0gzB31MNAoGAQNWrZvVdAbL95XW6STUu
+JmQlqJTlluuqS0Rrd/uVEQwW0Vd1dZjRQcFAFiSiCQWTbtId5gFZd6hiIQl53Xz0
+5cd+9mcyA/TaoCJYbMOFYsKbZMCBhefsovJlVQXedqJrIY6BdeGlet4GTAH5Qyl0
+ytEIUnvn5YmmbI7PDz80XpU=
+-----END PRIVATE KEY-----
diff --git a/server/testdata/test_key_encrypted.pem b/server/testdata/test_key_encrypted.pem
new file mode 100644
index 000000000..0ac715890
--- /dev/null
+++ b/server/testdata/test_key_encrypted.pem
@@ -0,0 +1,30 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQPH9PYzryCI3smm81
+J8rm+QICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEI+9XxNfKSiMYIVB
+UfcGfncEggTQVw7tPslGy3mlofCNnhBSnMViv9kj6M11smD6Y8vHG0k9Kq+6g+Dx
+mQE9ILrSZBzM0uS3y484u+vkdqlT4KehhjIx0IiezurOcM45UdTAwLFLPzeEDlHI
+lOWQ3gOTB3J5AxiUQOa6QsDIM7AZilidQG0BxQYWyRBA5B8evJwJoAvdzzA9wGSm
+2YdNm3tA6rU5U8cVG+qTJP9pjbtRx0medC/CBZdxGkrWBQH+aySfahJdU8X1JI2e
+SY4WJRw1rLCow+DnHjZS/IVHFJivJSRYvnvw8fwjOMVtkf+dAVctKlb1Fj9X+RdG
+T1sq3i6zwFLE/RRz4qM4DKZ6UaD9wRFLow8FmNWVuJJiPgCLx2rrNMe32quS/kQP
+iOsXAUeA/Yg1fdMCJORxl0nWDmLYcNtBghCmS1lyk+t+AKWwJudrds5tQQe8ha2t
+Q41is+tDKwGDC1wt4WXJvBhgAJzuqFtr30H0M1eBhwwDdaDd9v0Zr3r8V49WZM2c
+i3qkwPPYkQD+pOcR12xBV8ptvDxaUl7RGlVqnEWHagT51BaIaXQ9teUrG6UPt8o2
+LELJXF6CiwkbN6Y9sYx5XiKrIGxVhlQSZ1nB3XSFRHbu6e7VHPjnVwUeeg87J2Am
+MEwqDzPU5sjKRn84+M91Y4uFAIeinaOJAQ0/tZVrf1iSeCMQyMUhW/8m7JPfG19F
+NbJSPRXQuKmYKbWfXcMW2UFbp0zDs7s7p4zzbfde9IbVdq/o2nv3ZrNbrLak6O7y
+FVt9q/xG4Tty6hSK6xtqtNZWcmfiMcTlk1Qcz2STvScbXtqgcgR6WUZfkLuzi09I
+EDYFnzU5JNSY3U3VTv2hAPeU4xjTNM6kjF7L9JFGvdjH8Ko9UdxG9RZMd8xhBM/n
+hxdzdVba4bDDz2z+0A2blSObrPrNsKr/3ZbnfuUiSs5NmqmUOifZ1t1PqGGO2Y5S
+/cDKtrPk226hGomsUBfHtiIJPG1VRl4UaZiduqK3GGhtF491KU1mAfYzueok3TPq
+JhLtLDIvEaFgmOmitFzROI/ifm6s4ssUvcvtbjwJumbjkU38OxYZFwbhwbe268G2
+vgspJamlEGJNdGDzrCFQlA2+A9kazCttztikfh5QGV6WFfkc3Bt1XTPL51vtliQy
+MS2gUnJUY2fuYCfz8rxLH1kQmyYsHQz5rUYyBkeDffrG9MzarmzSJXR63FRzVMf1
+LQ7BSzei7dF6+J4KVCxjbGWF3GUGmGeOP5g5vJ3xb3YPJNJLT4Vai103pay59TGP
+tESM2Vn0gJEvYApi707noFH5uFTW1cp7lloF41ddIUkL/QO7j+sjvBww+4DqBB7J
+BmvLMnswa23yw9egYRG5jOXyCgIr+1rnNcph1HGJsvxvgJ2gwwo5NKCG8SC6LcZQ
+fbDjX+ssmobLE3ktN03FZPMp32/ciexzuZoamfyiPXh7xE++ckifNEKJlNhx+kCG
+mSR2wh+UGigQkgp/JxOzl6C4fhUbrEZr17oBqGim2p8h+GE0zD5JSHcn1rP86gGU
+8JG/ilG4I8uMxUwhGj7amrWXUlJBd1by7e1EAL+utCo14/Tx3otB9/JtqY+lm9Ey
+1ptPhMRQxvDNWrCmYM2kyrGghdNfEMir6GKDWI6PY9cwAFv/PLOxr1c=
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/server/testdata/test_key_encrypted_legacy.pem b/server/testdata/test_key_encrypted_legacy.pem
new file mode 100644
index 000000000..4b9215cdf
--- /dev/null
+++ b/server/testdata/test_key_encrypted_legacy.pem
@@ -0,0 +1,30 @@
+-----BEGIN RSA PRIVATE KEY-----
+Proc-Type: 4,ENCRYPTED
+DEK-Info: AES-256-CBC,3C969050EAB73F121B7F0E6B75C42525
+
+V6pSaAsrn9CQNo4p88QshJLbg8zkQJEom81dPbYSVqQSZa9YlPtpLZ9YtuLj/Ay0
+TScEKIj/gzQ32wNl6nhcSNIL9yy+X11r5gNv1kIHkecf+EbDW20VOiJsfD+6LUyW
+hA96AIbPOwc76iCuvsKHPKU9MlEmjGipmk/C2RQLHCZJ3WkiDRgCM8KQ7vKhfACT
+w908yj4cB1e/P0JPq8t/3F7kPJ+6SVM1vMEffHl0otQR3rAyrK8QikwJ0K9qX62d
+cqchTVlEyyZBYovR8DrRRUDbsXS5j1ZmX3NQpvTSTFowr+33fMrY+4Oz8sdR4yx1
+CQc0A0sHHxSEIr2xu4KzczwOYVJN8PVdU0pgvFj9KEm66N6EY5CSFIBHyO/ycOt9
+U+wpkRjf3zS6ZaUU0NKdOcop4YX33i99/tZF2RNR1i7ETLYph+/LCf09286Bi3u/
+UCCuWedyECPdz0c6j0s27Fdfc/HEK90OEzeWh/fc+H2gJZhqJYK9V47HPTQNNMnB
+U1a6FsJlrKE3E6nfSnTLxrSx9m/XTV7HV+HkgX+q8VhN7Q2VHUqkPzE7ZOPYpZ+A
+dQzsm1TmEMxym6osYqFzQScXR1NZasrV2MTQ2J16dUgCdGAM2YMUD9JaoJR+u77M
+WAjYzDiRg84rLr/KbJPAwHbsfo2KpiapJGSBBEDhz4W1/LOrFhsjaqIMSy4yZDGm
+1KqXGHIlqmuHI7v4fD8vuzhj7GUujRx85HSZWakE/uc6s5WrhkSeVKYJWPfpsxTv
+dT3oLOGJ+nRzWxM3aFtuJghX0nIGdKxT4EAUNXz0/vLT3OP1QCZR+oELrriFzmtj
++O30bGH2SAFZEQJ/uTQg6celoNh89IzH4DJkcn67hqpX6mUiU9CrIr/eR9C/en8Q
+smTbbC1C1pDUaCwR26Z+zgM90amh4yfOFKK2geO2Kj+TmwFHUvi6ZnSzMzCvty3t
++wdIrUtf55Lw51JCpLGl70mg4b/zBj5hqBkU2YvAAnz/htjfH/wrD6ZAF1TCdlRO
+gyODrJjGRnLd/v0XLk0wp+RkAjBcSlRlkUvZY5BtugL7dIdwiNGGQPcOni9IVeG0
+6vDUEQnDOLYDj4d/JcckTLuHdrP+SW+0RQl2HK5+/w1hScGXN4O48gccu7yR/MN8
+DmpCg5rD/nq8sxJosmSt07GrN36KppYt8LCXQbSg3NG2Ad715caS2C+0Qtdm5MPD
+rM1UyTXQYSJXgUN9yZS/pmzlguCywnnvsBPU6j3ljZwcoD41QJ/1OU09/W6sIMQR
+IAiM35JHiLJiccFgxSE1qx5F1UZqX4P47jF0Wzi/sE/DYXg5qw2DoauqXNzqnumH
+71UDGK1V6wQIV7UCZDa0WUfFzu470XpuFb8VmMOuHSQxkZESc9cz8k/ueAuO438Q
+jnlkF1Ge2EEPuaK2zeaTj/lGyYA1AUfHRRgt/EMUQSBntmhlpnwVPYTVvYtHO2N5
+wp7/y39KirnlTl99i3XiOJ4WF4gIU2IaSlqMo4+e/A32h2JFi9QfNyfItXe6Fm1X
+d0j2XGHzwMfHEFKdWyrgtVZwc38/1d6xWYAhs02b2basV/0AQhFTaKf5Z268eBNJ
+-----END RSA PRIVATE KEY-----
diff --git a/tests/fixtures/bom-test.lrc b/tests/fixtures/bom-test.lrc
new file mode 100644
index 000000000..223c37de0
--- /dev/null
+++ b/tests/fixtures/bom-test.lrc
@@ -0,0 +1,4 @@
+[00:00.00] 作曲 : 柏大輔
+NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at byte 0.
+This tests BOM handling in lyrics parsing (GitHub issue #4631).
+The BOM bytes are: 0xEF 0xBB 0xBF
\ No newline at end of file
diff --git a/tests/fixtures/bom-utf16-test.lrc b/tests/fixtures/bom-utf16-test.lrc
new file mode 100644
index 000000000..e40ea3255
Binary files /dev/null and b/tests/fixtures/bom-utf16-test.lrc differ
diff --git a/tests/fixtures/deezer.artist.bio.json b/tests/fixtures/deezer.artist.bio.json
new file mode 100644
index 000000000..80e439bae
--- /dev/null
+++ b/tests/fixtures/deezer.artist.bio.json
@@ -0,0 +1,9 @@
+{
+ "data": {
+ "artist": {
+ "bio": {
+ "full": "Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act.
"
+ }
+ }
+ }
+}
diff --git a/tests/fixtures/deezer.artist.related.json b/tests/fixtures/deezer.artist.related.json
new file mode 100644
index 000000000..2a55b303e
--- /dev/null
+++ b/tests/fixtures/deezer.artist.related.json
@@ -0,0 +1 @@
+{"data":[{"id":6404,"name":"Justice","link":"https:\/\/www.deezer.com\/artist\/6404","picture":"https:\/\/api.deezer.com\/artist\/6404\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/1000x1000-000000-80-0-0.jpg","nb_album":41,"nb_fan":774236,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/6404\/top?limit=50","type":"artist"},{"id":2049,"name":"Cassius","link":"https:\/\/www.deezer.com\/artist\/2049","picture":"https:\/\/api.deezer.com\/artist\/2049\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/1000x1000-000000-80-0-0.jpg","nb_album":25,"nb_fan":127692,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2049\/top?limit=50","type":"artist"},{"id":2318,"name":"Etienne de Cr\u00e9cy","link":"https:\/\/www.deezer.com\/artist\/2318","picture":"https:\/\/api.deezer.com\/artist\/2318\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/1000x1000-000000-80-0-0.jpg","nb_album":58,"nb_fan":104626,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2318\/top?limit=50","type":"artist"},{"id":72041,"name":"Yuksek","link":"https:\/\/www.deezer.com\/artist\/72041","picture":"https:\/\/api.deezer.com\/artist\/72041\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/1000x1000-000000-80-0-0.jpg","nb_album":102,"nb_fan":115772,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/72041\/top?limit=50","type":"artist"},{"id":81,"name":"The Chemical Brothers","link":"https:\/\/www.deezer.com\/artist\/81","picture":"https:\/\/api.deezer.com\/artist\/81\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/1000x1000-000000-80-0-0.jpg","nb_album":83,"nb_fan":1433333,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/81\/top?limit=50","type":"artist"},{"id":3771,"name":"Mr. Oizo","link":"https:\/\/www.deezer.com\/artist\/3771","picture":"https:\/\/api.deezer.com\/artist\/3771\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/1000x1000-000000-80-0-0.jpg","nb_album":31,"nb_fan":172085,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/3771\/top?limit=50","type":"artist"},{"id":9905,"name":"Alex Gopher","link":"https:\/\/www.deezer.com\/artist\/9905","picture":"https:\/\/api.deezer.com\/artist\/9905\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/1000x1000-000000-80-0-0.jpg","nb_album":46,"nb_fan":10430,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/9905\/top?limit=50","type":"artist"},{"id":7914,"name":"Demon","link":"https:\/\/www.deezer.com\/artist\/7914","picture":"https:\/\/api.deezer.com\/artist\/7914\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/1000x1000-000000-80-0-0.jpg","nb_album":21,"nb_fan":9286,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7914\/top?limit=50","type":"artist"},{"id":8937,"name":"SebastiAn","link":"https:\/\/www.deezer.com\/artist\/8937","picture":"https:\/\/api.deezer.com\/artist\/8937\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/1000x1000-000000-80-0-0.jpg","nb_album":48,"nb_fan":74884,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/8937\/top?limit=50","type":"artist"},{"id":2508,"name":"Digitalism","link":"https:\/\/www.deezer.com\/artist\/2508","picture":"https:\/\/api.deezer.com\/artist\/2508\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/1000x1000-000000-80-0-0.jpg","nb_album":79,"nb_fan":158628,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2508\/top?limit=50","type":"artist"},{"id":11703,"name":"Alan Braxe","link":"https:\/\/www.deezer.com\/artist\/11703","picture":"https:\/\/api.deezer.com\/artist\/11703\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/1000x1000-000000-80-0-0.jpg","nb_album":25,"nb_fan":12595,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11703\/top?limit=50","type":"artist"},{"id":574,"name":"Para One","link":"https:\/\/www.deezer.com\/artist\/574","picture":"https:\/\/api.deezer.com\/artist\/574\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/1000x1000-000000-80-0-0.jpg","nb_album":40,"nb_fan":30828,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/574\/top?limit=50","type":"artist"},{"id":4397,"name":"Kojak","link":"https:\/\/www.deezer.com\/artist\/4397","picture":"https:\/\/api.deezer.com\/artist\/4397\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/1000x1000-000000-80-0-0.jpg","nb_album":55,"nb_fan":1522,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/4397\/top?limit=50","type":"artist"},{"id":12439,"name":"Busy P","link":"https:\/\/www.deezer.com\/artist\/12439","picture":"https:\/\/api.deezer.com\/artist\/12439\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/1000x1000-000000-80-0-0.jpg","nb_album":12,"nb_fan":65585,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/12439\/top?limit=50","type":"artist"},{"id":11656979,"name":"Mr Flash","link":"https:\/\/www.deezer.com\/artist\/11656979","picture":"https:\/\/api.deezer.com\/artist\/11656979\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/1000x1000-000000-80-0-0.jpg","nb_album":7,"nb_fan":769,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11656979\/top?limit=50","type":"artist"},{"id":76,"name":"Fatboy Slim","link":"https:\/\/www.deezer.com\/artist\/76","picture":"https:\/\/api.deezer.com\/artist\/76\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/1000x1000-000000-80-0-0.jpg","nb_album":76,"nb_fan":1231355,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/76\/top?limit=50","type":"artist"},{"id":11265,"name":"Lifelike","link":"https:\/\/www.deezer.com\/artist\/11265","picture":"https:\/\/api.deezer.com\/artist\/11265\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/1000x1000-000000-80-0-0.jpg","nb_album":38,"nb_fan":8316,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11265\/top?limit=50","type":"artist"},{"id":2048,"name":"Groove Armada","link":"https:\/\/www.deezer.com\/artist\/2048","picture":"https:\/\/api.deezer.com\/artist\/2048\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/1000x1000-000000-80-0-0.jpg","nb_album":92,"nb_fan":173879,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2048\/top?limit=50","type":"artist"},{"id":71708,"name":"Surkin","link":"https:\/\/www.deezer.com\/artist\/71708","picture":"https:\/\/api.deezer.com\/artist\/71708\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/1000x1000-000000-80-0-0.jpg","nb_album":15,"nb_fan":23101,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/71708\/top?limit=50","type":"artist"},{"id":166713,"name":"Fred Falke","link":"https:\/\/www.deezer.com\/artist\/166713","picture":"https:\/\/api.deezer.com\/artist\/166713\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/1000x1000-000000-80-0-0.jpg","nb_album":67,"nb_fan":9688,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/166713\/top?limit=50","type":"artist"}],"total":20}
\ No newline at end of file
diff --git a/tests/fixtures/deezer.artist.top.json b/tests/fixtures/deezer.artist.top.json
new file mode 100644
index 000000000..e3f22a1aa
--- /dev/null
+++ b/tests/fixtures/deezer.artist.top.json
@@ -0,0 +1 @@
+{"data":[{"id":67238732,"readable":true,"title":"Instant Crush (feat. Julian Casablancas)","title_short":"Instant Crush","title_version":"(feat. Julian Casablancas)","link":"https:\/\/www.deezer.com\/track\/67238732","duration":337,"rank":944042,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/d\/6\/b\/0\/d6bc80aadfa1d7625d59a6620f229371.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/d\/6\/b\/0\/d6bc80aadfa1d7625d59a6620f229371.mp3*~data=user_id=0,application_id=42~hmac=66213cecf953c7ef8b4d89e3539a1355d318679c5ab54cac2007d4effa6c3bf4","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":295821,"name":"Julian Casablancas","link":"https:\/\/www.deezer.com\/artist\/295821","share":"https:\/\/www.deezer.com\/artist\/295821?utm_source=deezer&utm_content=artist-295821&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/295821\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/295821\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"311bba0fc112d15f72c8b5a65f0456c1","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6575789,"title":"Random Access Memories","cover":"https:\/\/api.deezer.com\/album\/6575789\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/1000x1000-000000-80-0-0.jpg","md5_image":"311bba0fc112d15f72c8b5a65f0456c1","tracklist":"https:\/\/api.deezer.com\/album\/6575789\/tracks","type":"album"},"type":"track"},{"id":3135553,"readable":true,"title":"One More Time","title_short":"One More Time","title_version":"","link":"https:\/\/www.deezer.com\/track\/3135553","duration":320,"rank":888570,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/f\/8\/c\/0\/f8c5dc3837912dba37c9a1ab3170cc3f.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/f\/8\/c\/0\/f8c5dc3837912dba37c9a1ab3170cc3f.mp3*~data=user_id=0,application_id=42~hmac=0824ec7ad045b82c04904fcd5f2a8ec2175acbe3d1649030d457023fdef45620","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"5718f7c81c27e0b2417e2a4c45224f8a","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":302127,"title":"Discovery","cover":"https:\/\/api.deezer.com\/album\/302127\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/1000x1000-000000-80-0-0.jpg","md5_image":"5718f7c81c27e0b2417e2a4c45224f8a","tracklist":"https:\/\/api.deezer.com\/album\/302127\/tracks","type":"album"},"type":"track"},{"id":66609426,"readable":true,"title":"Get Lucky (Radio Edit - feat. Pharrell Williams and Nile Rodgers)","title_short":"Get Lucky","title_version":"(Radio Edit - feat. Pharrell Williams and Nile Rodgers)","link":"https:\/\/www.deezer.com\/track\/66609426","duration":248,"rank":952197,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/1\/b\/f\/0\/1bf80a82992903ff685ba1b7275223f8.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/1\/b\/f\/0\/1bf80a82992903ff685ba1b7275223f8.mp3*~data=user_id=0,application_id=42~hmac=c6dfe58571df62f41e7b326dd9afebf87015541c06a521ebc88fc18671d8d06d","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":103,"name":"Pharrell Williams","link":"https:\/\/www.deezer.com\/artist\/103","share":"https:\/\/www.deezer.com\/artist\/103?utm_source=deezer&utm_content=artist-103&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/103\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/103\/top?limit=50","type":"artist","role":"Main"},{"id":7207,"name":"Nile Rodgers","link":"https:\/\/www.deezer.com\/artist\/7207","share":"https:\/\/www.deezer.com\/artist\/7207?utm_source=deezer&utm_content=artist-7207&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/7207\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7207\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"bc49adb87758e0c8c4e508a9c5cce85d","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6516139,"title":"Get Lucky (Radio Edit - feat. Pharrell Williams and Nile Rodgers)","cover":"https:\/\/api.deezer.com\/album\/6516139\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/1000x1000-000000-80-0-0.jpg","md5_image":"bc49adb87758e0c8c4e508a9c5cce85d","tracklist":"https:\/\/api.deezer.com\/album\/6516139\/tracks","type":"album"},"type":"track"},{"id":67238735,"readable":true,"title":"Get Lucky (feat. Pharrell Williams and Nile Rodgers)","title_short":"Get Lucky","title_version":"(feat. Pharrell Williams and Nile Rodgers)","link":"https:\/\/www.deezer.com\/track\/67238735","duration":367,"rank":873875,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/c\/8\/a\/0\/c8a61130657a2cf58e3ac751e7950617.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/c\/8\/a\/0\/c8a61130657a2cf58e3ac751e7950617.mp3*~data=user_id=0,application_id=42~hmac=92002e6bade5ff82dd44751e8998beaa60844210df1d73b8f1bf7dafb02dc5c3","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":103,"name":"Pharrell Williams","link":"https:\/\/www.deezer.com\/artist\/103","share":"https:\/\/www.deezer.com\/artist\/103?utm_source=deezer&utm_content=artist-103&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/103\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/103\/top?limit=50","type":"artist","role":"Main"},{"id":7207,"name":"Nile Rodgers","link":"https:\/\/www.deezer.com\/artist\/7207","share":"https:\/\/www.deezer.com\/artist\/7207?utm_source=deezer&utm_content=artist-7207&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/7207\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7207\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"311bba0fc112d15f72c8b5a65f0456c1","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6575789,"title":"Random Access Memories","cover":"https:\/\/api.deezer.com\/album\/6575789\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/1000x1000-000000-80-0-0.jpg","md5_image":"311bba0fc112d15f72c8b5a65f0456c1","tracklist":"https:\/\/api.deezer.com\/album\/6575789\/tracks","type":"album"},"type":"track"},{"id":3129775,"readable":true,"title":"Around the World","title_short":"Around the World","title_version":"","link":"https:\/\/www.deezer.com\/track\/3129775","duration":429,"rank":829911,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/a\/4\/7\/0\/a47dbed01e6d9b0ac4e39a134f745ca2.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/a\/4\/7\/0\/a47dbed01e6d9b0ac4e39a134f745ca2.mp3*~data=user_id=0,application_id=42~hmac=9b7aa12b647cabd3219779e0270e51e639dc326442071fceb6d723c331059a67","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"b870579c8650cd59b1cce656dde2ef17","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":301775,"title":"Homework","cover":"https:\/\/api.deezer.com\/album\/301775\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/1000x1000-000000-80-0-0.jpg","md5_image":"b870579c8650cd59b1cce656dde2ef17","tracklist":"https:\/\/api.deezer.com\/album\/301775\/tracks","type":"album"},"type":"track"}],"total":100,"next":"https:\/\/api.deezer.com\/artist\/27\/top?index=5"}
\ No newline at end of file
diff --git a/tests/fixtures/lastfm.artist.page.html b/tests/fixtures/lastfm.artist.page.html
new file mode 100644
index 000000000..1922e313b
--- /dev/null
+++ b/tests/fixtures/lastfm.artist.page.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/fixtures/lastfm.artist.page.ignored.html b/tests/fixtures/lastfm.artist.page.ignored.html
new file mode 100644
index 000000000..96eda2377
--- /dev/null
+++ b/tests/fixtures/lastfm.artist.page.ignored.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/fixtures/lastfm.artist.page.no_meta.html b/tests/fixtures/lastfm.artist.page.no_meta.html
new file mode 100644
index 000000000..aa7b9c934
--- /dev/null
+++ b/tests/fixtures/lastfm.artist.page.no_meta.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/fixtures/mixed-lyrics.flac b/tests/fixtures/mixed-lyrics.flac
new file mode 100644
index 000000000..d048234f5
Binary files /dev/null and b/tests/fixtures/mixed-lyrics.flac differ
diff --git a/tests/fixtures/playlists/bom-test-utf16.m3u b/tests/fixtures/playlists/bom-test-utf16.m3u
new file mode 100644
index 000000000..9c2e9d599
Binary files /dev/null and b/tests/fixtures/playlists/bom-test-utf16.m3u differ
diff --git a/tests/fixtures/playlists/bom-test.m3u b/tests/fixtures/playlists/bom-test.m3u
new file mode 100644
index 000000000..f5a00806c
--- /dev/null
+++ b/tests/fixtures/playlists/bom-test.m3u
@@ -0,0 +1,6 @@
+#EXTM3U
+# NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at the beginning
+# (bytes 0xEF 0xBB 0xBF) to test BOM handling in playlist parsing.
+#PLAYLIST:Test Playlist
+#EXTINF:123,Test Artist - Test Song
+test.mp3
diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go
index 58c33c97f..642ce6b41 100644
--- a/tests/mock_album_repo.go
+++ b/tests/mock_album_repo.go
@@ -16,10 +16,11 @@ func CreateMockAlbumRepo() *MockAlbumRepo {
type MockAlbumRepo struct {
model.AlbumRepository
- Data map[string]*model.Album
- All model.Albums
- Err bool
- Options model.QueryOptions
+ Data map[string]*model.Album
+ All model.Albums
+ Err bool
+ Options model.QueryOptions
+ ReassignAnnotationCalls map[string]string // prevID -> newID
}
func (m *MockAlbumRepo) SetError(err bool) {
@@ -117,4 +118,44 @@ func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error {
return nil
}
+func (m *MockAlbumRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) {
+ if len(options) > 0 {
+ m.Options = options[0]
+ }
+ if m.Err {
+ return nil, errors.New("unexpected error")
+ }
+ // Simple mock implementation - just return all albums for testing
+ return m.All, nil
+}
+
+// ReassignAnnotation reassigns annotations from one album to another
+func (m *MockAlbumRepo) ReassignAnnotation(prevID string, newID string) error {
+ if m.Err {
+ return errors.New("unexpected error")
+ }
+ // Mock implementation - track the reassignment calls
+ if m.ReassignAnnotationCalls == nil {
+ m.ReassignAnnotationCalls = make(map[string]string)
+ }
+ m.ReassignAnnotationCalls[prevID] = newID
+ return nil
+}
+
+// SetRating sets the rating for an album
+func (m *MockAlbumRepo) SetRating(rating int, itemID string) error {
+ if m.Err {
+ return errors.New("unexpected error")
+ }
+ return nil
+}
+
+// SetStar sets the starred status for albums
+func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error {
+ if m.Err {
+ return errors.New("unexpected error")
+ }
+ return nil
+}
+
var _ model.AlbumRepository = (*MockAlbumRepo)(nil)
diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go
index da5851061..6d4792f83 100644
--- a/tests/mock_artist_repo.go
+++ b/tests/mock_artist_repo.go
@@ -16,8 +16,9 @@ func CreateMockArtistRepo() *MockArtistRepo {
type MockArtistRepo struct {
model.ArtistRepository
- Data map[string]*model.Artist
- Err bool
+ Data map[string]*model.Artist
+ Err bool
+ Options model.QueryOptions
}
func (m *MockArtistRepo) SetError(err bool) {
@@ -73,6 +74,9 @@ func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error {
}
func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
+ if len(options) > 0 {
+ m.Options = options[0]
+ }
if m.Err {
return nil, errors.New("mock repo error")
}
@@ -108,4 +112,49 @@ func (m *MockArtistRepo) RefreshPlayCounts() (int64, error) {
return int64(len(m.Data)), nil
}
+func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) {
+ if m.Err {
+ return nil, errors.New("mock repo error")
+ }
+
+ artists, err := m.GetAll()
+ if err != nil {
+ return nil, err
+ }
+
+ // For mock purposes, if no artists available, return empty result
+ if len(artists) == 0 {
+ return model.ArtistIndexes{}, nil
+ }
+
+ // Simple index grouping by first letter (simplified implementation for mocks)
+ indexMap := make(map[string]model.Artists)
+ for _, artist := range artists {
+ key := "#"
+ if len(artist.Name) > 0 {
+ key = string(artist.Name[0])
+ }
+ indexMap[key] = append(indexMap[key], artist)
+ }
+
+ var result model.ArtistIndexes
+ for k, artists := range indexMap {
+ result = append(result, model.ArtistIndex{ID: k, Artists: artists})
+ }
+
+ return result, nil
+}
+
+func (m *MockArtistRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) {
+ if len(options) > 0 {
+ m.Options = options[0]
+ }
+ if m.Err {
+ return nil, errors.New("unexpected error")
+ }
+ // Simple mock implementation - just return all artists for testing
+ allArtists, err := m.GetAll()
+ return allArtists, err
+}
+
var _ model.ArtistRepository = (*MockArtistRepo)(nil)
diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go
index 02a03e56e..ba586ab53 100644
--- a/tests/mock_data_store.go
+++ b/tests/mock_data_store.go
@@ -27,6 +27,11 @@ type MockDataStore struct {
MockedScrobbleBuffer model.ScrobbleBufferRepository
MockedRadio model.RadioRepository
scrobbleBufferMu sync.Mutex
+ repoMu sync.Mutex
+
+ // GC tracking
+ GCCalled bool
+ GCError error
}
func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository {
@@ -85,6 +90,8 @@ func (db *MockDataStore) Artist(ctx context.Context) model.ArtistRepository {
}
func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
+ db.repoMu.Lock()
+ defer db.repoMu.Unlock()
if db.MockedMediaFile == nil {
if db.RealDS != nil {
db.MockedMediaFile = db.RealDS.MediaFile(ctx)
@@ -255,6 +262,10 @@ func (db *MockDataStore) Resource(ctx context.Context, m any) model.ResourceRepo
}
}
-func (db *MockDataStore) GC(context.Context) error {
+func (db *MockDataStore) GC(context.Context, ...int) error {
+ db.GCCalled = true
+ if db.GCError != nil {
+ return db.GCError
+ }
return nil
}
diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go
index 7cc8b02f7..4d7539aa9 100644
--- a/tests/mock_library_repo.go
+++ b/tests/mock_library_repo.go
@@ -1,14 +1,22 @@
package tests
import (
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+ "strconv"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
- "golang.org/x/exp/maps"
)
type MockLibraryRepo struct {
model.LibraryRepository
- Data map[int]model.Library
- Err error
+ Data map[int]model.Library
+ Err error
+ PutFn func(*model.Library) error // Allow custom Put behavior for testing
}
func (m *MockLibraryRepo) SetData(data model.Libraries) {
@@ -22,7 +30,54 @@ func (m *MockLibraryRepo) GetAll(...model.QueryOptions) (model.Libraries, error)
if m.Err != nil {
return nil, m.Err
}
- return maps.Values(m.Data), nil
+ var libraries model.Libraries
+ for _, lib := range m.Data {
+ libraries = append(libraries, lib)
+ }
+ // Sort by ID for predictable order
+ slices.SortFunc(libraries, func(a, b model.Library) int {
+ return a.ID - b.ID
+ })
+ return libraries, nil
+}
+
+func (m *MockLibraryRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
+ if m.Err != nil {
+ return 0, m.Err
+ }
+
+ // If no query options, return total count
+ if len(qo) == 0 || qo[0].Filters == nil {
+ return int64(len(m.Data)), nil
+ }
+
+ // Handle squirrel.Eq filter for ID validation
+ if eq, ok := qo[0].Filters.(squirrel.Eq); ok {
+ if idFilter, exists := eq["id"]; exists {
+ if ids, isSlice := idFilter.([]int); isSlice {
+ count := 0
+ for _, id := range ids {
+ if _, exists := m.Data[id]; exists {
+ count++
+ }
+ }
+ return int64(count), nil
+ }
+ }
+ }
+
+ // Default to total count for other filters
+ return int64(len(m.Data)), nil
+}
+
+func (m *MockLibraryRepo) Get(id int) (*model.Library, error) {
+ if m.Err != nil {
+ return nil, m.Err
+ }
+ if lib, ok := m.Data[id]; ok {
+ return &lib, nil
+ }
+ return nil, model.ErrNotFound
}
func (m *MockLibraryRepo) GetPath(id int) (string, error) {
@@ -35,8 +90,223 @@ func (m *MockLibraryRepo) GetPath(id int) (string, error) {
return "", model.ErrNotFound
}
+func (m *MockLibraryRepo) Put(library *model.Library) error {
+ if m.PutFn != nil {
+ return m.PutFn(library)
+ }
+ if m.Err != nil {
+ return m.Err
+ }
+ if m.Data == nil {
+ m.Data = make(map[int]model.Library)
+ }
+ m.Data[library.ID] = *library
+ return nil
+}
+
+func (m *MockLibraryRepo) Delete(id int) error {
+ if m.Err != nil {
+ return m.Err
+ }
+ if _, ok := m.Data[id]; !ok {
+ return model.ErrNotFound
+ }
+ delete(m.Data, id)
+ return nil
+}
+
+func (m *MockLibraryRepo) StoreMusicFolder() error {
+ if m.Err != nil {
+ return m.Err
+ }
+ return nil
+}
+
+func (m *MockLibraryRepo) AddArtist(id int, artistID string) error {
+ if m.Err != nil {
+ return m.Err
+ }
+ return nil
+}
+
+func (m *MockLibraryRepo) ScanBegin(id int, fullScan bool) error {
+ if m.Err != nil {
+ return m.Err
+ }
+ return nil
+}
+
+func (m *MockLibraryRepo) ScanEnd(id int) error {
+ if m.Err != nil {
+ return m.Err
+ }
+ return nil
+}
+
+func (m *MockLibraryRepo) ScanInProgress() (bool, error) {
+ if m.Err != nil {
+ return false, m.Err
+ }
+ return false, nil
+}
+
func (m *MockLibraryRepo) RefreshStats(id int) error {
return nil
}
-var _ model.LibraryRepository = &MockLibraryRepo{}
+// User-library association methods - mock implementations
+
+func (m *MockLibraryRepo) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) {
+ if m.Err != nil {
+ return nil, m.Err
+ }
+ // Mock: return empty users for now
+ return model.Users{}, nil
+}
+
+func (m *MockLibraryRepo) Count(options ...rest.QueryOptions) (int64, error) {
+ return m.CountAll()
+}
+
+func (m *MockLibraryRepo) Read(id string) (interface{}, error) {
+ idInt, _ := strconv.Atoi(id)
+ mf, err := m.Get(idInt)
+ if errors.Is(err, model.ErrNotFound) {
+ return nil, rest.ErrNotFound
+ }
+ return mf, err
+}
+
+func (m *MockLibraryRepo) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
+ return m.GetAll()
+}
+
+func (m *MockLibraryRepo) EntityName() string {
+ return "library"
+}
+
+func (m *MockLibraryRepo) NewInstance() interface{} {
+ return &model.Library{}
+}
+
+// REST Repository methods (string-based IDs)
+
+func (m *MockLibraryRepo) Save(entity interface{}) (string, error) {
+ lib := entity.(*model.Library)
+ if m.Err != nil {
+ return "", m.Err
+ }
+
+ // Validate required fields
+ if lib.Name == "" {
+ return "", &rest.ValidationError{Errors: map[string]string{"name": "library name is required"}}
+ }
+ if lib.Path == "" {
+ return "", &rest.ValidationError{Errors: map[string]string{"path": "library path is required"}}
+ }
+
+ // Generate ID if not set
+ if lib.ID == 0 {
+ lib.ID = len(m.Data) + 1
+ }
+ if m.Data == nil {
+ m.Data = make(map[int]model.Library)
+ }
+ m.Data[lib.ID] = *lib
+ return strconv.Itoa(lib.ID), nil
+}
+
+func (m *MockLibraryRepo) Update(id string, entity interface{}, cols ...string) error {
+ lib := entity.(*model.Library)
+ if m.Err != nil {
+ return m.Err
+ }
+
+ // Validate required fields
+ if lib.Name == "" {
+ return &rest.ValidationError{Errors: map[string]string{"name": "library name is required"}}
+ }
+ if lib.Path == "" {
+ return &rest.ValidationError{Errors: map[string]string{"path": "library path is required"}}
+ }
+
+ idInt, err := strconv.Atoi(id)
+ if err != nil {
+ return errors.New("invalid ID format")
+ }
+ if _, exists := m.Data[idInt]; !exists {
+ return rest.ErrNotFound
+ }
+ lib.ID = idInt
+ m.Data[idInt] = *lib
+ return nil
+}
+
+func (m *MockLibraryRepo) DeleteByStringID(id string) error {
+ if m.Err != nil {
+ return m.Err
+ }
+ idInt, err := strconv.Atoi(id)
+ if err != nil {
+ return errors.New("invalid ID format")
+ }
+ if _, exists := m.Data[idInt]; !exists {
+ return rest.ErrNotFound
+ }
+ delete(m.Data, idInt)
+ return nil
+}
+
+// Service-level methods for core.Library interface
+
+func (m *MockLibraryRepo) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
+ if m.Err != nil {
+ return nil, m.Err
+ }
+ if userID == "non-existent" {
+ return nil, model.ErrNotFound
+ }
+ // Convert map to slice for return
+ var libraries model.Libraries
+ for _, lib := range m.Data {
+ libraries = append(libraries, lib)
+ }
+ // Sort by ID for predictable order
+ slices.SortFunc(libraries, func(a, b model.Library) int {
+ return a.ID - b.ID
+ })
+ return libraries, nil
+}
+
+func (m *MockLibraryRepo) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error {
+ if m.Err != nil {
+ return m.Err
+ }
+ if userID == "non-existent" {
+ return model.ErrNotFound
+ }
+ if userID == "admin-1" {
+ return fmt.Errorf("%w: cannot manually assign libraries to admin users", model.ErrValidation)
+ }
+ 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
+ for _, id := range libraryIDs {
+ if _, exists := m.Data[id]; !exists {
+ return fmt.Errorf("%w: library ID %d does not exist", model.ErrValidation, id)
+ }
+ }
+ return nil
+}
+
+func (m *MockLibraryRepo) ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error {
+ if m.Err != nil {
+ return m.Err
+ }
+ // For testing purposes, allow access to all libraries
+ return nil
+}
+
+var _ model.LibraryRepository = (*MockLibraryRepo)(nil)
+var _ model.ResourceRepository = (*MockLibraryRepo)(nil)
diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go
index 7bba8eda8..5b38a7187 100644
--- a/tests/mock_mediafile_repo.go
+++ b/tests/mock_mediafile_repo.go
@@ -27,6 +27,10 @@ type MockMediaFileRepo struct {
CountAllValue int64
CountAllOptions model.QueryOptions
DeleteAllMissingValue int64
+ Options model.QueryOptions
+ // Add fields for cross-library move detection tests
+ FindRecentFilesByMBZTrackIDFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error)
+ FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error)
}
func (m *MockMediaFileRepo) SetError(err bool) {
@@ -72,7 +76,10 @@ func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, er
return nil, model.ErrNotFound
}
-func (m *MockMediaFileRepo) GetAll(...model.QueryOptions) (model.MediaFiles, error) {
+func (m *MockMediaFileRepo) GetAll(qo ...model.QueryOptions) (model.MediaFiles, error) {
+ if len(qo) > 0 {
+ m.Options = qo[0]
+ }
if m.Err {
return nil, errors.New("error")
}
@@ -227,5 +234,66 @@ func (m *MockMediaFileRepo) NewInstance() interface{} {
return &model.MediaFile{}
}
+func (m *MockMediaFileRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) {
+ if len(options) > 0 {
+ m.Options = options[0]
+ }
+ if m.Err {
+ return nil, errors.New("unexpected error")
+ }
+ // Simple mock implementation - just return all media files for testing
+ allFiles, err := m.GetAll()
+ return allFiles, err
+}
+
+// Cross-library move detection mock methods
+func (m *MockMediaFileRepo) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
+ if m.Err {
+ return nil, errors.New("error")
+ }
+ if m.FindRecentFilesByMBZTrackIDFunc != nil {
+ return m.FindRecentFilesByMBZTrackIDFunc(missing, since)
+ }
+ // Default implementation: find files with same MBZ Track ID in other libraries
+ var result model.MediaFiles
+ for _, mf := range m.Data {
+ if mf.LibraryID != missing.LibraryID &&
+ mf.MbzReleaseTrackID == missing.MbzReleaseTrackID &&
+ mf.MbzReleaseTrackID != "" &&
+ mf.Suffix == missing.Suffix &&
+ mf.CreatedAt.After(since) &&
+ !mf.Missing {
+ result = append(result, *mf)
+ }
+ }
+ return result, nil
+}
+
+func (m *MockMediaFileRepo) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
+ if m.Err {
+ return nil, errors.New("error")
+ }
+ if m.FindRecentFilesByPropertiesFunc != nil {
+ return m.FindRecentFilesByPropertiesFunc(missing, since)
+ }
+ // Default implementation: find files with same properties in other libraries
+ var result model.MediaFiles
+ for _, mf := range m.Data {
+ if mf.LibraryID != missing.LibraryID &&
+ mf.Title == missing.Title &&
+ mf.Size == missing.Size &&
+ mf.Suffix == missing.Suffix &&
+ mf.DiscNumber == missing.DiscNumber &&
+ mf.TrackNumber == missing.TrackNumber &&
+ mf.Album == missing.Album &&
+ mf.MbzReleaseTrackID == "" && // Exclude files with MBZ Track ID
+ mf.CreatedAt.After(since) &&
+ !mf.Missing {
+ result = append(result, *mf)
+ }
+ }
+ return result, nil
+}
+
var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil)
var _ model.ResourceRepository = (*MockMediaFileRepo)(nil)
diff --git a/tests/mock_scanner.go b/tests/mock_scanner.go
new file mode 100644
index 000000000..52396723f
--- /dev/null
+++ b/tests/mock_scanner.go
@@ -0,0 +1,120 @@
+package tests
+
+import (
+ "context"
+ "sync"
+
+ "github.com/navidrome/navidrome/model"
+)
+
+// MockScanner implements scanner.Scanner for testing with proper synchronization
+type MockScanner struct {
+ mu sync.Mutex
+ scanAllCalls []ScanAllCall
+ scanFoldersCalls []ScanFoldersCall
+ scanningStatus bool
+ statusResponse *model.ScannerStatus
+}
+
+type ScanAllCall struct {
+ FullScan bool
+}
+
+type ScanFoldersCall struct {
+ FullScan bool
+ Targets []model.ScanTarget
+}
+
+func NewMockScanner() *MockScanner {
+ return &MockScanner{
+ scanAllCalls: make([]ScanAllCall, 0),
+ scanFoldersCalls: make([]ScanFoldersCall, 0),
+ }
+}
+
+func (m *MockScanner) ScanAll(_ context.Context, fullScan bool) ([]string, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.scanAllCalls = append(m.scanAllCalls, ScanAllCall{FullScan: fullScan})
+
+ return nil, nil
+}
+
+func (m *MockScanner) ScanFolders(_ context.Context, fullScan bool, targets []model.ScanTarget) ([]string, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ // Make a copy of targets to avoid race conditions
+ targetsCopy := make([]model.ScanTarget, len(targets))
+ copy(targetsCopy, targets)
+
+ m.scanFoldersCalls = append(m.scanFoldersCalls, ScanFoldersCall{
+ FullScan: fullScan,
+ Targets: targetsCopy,
+ })
+
+ return nil, nil
+}
+
+func (m *MockScanner) Status(_ context.Context) (*model.ScannerStatus, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if m.statusResponse != nil {
+ return m.statusResponse, nil
+ }
+
+ return &model.ScannerStatus{
+ Scanning: m.scanningStatus,
+ }, nil
+}
+
+func (m *MockScanner) GetScanAllCallCount() int {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return len(m.scanAllCalls)
+}
+
+func (m *MockScanner) GetScanAllCalls() []ScanAllCall {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ // Return a copy to avoid race conditions
+ calls := make([]ScanAllCall, len(m.scanAllCalls))
+ copy(calls, m.scanAllCalls)
+ return calls
+}
+
+func (m *MockScanner) GetScanFoldersCallCount() int {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return len(m.scanFoldersCalls)
+}
+
+func (m *MockScanner) GetScanFoldersCalls() []ScanFoldersCall {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ // Return a copy to avoid race conditions
+ calls := make([]ScanFoldersCall, len(m.scanFoldersCalls))
+ copy(calls, m.scanFoldersCalls)
+ return calls
+}
+
+func (m *MockScanner) Reset() {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.scanAllCalls = make([]ScanAllCall, 0)
+ m.scanFoldersCalls = make([]ScanFoldersCall, 0)
+}
+
+func (m *MockScanner) SetScanning(scanning bool) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.scanningStatus = scanning
+}
+
+func (m *MockScanner) SetStatusResponse(status *model.ScannerStatus) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.statusResponse = status
+}
diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go
index 09d804ccd..9f3dd672e 100644
--- a/tests/mock_user_repo.go
+++ b/tests/mock_user_repo.go
@@ -2,6 +2,7 @@ package tests
import (
"encoding/base64"
+ "fmt"
"strings"
"time"
@@ -11,14 +12,16 @@ import (
func CreateMockUserRepo() *MockedUserRepo {
return &MockedUserRepo{
- Data: map[string]*model.User{},
+ Data: map[string]*model.User{},
+ UserLibraries: map[string][]int{},
}
}
type MockedUserRepo struct {
model.UserRepository
- Error error
- Data map[string]*model.User
+ Error error
+ Data map[string]*model.User
+ UserLibraries map[string][]int // userID -> libraryIDs
}
func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
@@ -55,6 +58,18 @@ func (u *MockedUserRepo) FindByUsernameWithPassword(username string) (*model.Use
return u.FindByUsername(username)
}
+func (u *MockedUserRepo) Get(id string) (*model.User, error) {
+ if u.Error != nil {
+ return nil, u.Error
+ }
+ for _, usr := range u.Data {
+ if usr.ID == id {
+ return usr, nil
+ }
+ }
+ return nil, model.ErrNotFound
+}
+
func (u *MockedUserRepo) UpdateLastLoginAt(id string) error {
for _, usr := range u.Data {
if usr.ID == id {
@@ -74,3 +89,37 @@ func (u *MockedUserRepo) UpdateLastAccessAt(id string) error {
}
return u.Error
}
+
+// Library association methods - mock implementations
+
+func (u *MockedUserRepo) GetUserLibraries(userID string) (model.Libraries, error) {
+ if u.Error != nil {
+ return nil, u.Error
+ }
+ libraryIDs, exists := u.UserLibraries[userID]
+ if !exists {
+ return model.Libraries{}, nil
+ }
+
+ // Mock: Create libraries based on IDs
+ var libraries model.Libraries
+ for _, id := range libraryIDs {
+ libraries = append(libraries, model.Library{
+ ID: id,
+ Name: fmt.Sprintf("Test Library %d", id),
+ Path: fmt.Sprintf("/music/library%d", id),
+ })
+ }
+ return libraries, nil
+}
+
+func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error {
+ if u.Error != nil {
+ return u.Error
+ }
+ if u.UserLibraries == nil {
+ u.UserLibraries = make(map[string][]int)
+ }
+ u.UserLibraries[userID] = libraryIDs
+ return nil
+}
diff --git a/tests/test_helpers.go b/tests/test_helpers.go
index 1251c90cd..0a2cad4ad 100644
--- a/tests/test_helpers.go
+++ b/tests/test_helpers.go
@@ -6,7 +6,10 @@ import (
"path/filepath"
"github.com/navidrome/navidrome/db"
+ "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/id"
+ "github.com/sirupsen/logrus"
+ "github.com/sirupsen/logrus/hooks/test"
)
type testingT interface {
@@ -35,3 +38,23 @@ func ClearDB() error {
`)
return err
}
+
+// LogHook sets up a logrus test hook and configures the default logger to use it.
+// It returns the hook and a cleanup function to restore the default logger.
+// Example usage:
+//
+// hook, cleanup := LogHook()
+// defer cleanup()
+// // ... perform logging operations ...
+// Expect(hook.LastEntry()).ToNot(BeNil())
+// Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
+// Expect(hook.LastEntry().Message).To(Equal("log message"))
+func LogHook() (*test.Hook, func()) {
+ l, hook := test.NewNullLogger()
+ log.SetLevel(log.LevelWarn)
+ log.SetDefaultLogger(l)
+ return hook, func() {
+ // Restore default logger after test
+ log.SetDefaultLogger(logrus.New())
+ }
+}
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 9e449c5e0..c0901a73d 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -9,7 +9,7 @@
"dependencies": {
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
- "@material-ui/lab": "^4.0.0-alpha.58",
+ "@material-ui/lab": "^4.0.0-alpha.61",
"@material-ui/styles": "^4.11.5",
"blueimp-md5": "^2.19.0",
"clsx": "^2.1.1",
@@ -37,8 +37,8 @@
"react-redux": "^7.2.9",
"react-router-dom": "^5.3.4",
"redux": "^4.2.1",
- "redux-saga": "^1.3.0",
- "uuid": "^11.1.0",
+ "redux-saga": "^1.4.2",
+ "uuid": "^13.0.0",
"workbox-cli": "^7.3.0"
},
"devDependencies": {
@@ -46,46 +46,35 @@
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "^14.6.1",
- "@types/node": "^22.15.21",
- "@types/react": "^17.0.86",
+ "@types/node": "^24.9.1",
+ "@types/react": "^17.0.89",
"@types/react-dom": "^17.0.26",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
- "@vitejs/plugin-react": "^4.5.0",
- "@vitest/coverage-v8": "^3.1.4",
+ "@vitejs/plugin-react": "^5.1.0",
+ "@vitest/coverage-v8": "^4.0.3",
"eslint": "^8.57.1",
- "eslint-config-prettier": "^10.1.5",
+ "eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
- "eslint-plugin-react-refresh": "^0.4.20",
- "happy-dom": "^17.4.7",
+ "eslint-plugin-react-refresh": "^0.4.24",
+ "happy-dom": "^20.0.8",
"jsdom": "^26.1.0",
- "prettier": "^3.5.3",
+ "prettier": "^3.6.2",
"ra-test": "^3.19.12",
"typescript": "^5.8.3",
- "vite": "^6.3.5",
- "vite-plugin-pwa": "^0.21.2",
- "vitest": "^3.1.4"
+ "vite": "^7.1.12",
+ "vite-plugin-pwa": "^1.1.0",
+ "vitest": "^4.0.3"
}
},
"node_modules/@adobe/css-tools": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz",
- "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==",
- "dev": true
- },
- "node_modules/@ampproject/remapping": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
- "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- },
- "engines": {
- "node": ">=6.0.0"
- }
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
@@ -128,20 +117,20 @@
}
},
"node_modules/@babel/core": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz",
- "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
+ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dependencies": {
- "@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.27.1",
- "@babel/helper-compilation-targets": "^7.27.1",
- "@babel/helper-module-transforms": "^7.27.1",
- "@babel/helpers": "^7.27.1",
- "@babel/parser": "^7.27.1",
- "@babel/template": "^7.27.1",
- "@babel/traverse": "^7.27.1",
- "@babel/types": "^7.27.1",
+ "@babel/generator": "^7.28.5",
+ "@babel/helper-compilation-targets": "^7.27.2",
+ "@babel/helper-module-transforms": "^7.28.3",
+ "@babel/helpers": "^7.28.4",
+ "@babel/parser": "^7.28.5",
+ "@babel/template": "^7.27.2",
+ "@babel/traverse": "^7.28.5",
+ "@babel/types": "^7.28.5",
+ "@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -165,14 +154,14 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz",
- "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
+ "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
"dependencies": {
- "@babel/parser": "^7.27.1",
- "@babel/types": "^7.27.1",
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.25",
+ "@babel/parser": "^7.28.5",
+ "@babel/types": "^7.28.5",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
@@ -299,6 +288,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz",
@@ -324,13 +321,13 @@
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz",
- "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==",
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
+ "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
- "@babel/traverse": "^7.27.1"
+ "@babel/traverse": "^7.28.3"
},
"engines": {
"node": ">=6.9.0"
@@ -411,9 +408,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
- "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"engines": {
"node": ">=6.9.0"
}
@@ -440,23 +437,23 @@
}
},
"node_modules/@babel/helpers": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz",
- "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
+ "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
"dependencies": {
- "@babel/template": "^7.27.1",
- "@babel/types": "^7.27.1"
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.4"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.27.2",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz",
- "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
+ "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"dependencies": {
- "@babel/types": "^7.27.1"
+ "@babel/types": "^7.28.5"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1464,9 +1461,10 @@
}
},
"node_modules/@babel/runtime": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
- "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+ "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+ "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -1497,29 +1495,29 @@
}
},
"node_modules/@babel/traverse": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz",
- "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
+ "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
"dependencies": {
"@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.27.1",
- "@babel/parser": "^7.27.1",
- "@babel/template": "^7.27.1",
- "@babel/types": "^7.27.1",
- "debug": "^4.3.1",
- "globals": "^11.1.0"
+ "@babel/generator": "^7.28.5",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.28.5",
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.5",
+ "debug": "^4.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
- "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
+ "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.27.1"
+ "@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -2100,10 +2098,11 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -2173,10 +2172,11 @@
}
},
"node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -2214,76 +2214,6 @@
"deprecated": "Use @eslint/object-schema instead",
"dev": true
},
- "node_modules/@isaacs/cliui": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
- "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
- "dev": true,
- "dependencies": {
- "string-width": "^5.1.2",
- "string-width-cjs": "npm:string-width@^4.2.0",
- "strip-ansi": "^7.0.1",
- "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
- "wrap-ansi": "^8.1.0",
- "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
- "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
- "dev": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/string-width": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
- "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "dev": true,
- "dependencies": {
- "eastasianwidth": "^0.2.0",
- "emoji-regex": "^9.2.2",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^6.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
- }
- },
- "node_modules/@istanbuljs/schema": {
- "version": "0.1.3",
- "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
- "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/@jest/types": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz",
@@ -2301,16 +2231,21 @@
}
},
"node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.8",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
- "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dependencies": {
- "@jridgewell/set-array": "^1.2.1",
- "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
- },
- "engines": {
- "node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
@@ -2321,14 +2256,6 @@
"node": ">=6.0.0"
}
},
- "node_modules/@jridgewell/set-array": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
- "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
- "engines": {
- "node": ">=6.0.0"
- }
- },
"node_modules/@jridgewell/source-map": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
@@ -2339,14 +2266,14 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
- "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
},
"node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.25",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
- "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
@@ -2596,16 +2523,6 @@
"node": ">= 8"
}
},
- "node_modules/@pkgjs/parseargs": {
- "version": "0.11.0",
- "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
- "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
- "dev": true,
- "optional": true,
- "engines": {
- "node": ">=14"
- }
- },
"node_modules/@react-dnd/asap": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
@@ -2630,16 +2547,17 @@
}
},
"node_modules/@redux-saga/core": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.3.0.tgz",
- "integrity": "sha512-L+i+qIGuyWn7CIg7k1MteHGfttKPmxwZR5E7OsGikCL2LzYA0RERlaUY00Y3P3ZV2EYgrsYlBrGs6cJP5OKKqA==",
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.4.2.tgz",
+ "integrity": "sha512-nIMLGKo6jV6Wc1sqtVQs1iqbB3Kq20udB/u9XEaZQisT6YZ0NRB8+4L6WqD/E+YziYutd27NJbG8EWUPkb7c6Q==",
+ "license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.6.3",
- "@redux-saga/deferred": "^1.2.1",
- "@redux-saga/delay-p": "^1.2.1",
- "@redux-saga/is": "^1.1.3",
- "@redux-saga/symbols": "^1.1.3",
- "@redux-saga/types": "^1.2.1",
+ "@babel/runtime": "^7.28.4",
+ "@redux-saga/deferred": "^1.3.1",
+ "@redux-saga/delay-p": "^1.3.1",
+ "@redux-saga/is": "^1.2.1",
+ "@redux-saga/symbols": "^1.2.1",
+ "@redux-saga/types": "^1.3.1",
"typescript-tuple": "^2.2.1"
},
"funding": {
@@ -2648,41 +2566,46 @@
}
},
"node_modules/@redux-saga/deferred": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.2.1.tgz",
- "integrity": "sha512-cmin3IuuzMdfQjA0lG4B+jX+9HdTgHZZ+6u3jRAOwGUxy77GSlTi4Qp2d6PM1PUoTmQUR5aijlA39scWWPF31g=="
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.3.1.tgz",
+ "integrity": "sha512-0YZ4DUivWojXBqLB/TmuRRpDDz7tyq1I0AuDV7qi01XlLhM5m51W7+xYtIckH5U2cMlv9eAuicsfRAi1XHpXIg==",
+ "license": "MIT"
},
"node_modules/@redux-saga/delay-p": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.2.1.tgz",
- "integrity": "sha512-MdiDxZdvb1m+Y0s4/hgdcAXntpUytr9g0hpcOO1XFVyyzkrDu3SKPgBFOtHn7lhu7n24ZKIAT1qtKyQjHqRd+w==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.3.1.tgz",
+ "integrity": "sha512-597I7L5MXbD/1i3EmcaOOjL/5suxJD7p5tnbV1PiWnE28c2cYiIHqmSMK2s7us2/UrhOL2KTNBiD0qBg6KnImg==",
+ "license": "MIT",
"dependencies": {
- "@redux-saga/symbols": "^1.1.3"
+ "@redux-saga/symbols": "^1.2.1"
}
},
"node_modules/@redux-saga/is": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.1.3.tgz",
- "integrity": "sha512-naXrkETG1jLRfVfhOx/ZdLj0EyAzHYbgJWkXbB3qFliPcHKiWbv/ULQryOAEKyjrhiclmr6AMdgsXFyx7/yE6Q==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.2.1.tgz",
+ "integrity": "sha512-x3aWtX3GmQfEvn8dh0ovPbsXgK9JjpiR24wKztpGbZP8JZUWWvUgKrvnWZ/T/4iphOBftyVc9VrIwhAnsM+OFA==",
+ "license": "MIT",
"dependencies": {
- "@redux-saga/symbols": "^1.1.3",
- "@redux-saga/types": "^1.2.1"
+ "@redux-saga/symbols": "^1.2.1",
+ "@redux-saga/types": "^1.3.1"
}
},
"node_modules/@redux-saga/symbols": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.1.3.tgz",
- "integrity": "sha512-hCx6ZvU4QAEUojETnX8EVg4ubNLBFl1Lps4j2tX7o45x/2qg37m3c6v+kSp8xjDJY+2tJw4QB3j8o8dsl1FDXg=="
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.2.1.tgz",
+ "integrity": "sha512-3dh+uDvpBXi7EUp/eO+N7eFM4xKaU4yuGBXc50KnZGzIrR/vlvkTFQsX13zsY8PB6sCFYAgROfPSRUj8331QSA==",
+ "license": "MIT"
},
"node_modules/@redux-saga/types": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.2.1.tgz",
- "integrity": "sha512-1dgmkh+3so0+LlBWRhGA33ua4MYr7tUOj+a9Si28vUi0IUFNbff1T3sgpeDJI/LaC75bBYnQ0A3wXjn0OrRNBA=="
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.3.1.tgz",
+ "integrity": "sha512-YRCrJdhQLobGIQ8Cj1sta3nn6DrZDTSUnrIYhS2e5V590BmfVDleKoAquclAiKSBKWJwmuXTb+b4BL6rSHnahw==",
+ "license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.9",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz",
- "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==",
+ "version": "1.0.0-beta.43",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz",
+ "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==",
"dev": true
},
"node_modules/@rollup/plugin-node-resolve": {
@@ -2793,6 +2716,12 @@
"node": ">=6"
}
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
+ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
+ "dev": true
+ },
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@@ -2844,17 +2773,17 @@
}
},
"node_modules/@testing-library/jest-dom": {
- "version": "6.6.3",
- "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz",
- "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==",
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
- "chalk": "^3.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
- "lodash": "^4.17.21",
+ "picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"engines": {
@@ -2863,24 +2792,12 @@
"yarn": ">=1"
}
},
- "node_modules/@testing-library/jest-dom/node_modules/chalk": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
- "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
- "dev": true,
- "dependencies": {
- "ansi-styles": "^4.1.0",
- "supports-color": "^7.1.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "12.1.5",
@@ -3017,6 +2934,22 @@
"@babel/types": "^7.20.7"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true
+ },
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@@ -3067,12 +3000,13 @@
"integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag=="
},
"node_modules/@types/node": {
- "version": "22.15.21",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
- "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==",
+ "version": "24.9.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
+ "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"devOptional": true,
+ "license": "MIT",
"dependencies": {
- "undici-types": "~6.21.0"
+ "undici-types": "~7.16.0"
}
},
"node_modules/@types/normalize-package-data": {
@@ -3086,9 +3020,10 @@
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="
},
"node_modules/@types/react": {
- "version": "17.0.86",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.86.tgz",
- "integrity": "sha512-lPFuSjA85jecet6D4ZsPvCFuSrz6g2hkTSUw8MM0x5z2EndPV/itGnYQ39abjxd7F+cAcxLGtKQjnLn9cNUz3g==",
+ "version": "17.0.89",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.89.tgz",
+ "integrity": "sha512-I98SaDCar5lvEYl80ClRIUztH/hyWHR+I2f+5yTVp/MQ205HgYkA2b5mVdry/+nsEIrf8I65KA5V/PASx68MsQ==",
+ "license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "^0.16",
@@ -3158,6 +3093,13 @@
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/yargs": {
"version": "15.0.19",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.19.tgz",
@@ -3370,50 +3312,49 @@
"dev": true
},
"node_modules/@vitejs/plugin-react": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz",
- "integrity": "sha512-JuLWaEqypaJmOJPLWwO335Ig6jSgC1FTONCWAxnqcQthLTK/Yc9aH6hr9z/87xciejbQcnP3GnA1FWUSWeXaeg==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz",
+ "integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==",
"dev": true,
"dependencies": {
- "@babel/core": "^7.26.10",
- "@babel/plugin-transform-react-jsx-self": "^7.25.9",
- "@babel/plugin-transform-react-jsx-source": "^7.25.9",
- "@rolldown/pluginutils": "1.0.0-beta.9",
+ "@babel/core": "^7.28.4",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.43",
"@types/babel__core": "^7.20.5",
- "react-refresh": "^0.17.0"
+ "react-refresh": "^0.18.0"
},
"engines": {
- "node": "^14.18.0 || >=16.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
- "vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/coverage-v8": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.4.tgz",
- "integrity": "sha512-G4p6OtioySL+hPV7Y6JHlhpsODbJzt1ndwHAFkyk6vVjpK03PFsKnauZIzcd0PrK4zAbc5lc+jeZ+eNGiMA+iw==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.3.tgz",
+ "integrity": "sha512-I+MlLwyJRBjmJr1kFYSxoseINbIdpxIAeK10jmXgB0FUtIfdYsvM3lGAvBu5yk8WPyhefzdmbCHCc1idFbNRcg==",
"dev": true,
"dependencies": {
- "@ampproject/remapping": "^2.3.0",
"@bcoe/v8-coverage": "^1.0.2",
- "debug": "^4.4.0",
+ "@vitest/utils": "4.0.3",
+ "ast-v8-to-istanbul": "^0.3.5",
+ "debug": "^4.4.3",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.6",
- "istanbul-reports": "^3.1.7",
- "magic-string": "^0.30.17",
+ "istanbul-reports": "^3.2.0",
"magicast": "^0.3.5",
"std-env": "^3.9.0",
- "test-exclude": "^7.0.1",
- "tinyrainbow": "^2.0.0"
+ "tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "@vitest/browser": "3.1.4",
- "vitest": "3.1.4"
+ "@vitest/browser": "4.0.3",
+ "vitest": "4.0.3"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -3422,36 +3363,38 @@
}
},
"node_modules/@vitest/expect": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.4.tgz",
- "integrity": "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.3.tgz",
+ "integrity": "sha512-v3eSDx/bF25pzar6aEJrrdTXJduEBU3uSGXHslIdGIpJVP8tQQHV6x1ZfzbFQ/bLIomLSbR/2ZCfnaEGkWkiVQ==",
"dev": true,
"dependencies": {
- "@vitest/spy": "3.1.4",
- "@vitest/utils": "3.1.4",
- "chai": "^5.2.0",
- "tinyrainbow": "^2.0.0"
+ "@standard-schema/spec": "^1.0.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.0.3",
+ "@vitest/utils": "4.0.3",
+ "chai": "^6.0.1",
+ "tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.4.tgz",
- "integrity": "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.3.tgz",
+ "integrity": "sha512-evZcRspIPbbiJEe748zI2BRu94ThCBE+RkjCpVF8yoVYuTV7hMe+4wLF/7K86r8GwJHSmAPnPbZhpXWWrg1qbA==",
"dev": true,
"dependencies": {
- "@vitest/spy": "3.1.4",
+ "@vitest/spy": "4.0.3",
"estree-walker": "^3.0.3",
- "magic-string": "^0.30.17"
+ "magic-string": "^0.30.19"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
- "vite": "^5.0.0 || ^6.0.0"
+ "vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
@@ -3463,24 +3406,24 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.4.tgz",
- "integrity": "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.3.tgz",
+ "integrity": "sha512-N7gly/DRXzxa9w9sbDXwD9QNFYP2hw90LLLGDobPNwiWgyW95GMxsCt29/COIKKh3P7XJICR38PSDePenMBtsw==",
"dev": true,
"dependencies": {
- "tinyrainbow": "^2.0.0"
+ "tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.4.tgz",
- "integrity": "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.3.tgz",
+ "integrity": "sha512-1/aK6fPM0lYXWyGKwop2Gbvz1plyTps/HDbIIJXYtJtspHjpXIeB3If07eWpVH4HW7Rmd3Rl+IS/+zEAXrRtXA==",
"dev": true,
"dependencies": {
- "@vitest/utils": "3.1.4",
+ "@vitest/utils": "4.0.3",
"pathe": "^2.0.3"
},
"funding": {
@@ -3488,13 +3431,13 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.4.tgz",
- "integrity": "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.3.tgz",
+ "integrity": "sha512-amnYmvZ5MTjNCP1HZmdeczAPLRD6iOm9+2nMRUGxbe/6sQ0Ymur0NnR9LIrWS8JA3wKE71X25D6ya/3LN9YytA==",
"dev": true,
"dependencies": {
- "@vitest/pretty-format": "3.1.4",
- "magic-string": "^0.30.17",
+ "@vitest/pretty-format": "4.0.3",
+ "magic-string": "^0.30.19",
"pathe": "^2.0.3"
},
"funding": {
@@ -3502,26 +3445,22 @@
}
},
"node_modules/@vitest/spy": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.4.tgz",
- "integrity": "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.3.tgz",
+ "integrity": "sha512-82vVL8Cqz7rbXaNUl35V2G7xeNMAjBdNOVaHbrzznT9BmiCiPOzhf0FhU3eP41nP1bLDm/5wWKZqkG4nyU95DQ==",
"dev": true,
- "dependencies": {
- "tinyspy": "^3.0.2"
- },
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.4.tgz",
- "integrity": "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.3.tgz",
+ "integrity": "sha512-qV6KJkq8W3piW6MDIbGOmn1xhvcW4DuA07alqaQ+vdx7YA49J85pnwnxigZVQFQw3tWnQNRKWwhz5wbP6iv/GQ==",
"dev": true,
"dependencies": {
- "@vitest/pretty-format": "3.1.4",
- "loupe": "^3.1.3",
- "tinyrainbow": "^2.0.0"
+ "@vitest/pretty-format": "4.0.3",
+ "tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -3813,6 +3752,23 @@
"integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==",
"dev": true
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.8",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz",
+ "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^9.0.1"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true
+ },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -4027,9 +3983,10 @@
}
},
"node_modules/brace-expansion": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
- "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -4104,15 +4061,6 @@
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
- "node_modules/cac": {
- "version": "6.7.14",
- "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
- "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/cacheable-request": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
@@ -4262,19 +4210,12 @@
]
},
"node_modules/chai": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
- "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz",
+ "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==",
"dev": true,
- "dependencies": {
- "assertion-error": "^2.0.1",
- "check-error": "^2.1.1",
- "deep-eql": "^5.0.1",
- "loupe": "^3.1.0",
- "pathval": "^2.0.0"
- },
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/chalk": {
@@ -4297,15 +4238,6 @@
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="
},
- "node_modules/check-error": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
- "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
- "dev": true,
- "engines": {
- "node": ">= 16"
- }
- },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -4590,7 +4522,8 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/cssstyle": {
"version": "4.3.1",
@@ -4692,9 +4625,9 @@
"integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw=="
},
"node_modules/debug": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
- "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
@@ -4763,15 +4696,6 @@
"node": ">=4"
}
},
- "node_modules/deep-eql": {
- "version": "5.0.2",
- "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
- "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
- "dev": true,
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/deep-equal": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
@@ -5014,12 +4938,6 @@
"resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz",
"integrity": "sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA=="
},
- "node_modules/eastasianwidth": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
- "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
- "dev": true
- },
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@@ -5390,9 +5308,9 @@
}
},
"node_modules/eslint-config-prettier": {
- "version": "10.1.5",
- "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz",
- "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
+ "version": "10.1.8",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
+ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"bin": {
@@ -5444,10 +5362,11 @@
}
},
"node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -5510,19 +5429,21 @@
}
},
"node_modules/eslint-plugin-react-refresh": {
- "version": "0.4.20",
- "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz",
- "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==",
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz",
+ "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==",
"dev": true,
+ "license": "MIT",
"peerDependencies": {
"eslint": ">=8.40"
}
},
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -5590,10 +5511,11 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -5716,9 +5638,9 @@
"integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw=="
},
"node_modules/expect-type": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz",
- "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz",
+ "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==",
"dev": true,
"engines": {
"node": ">=12.0.0"
@@ -5964,34 +5886,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/foreground-child": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
- "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
- "dev": true,
- "dependencies": {
- "cross-spawn": "^7.0.6",
- "signal-exit": "^4.0.1"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/foreground-child/node_modules/signal-exit": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
- "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
- "dev": true,
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@@ -6172,9 +6066,10 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -6292,18 +6187,37 @@
"dev": true
},
"node_modules/happy-dom": {
- "version": "17.4.7",
- "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.4.7.tgz",
- "integrity": "sha512-NZypxadhCiV5NT4A+Y86aQVVKQ05KDmueja3sz008uJfDRwz028wd0aTiJPwo4RQlvlz0fznkEEBBCHVNWc08g==",
+ "version": "20.0.8",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.8.tgz",
+ "integrity": "sha512-TlYaNQNtzsZ97rNMBAm8U+e2cUQXNithgfCizkDgc11lgmN4j9CKMhO3FPGKWQYPwwkFcPpoXYF/CqEPLgzfOg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "webidl-conversions": "^7.0.0",
+ "@types/node": "^20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
"whatwg-mimetype": "^3.0.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
+ "node_modules/happy-dom/node_modules/@types/node": {
+ "version": "20.19.23",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
+ "integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/happy-dom/node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/hard-rejection": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz",
@@ -7186,9 +7100,9 @@
}
},
"node_modules/istanbul-reports": {
- "version": "3.1.7",
- "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
- "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"dependencies": {
"html-escaper": "^2.0.0",
@@ -7215,21 +7129,6 @@
"node": ">= 0.4"
}
},
- "node_modules/jackspeak": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
- "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
- "dev": true,
- "dependencies": {
- "@isaacs/cliui": "^8.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- },
- "optionalDependencies": {
- "@pkgjs/parseargs": "^0.11.0"
- }
- },
"node_modules/jake": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
@@ -7248,9 +7147,10 @@
}
},
"node_modules/jake/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -7663,12 +7563,6 @@
"loose-envify": "cli.js"
}
},
- "node_modules/loupe": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
- "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==",
- "dev": true
- },
"node_modules/lowercase-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz",
@@ -7695,12 +7589,12 @@
}
},
"node_modules/magic-string": {
- "version": "0.30.17",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
- "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0"
+ "@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
@@ -7865,15 +7759,6 @@
"node": ">= 6"
}
},
- "node_modules/minipass": {
- "version": "7.1.2",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
- "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
- "dev": true,
- "engines": {
- "node": ">=16 || 14 >=14.17"
- }
- },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -8263,12 +8148,6 @@
"node": ">=8"
}
},
- "node_modules/package-json-from-dist": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
- "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
- "dev": true
- },
"node_modules/package-json/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -8348,28 +8227,6 @@
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
- "node_modules/path-scurry": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
- "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
- "dev": true,
- "dependencies": {
- "lru-cache": "^10.2.0",
- "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
- },
- "engines": {
- "node": ">=16 || 14 >=14.18"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/path-scurry/node_modules/lru-cache": {
- "version": "10.4.3",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
- "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
- "dev": true
- },
"node_modules/path-to-regexp": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz",
@@ -8393,15 +8250,6 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true
},
- "node_modules/pathval": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
- "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
- "dev": true,
- "engines": {
- "node": ">= 14.16"
- }
- },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8432,9 +8280,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.3",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
- "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
@@ -8451,7 +8299,7 @@
}
],
"dependencies": {
- "nanoid": "^3.3.8",
+ "nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -8477,9 +8325,9 @@
}
},
"node_modules/prettier": {
- "version": "3.5.3",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
- "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
+ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
@@ -9258,9 +9106,9 @@
}
},
"node_modules/react-refresh": {
- "version": "0.17.0",
- "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
- "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
"dev": true,
"engines": {
"node": ">=0.10.0"
@@ -9461,11 +9309,12 @@
}
},
"node_modules/redux-saga": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.3.0.tgz",
- "integrity": "sha512-J9RvCeAZXSTAibFY0kGw6Iy4EdyDNW7k6Q+liwX+bsck7QVsU78zz8vpBRweEfANxnnlG/xGGeOvf6r8UXzNJQ==",
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz",
+ "integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==",
+ "license": "MIT",
"dependencies": {
- "@redux-saga/core": "^1.3.0"
+ "@redux-saga/core": "^1.4.2"
}
},
"node_modules/reflect.getprototypeof": {
@@ -10184,9 +10033,9 @@
"dev": true
},
"node_modules/std-env": {
- "version": "3.9.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
- "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true
},
"node_modules/stop-iteration-iterator": {
@@ -10231,27 +10080,6 @@
"node": ">=8"
}
},
- "node_modules/string-width-cjs": {
- "name": "string-width",
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/string-width-cjs/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true
- },
"node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -10384,19 +10212,6 @@
"node": ">=8"
}
},
- "node_modules/strip-ansi-cjs": {
- "name": "strip-ansi",
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/strip-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
@@ -10509,55 +10324,6 @@
"node": ">=10"
}
},
- "node_modules/test-exclude": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
- "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
- "dev": true,
- "dependencies": {
- "@istanbuljs/schema": "^0.1.2",
- "glob": "^10.4.1",
- "minimatch": "^9.0.4"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/test-exclude/node_modules/glob": {
- "version": "10.4.5",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
- "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
- "dev": true,
- "dependencies": {
- "foreground-child": "^3.1.0",
- "jackspeak": "^3.1.2",
- "minimatch": "^9.0.4",
- "minipass": "^7.1.2",
- "package-json-from-dist": "^1.0.0",
- "path-scurry": "^1.11.1"
- },
- "bin": {
- "glob": "dist/esm/bin.mjs"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/test-exclude/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
- "dev": true,
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -10592,13 +10358,13 @@
"dev": true
},
"node_modules/tinyglobby": {
- "version": "0.2.13",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
- "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"dependencies": {
- "fdir": "^6.4.4",
- "picomatch": "^4.0.2"
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
@@ -10608,10 +10374,13 @@
}
},
"node_modules/tinyglobby/node_modules/fdir": {
- "version": "6.4.4",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
- "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -10622,9 +10391,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
- "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"engines": {
"node": ">=12"
@@ -10633,28 +10402,10 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/tinypool": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz",
- "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==",
- "dev": true,
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
- }
- },
"node_modules/tinyrainbow": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
- "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
- "dev": true,
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/tinyspy": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
- "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
+ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"engines": {
"node": ">=14.0.0"
@@ -10875,6 +10626,7 @@
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/typescript-compare/-/typescript-compare-0.0.2.tgz",
"integrity": "sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==",
+ "license": "MIT",
"dependencies": {
"typescript-logic": "^0.0.0"
}
@@ -10882,12 +10634,14 @@
"node_modules/typescript-logic": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/typescript-logic/-/typescript-logic-0.0.0.tgz",
- "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q=="
+ "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==",
+ "license": "MIT"
},
"node_modules/typescript-tuple": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/typescript-tuple/-/typescript-tuple-2.2.1.tgz",
"integrity": "sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==",
+ "license": "MIT",
"dependencies": {
"typescript-compare": "^0.0.2"
}
@@ -10910,10 +10664,11 @@
}
},
"node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "devOptional": true
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "devOptional": true,
+ "license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
@@ -11072,15 +10827,16 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/uuid": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
- "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+ "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
+ "license": "MIT",
"bin": {
- "uuid": "dist/esm/bin/uuid"
+ "uuid": "dist-node/bin/uuid"
}
},
"node_modules/validate-npm-package-license": {
@@ -11098,23 +10854,23 @@
"integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="
},
"node_modules/vite": {
- "version": "6.3.5",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
- "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
+ "version": "7.1.12",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
+ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true,
"dependencies": {
"esbuild": "^0.25.0",
- "fdir": "^6.4.4",
- "picomatch": "^4.0.2",
- "postcss": "^8.5.3",
- "rollup": "^4.34.9",
- "tinyglobby": "^0.2.13"
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -11123,14 +10879,14 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
- "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
- "less": "*",
+ "less": "^4.0.0",
"lightningcss": "^1.21.0",
- "sass": "*",
- "sass-embedded": "*",
- "stylus": "*",
- "sugarss": "*",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
@@ -11171,32 +10927,10 @@
}
}
},
- "node_modules/vite-node": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.4.tgz",
- "integrity": "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==",
- "dev": true,
- "dependencies": {
- "cac": "^6.7.14",
- "debug": "^4.4.0",
- "es-module-lexer": "^1.7.0",
- "pathe": "^2.0.3",
- "vite": "^5.0.0 || ^6.0.0"
- },
- "bin": {
- "vite-node": "vite-node.mjs"
- },
- "engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
"node_modules/vite-plugin-pwa": {
- "version": "0.21.2",
- "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.21.2.tgz",
- "integrity": "sha512-vFhH6Waw8itNu37hWUJxL50q+CBbNcMVzsKaYHQVrfxTt3ihk3PeLO22SbiP1UNWzcEPaTQv+YVxe4G0KOjAkg==",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.1.0.tgz",
+ "integrity": "sha512-VsSpdubPzXhHWVINcSx6uHRMpOHVHQcHsef1QgkOlEoaIDAlssFEW88LBq1a59BuokAhsh2kUDJbaX1bZv4Bjw==",
"dev": true,
"dependencies": {
"debug": "^4.3.6",
@@ -11212,8 +10946,8 @@
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
- "@vite-pwa/assets-generator": "^0.2.6",
- "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
+ "@vite-pwa/assets-generator": "^1.0.0",
+ "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0"
},
@@ -11224,10 +10958,13 @@
}
},
"node_modules/vite/node_modules/fdir": {
- "version": "6.4.4",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
- "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -11238,9 +10975,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
- "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"engines": {
"node": ">=12"
@@ -11250,38 +10987,37 @@
}
},
"node_modules/vitest": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.4.tgz",
- "integrity": "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.3.tgz",
+ "integrity": "sha512-IUSop8jgaT7w0g1yOM/35qVtKjr/8Va4PrjzH1OUb0YH4c3OXB2lCZDkMAB6glA8T5w8S164oJGsbcmAecr4sA==",
"dev": true,
"dependencies": {
- "@vitest/expect": "3.1.4",
- "@vitest/mocker": "3.1.4",
- "@vitest/pretty-format": "^3.1.4",
- "@vitest/runner": "3.1.4",
- "@vitest/snapshot": "3.1.4",
- "@vitest/spy": "3.1.4",
- "@vitest/utils": "3.1.4",
- "chai": "^5.2.0",
- "debug": "^4.4.0",
- "expect-type": "^1.2.1",
- "magic-string": "^0.30.17",
+ "@vitest/expect": "4.0.3",
+ "@vitest/mocker": "4.0.3",
+ "@vitest/pretty-format": "4.0.3",
+ "@vitest/runner": "4.0.3",
+ "@vitest/snapshot": "4.0.3",
+ "@vitest/spy": "4.0.3",
+ "@vitest/utils": "4.0.3",
+ "debug": "^4.4.3",
+ "es-module-lexer": "^1.7.0",
+ "expect-type": "^1.2.2",
+ "magic-string": "^0.30.19",
"pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
- "tinyglobby": "^0.2.13",
- "tinypool": "^1.0.2",
- "tinyrainbow": "^2.0.0",
- "vite": "^5.0.0 || ^6.0.0",
- "vite-node": "3.1.4",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3",
+ "vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -11289,9 +11025,11 @@
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
- "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
- "@vitest/browser": "3.1.4",
- "@vitest/ui": "3.1.4",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.0.3",
+ "@vitest/browser-preview": "4.0.3",
+ "@vitest/browser-webdriverio": "4.0.3",
+ "@vitest/ui": "4.0.3",
"happy-dom": "*",
"jsdom": "*"
},
@@ -11305,7 +11043,13 @@
"@types/node": {
"optional": true
},
- "@vitest/browser": {
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
@@ -11319,6 +11063,18 @@
}
}
},
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
@@ -11868,97 +11624,6 @@
"workbox-core": "7.3.0"
}
},
- "node_modules/wrap-ansi": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
- "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
- "dev": true,
- "dependencies": {
- "ansi-styles": "^6.1.0",
- "string-width": "^5.0.1",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/wrap-ansi-cjs": {
- "name": "wrap-ansi",
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
- "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "dev": true,
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/wrap-ansi/node_modules/ansi-regex": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
- "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
- "dev": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
- }
- },
- "node_modules/wrap-ansi/node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
- "dev": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/wrap-ansi/node_modules/string-width": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
- "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "dev": true,
- "dependencies": {
- "eastasianwidth": "^0.2.0",
- "emoji-regex": "^9.2.2",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/wrap-ansi/node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^6.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
- }
- },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
diff --git a/ui/package.json b/ui/package.json
index b9c93316b..a3612aaf4 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -18,7 +18,7 @@
"dependencies": {
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
- "@material-ui/lab": "^4.0.0-alpha.58",
+ "@material-ui/lab": "^4.0.0-alpha.61",
"@material-ui/styles": "^4.11.5",
"blueimp-md5": "^2.19.0",
"clsx": "^2.1.1",
@@ -46,8 +46,8 @@
"react-redux": "^7.2.9",
"react-router-dom": "^5.3.4",
"redux": "^4.2.1",
- "redux-saga": "^1.3.0",
- "uuid": "^11.1.0",
+ "redux-saga": "^1.4.2",
+ "uuid": "^13.0.0",
"workbox-cli": "^7.3.0"
},
"devDependencies": {
@@ -55,27 +55,27 @@
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "^14.6.1",
- "@types/node": "^22.15.21",
- "@types/react": "^17.0.86",
+ "@types/node": "^24.9.1",
+ "@types/react": "^17.0.89",
"@types/react-dom": "^17.0.26",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
- "@vitejs/plugin-react": "^4.5.0",
- "@vitest/coverage-v8": "^3.1.4",
+ "@vitejs/plugin-react": "^5.1.0",
+ "@vitest/coverage-v8": "^4.0.3",
"eslint": "^8.57.1",
- "eslint-config-prettier": "^10.1.5",
+ "eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
- "eslint-plugin-react-refresh": "^0.4.20",
- "happy-dom": "^17.4.7",
+ "eslint-plugin-react-refresh": "^0.4.24",
+ "happy-dom": "^20.0.8",
"jsdom": "^26.1.0",
- "prettier": "^3.5.3",
+ "prettier": "^3.6.2",
"ra-test": "^3.19.12",
"typescript": "^5.8.3",
- "vite": "^6.3.5",
- "vite-plugin-pwa": "^0.21.2",
- "vitest": "^3.1.4"
+ "vite": "^7.1.12",
+ "vite-plugin-pwa": "^1.1.0",
+ "vitest": "^4.0.3"
},
"overrides": {
"vite": {
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index 8469ac27e..dc4fe9b53 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -15,9 +15,11 @@ import artist from './artist'
import playlist from './playlist'
import radio from './radio'
import share from './share'
+import library from './library'
import { Player } from './audioplayer'
import customRoutes from './routes'
import {
+ libraryReducer,
themeReducer,
addToPlaylistDialogReducer,
expandInfoDialogReducer,
@@ -56,6 +58,7 @@ const adminStore = createAdminStore({
dataProvider,
history,
customReducers: {
+ library: libraryReducer,
player: playerReducer,
albumView: albumViewReducer,
theme: themeReducer,
@@ -122,7 +125,13 @@ const Admin = (props) => {
) : (
),
-
+ permissions === 'admin' ? (
+
+ ) : null,
permissions === 'admin' ? (
({
+ type: SET_SELECTED_LIBRARIES,
+ data: libraryIds,
+})
+
+export const setUserLibraries = (libraries) => ({
+ type: SET_USER_LIBRARIES,
+ data: libraries,
+})
diff --git a/ui/src/album/AlbumDatesField.jsx b/ui/src/album/AlbumDatesField.jsx
index e4cdeedce..ce1301380 100644
--- a/ui/src/album/AlbumDatesField.jsx
+++ b/ui/src/album/AlbumDatesField.jsx
@@ -10,6 +10,12 @@ export const AlbumDatesField = ({ className, ...rest }) => {
const releaseYear = releaseDate?.toString().substring(0, 4)
const yearRange =
formatRange(record, 'originalYear') || record['maxYear']?.toString()
+
+ // Don't show anything if the year starts with "0"
+ if (yearRange === '0' || releaseYear?.startsWith('0')) {
+ return null
+ }
+
let label = yearRange
if (releaseYear !== undefined && yearRange !== releaseYear) {
diff --git a/ui/src/album/AlbumDatesField.test.jsx b/ui/src/album/AlbumDatesField.test.jsx
new file mode 100644
index 000000000..9bcd41567
--- /dev/null
+++ b/ui/src/album/AlbumDatesField.test.jsx
@@ -0,0 +1,112 @@
+import { describe, test, expect, vi } from 'vitest'
+import { render } from '@testing-library/react'
+import { RecordContextProvider } from 'react-admin'
+import { AlbumDatesField } from './AlbumDatesField'
+import { formatRange } from '../common/index.js'
+
+// Mock the formatRange function
+vi.mock('../common/index.js', () => ({
+ formatRange: vi.fn(),
+}))
+
+describe('AlbumDatesField', () => {
+ test('renders nothing when yearRange is "0"', () => {
+ const record = {
+ maxYear: '0',
+ releaseDate: '2020-01-01',
+ }
+
+ vi.mocked(formatRange).mockReturnValue('0')
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ test('renders nothing when releaseYear is "0"', () => {
+ const record = {
+ maxYear: '2020',
+ releaseDate: '0-01-01',
+ }
+
+ vi.mocked(formatRange).mockReturnValue('2020')
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ test('renders only yearRange when releaseYear is undefined', () => {
+ const record = {
+ maxYear: '2020',
+ }
+
+ vi.mocked(formatRange).mockReturnValue('2020')
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(container.textContent).toBe('2020')
+ })
+
+ test('renders both years when they are different', () => {
+ const record = {
+ maxYear: '2018',
+ releaseDate: '2020-01-01',
+ }
+
+ vi.mocked(formatRange).mockReturnValue('2018')
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(container.textContent).toBe('♫ 2018 · ○ 2020')
+ })
+
+ test('renders only yearRange when both years are the same', () => {
+ const record = {
+ maxYear: '2020',
+ releaseDate: '2020-01-01',
+ }
+
+ vi.mocked(formatRange).mockReturnValue('2020')
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(container.textContent).toBe('2020')
+ })
+
+ test('applies className when provided', () => {
+ const record = {
+ maxYear: '2020',
+ }
+
+ vi.mocked(formatRange).mockReturnValue('2020')
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(container.firstChild).toHaveClass('test-class')
+ })
+})
diff --git a/ui/src/album/AlbumDetails.test.jsx b/ui/src/album/AlbumDetails.test.jsx
index e03022677..484045444 100644
--- a/ui/src/album/AlbumDetails.test.jsx
+++ b/ui/src/album/AlbumDetails.test.jsx
@@ -14,6 +14,24 @@ vi.mock('@material-ui/core', async () => {
}
})
+// Mock formatFullDate to return deterministic results
+vi.mock('../utils', async () => {
+ const actual = await import('../utils')
+ return {
+ ...actual,
+ formatFullDate: (date) => {
+ if (!date) return ''
+ // Use en-CA locale for consistent test results
+ return new Date(date).toLocaleDateString('en-CA', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ timeZone: 'UTC',
+ })
+ },
+ }
+})
+
describe('Details component', () => {
describe('Desktop view', () => {
beforeEach(() => {
diff --git a/ui/src/album/AlbumInfo.jsx b/ui/src/album/AlbumInfo.jsx
index 453dbb167..e71cd3d33 100644
--- a/ui/src/album/AlbumInfo.jsx
+++ b/ui/src/album/AlbumInfo.jsx
@@ -38,6 +38,7 @@ const AlbumInfo = (props) => {
const record = useRecordContext(props)
const data = {
album: ,
+ libraryName: ,
albumArtist: (
),
diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx
index 40b927a89..f10f8dbd3 100644
--- a/ui/src/album/AlbumList.jsx
+++ b/ui/src/album/AlbumList.jsx
@@ -42,6 +42,9 @@ const useStyles = makeStyles({
},
})
+const formatReleaseType = (record) =>
+ record?.tagValue ? humanize(record?.tagValue) : '-- None --'
+
const AlbumFilter = (props) => {
const classes = useStyles()
const translate = useTranslate()
@@ -142,9 +145,7 @@ const AlbumFilter = (props) => {
>
- record?.tagValue ? humanize(record?.tagValue) : '-- None --'
- }
+ optionText={formatReleaseType}
/>
diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx
index c33ee892b..8eebe6499 100644
--- a/ui/src/artist/ArtistActions.jsx
+++ b/ui/src/artist/ArtistActions.jsx
@@ -1,7 +1,7 @@
import React from 'react'
import PropTypes from 'prop-types'
import { useDispatch } from 'react-redux'
-import { useMediaQuery } from '@material-ui/core'
+import { useMediaQuery, CircularProgress } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import {
Button,
@@ -45,6 +45,12 @@ const useStyles = makeStyles((theme) => ({
},
}))
+const LoadingButton = ({ loading, icon, ...rest }) => (
+
+ {loading ? : icon}
+
+)
+
const ArtistActions = ({ className, record, ...rest }) => {
const dispatch = useDispatch()
const translate = useTranslate()
@@ -52,34 +58,45 @@ const ArtistActions = ({ className, record, ...rest }) => {
const notify = useNotify()
const classes = useStyles()
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('xs'))
+ const [loadingAction, setLoadingAction] = React.useState(null)
+ const isLoading = !!loadingAction
const handlePlay = React.useCallback(async () => {
+ setLoadingAction('play')
try {
await playTopSongs(dispatch, notify, record.name)
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error fetching top songs for artist:', e)
notify('ra.page.error', 'warning')
+ } finally {
+ setLoadingAction(null)
}
}, [dispatch, notify, record])
const handleShuffle = React.useCallback(async () => {
+ setLoadingAction('shuffle')
try {
await playShuffle(dataProvider, dispatch, record.id)
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error fetching songs for shuffle:', e)
notify('ra.page.error', 'warning')
+ } finally {
+ setLoadingAction(null)
}
}, [dataProvider, dispatch, record, notify])
const handleRadio = React.useCallback(async () => {
+ setLoadingAction('radio')
try {
await playSimilar(dispatch, notify, record.id)
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error starting radio for artist:', e)
notify('ra.page.error', 'warning')
+ } finally {
+ setLoadingAction(null)
}
}, [dispatch, notify, record])
@@ -88,30 +105,33 @@ const ArtistActions = ({ className, record, ...rest }) => {
className={`${className} ${classes.toolbar}`}
{...sanitizeListRestProps(rest)}
>
-
-
-
- }
+ />
+
-
-
- }
+ />
+
-
-
+ disabled={isLoading}
+ loading={loadingAction === 'radio'}
+ icon={ }
+ />
)
}
diff --git a/ui/src/artist/ArtistActions.test.jsx b/ui/src/artist/ArtistActions.test.jsx
index 90be28409..a11ee50e3 100644
--- a/ui/src/artist/ArtistActions.test.jsx
+++ b/ui/src/artist/ArtistActions.test.jsx
@@ -49,11 +49,21 @@ describe('ArtistActions', () => {
// Mock console.error to suppress error logging in tests
vi.spyOn(console, 'error').mockImplementation(() => {})
+ const songWithReplayGain = {
+ id: 'rec1',
+ replayGain: {
+ albumGain: -5,
+ albumPeak: 1,
+ trackGain: -6,
+ trackPeak: 0.8,
+ },
+ }
+
subsonic.getSimilarSongs2.mockResolvedValue({
json: {
'subsonic-response': {
status: 'ok',
- similarSongs2: { song: [{ id: 'rec1' }] },
+ similarSongs2: { song: [songWithReplayGain] },
},
},
})
@@ -61,7 +71,7 @@ describe('ArtistActions', () => {
json: {
'subsonic-response': {
status: 'ok',
- topSongs: { song: [{ id: 'rec1' }] },
+ topSongs: { song: [songWithReplayGain] },
},
},
})
@@ -93,6 +103,22 @@ describe('ArtistActions', () => {
)
expect(mockDispatch).toHaveBeenCalled()
})
+
+ it('maps replaygain info', async () => {
+ renderArtistActions()
+ clickActionButton('radio')
+
+ await waitFor(() =>
+ expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('ar1', 100),
+ )
+ const action = mockDispatch.mock.calls[0][0]
+ expect(action.data.rec1).toMatchObject({
+ rgAlbumGain: -5,
+ rgAlbumPeak: 1,
+ rgTrackGain: -6,
+ rgTrackPeak: 0.8,
+ })
+ })
})
describe('Play action', () => {
@@ -106,6 +132,22 @@ describe('ArtistActions', () => {
expect(mockDispatch).toHaveBeenCalled()
})
+ it('maps replaygain info for top songs', async () => {
+ renderArtistActions()
+ clickActionButton('topSongs')
+
+ await waitFor(() =>
+ expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
+ )
+ const action = mockDispatch.mock.calls[0][0]
+ expect(action.data.rec1).toMatchObject({
+ rgAlbumGain: -5,
+ rgAlbumPeak: 1,
+ rgTrackGain: -6,
+ rgTrackPeak: 0.8,
+ })
+ })
+
it('handles API rejection', async () => {
subsonic.getTopSongs.mockRejectedValue(new Error('Network error'))
diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx
index 7a14e9efe..e175763e3 100644
--- a/ui/src/artist/ArtistList.jsx
+++ b/ui/src/artist/ArtistList.jsx
@@ -132,8 +132,10 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
useResourceRefresh('artist')
const role = filterValues?.role
- const getCounter = (record, counter) =>
- role ? record?.stats[role]?.[counter] : record?.[counter]
+ const getCounter = (record, counter) => {
+ if (!record) return undefined
+ return role ? record?.stats?.[role]?.[counter] : record?.[counter]
+ }
const getAlbumCount = (record) => getCounter(record, 'albumCount')
const getSongCount = (record) => getCounter(record, 'songCount')
const getSize = (record) => {
diff --git a/ui/src/artist/actions.js b/ui/src/artist/actions.js
index 0ab648fa0..6a8fbd9c6 100644
--- a/ui/src/artist/actions.js
+++ b/ui/src/artist/actions.js
@@ -1,6 +1,32 @@
import subsonic from '../subsonic/index.js'
import { playTracks } from '../actions/index.js'
+const mapReplayGain = (song) => {
+ const { replayGain: rg } = song
+ if (!rg) {
+ return song
+ }
+
+ return {
+ ...song,
+ ...(rg.albumGain !== undefined && { rgAlbumGain: rg.albumGain }),
+ ...(rg.albumPeak !== undefined && { rgAlbumPeak: rg.albumPeak }),
+ ...(rg.trackGain !== undefined && { rgTrackGain: rg.trackGain }),
+ ...(rg.trackPeak !== undefined && { rgTrackPeak: rg.trackPeak }),
+ }
+}
+
+const processSongsForPlayback = (songs) => {
+ const songData = {}
+ const ids = []
+ songs.forEach((s) => {
+ const song = mapReplayGain(s)
+ songData[song.id] = song
+ ids.push(song.id)
+ })
+ return { songData, ids }
+}
+
export const playTopSongs = async (dispatch, notify, artistName) => {
const res = await subsonic.getTopSongs(artistName, 100)
const data = res.json['subsonic-response']
@@ -17,12 +43,7 @@ export const playTopSongs = async (dispatch, notify, artistName) => {
return
}
- const songData = {}
- const ids = []
- songs.forEach((s) => {
- songData[s.id] = s
- ids.push(s.id)
- })
+ const { songData, ids } = processSongsForPlayback(songs)
dispatch(playTracks(songData, ids))
}
@@ -42,12 +63,7 @@ export const playSimilar = async (dispatch, notify, id) => {
return
}
- const songData = {}
- const ids = []
- songs.forEach((s) => {
- songData[s.id] = s
- ids.push(s.id)
- })
+ const { songData, ids } = processSongsForPlayback(songs)
dispatch(playTracks(songData, ids))
}
diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx
index 05ca6ddf7..03419add3 100644
--- a/ui/src/audioplayer/Player.jsx
+++ b/ui/src/audioplayer/Player.jsx
@@ -127,6 +127,7 @@ const Player = () => {
/>
),
locale: locale(translate),
+ sortableOptions: { delay: 200, delayOnTouchOnly: true },
}),
[gainInfo, isDesktop, playerTheme, translate, playerState.mode],
)
diff --git a/ui/src/common/DateField.jsx b/ui/src/common/DateField.jsx
index fab15b53c..dce24a2b9 100644
--- a/ui/src/common/DateField.jsx
+++ b/ui/src/common/DateField.jsx
@@ -1,10 +1,11 @@
import React from 'react'
+import { isDateSet } from '../utils/validations'
import { DateField as RADateField } from 'react-admin'
export const DateField = (props) => {
const { record, source } = props
const value = record?.[source]
- if (value === '0001-01-01T00:00:00Z' || value === null) return null
+ if (!isDateSet(value)) return null
return
}
diff --git a/ui/src/common/LibrarySelector.jsx b/ui/src/common/LibrarySelector.jsx
new file mode 100644
index 000000000..1e89d3ec6
--- /dev/null
+++ b/ui/src/common/LibrarySelector.jsx
@@ -0,0 +1,221 @@
+import React, { useState, useEffect, useCallback } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+import { useDataProvider, useTranslate, useRefresh } from 'react-admin'
+import {
+ Box,
+ Chip,
+ ClickAwayListener,
+ FormControl,
+ FormGroup,
+ FormControlLabel,
+ Checkbox,
+ Typography,
+ Paper,
+ Popper,
+ makeStyles,
+} from '@material-ui/core'
+import { ExpandMore, ExpandLess, LibraryMusic } from '@material-ui/icons'
+import { setSelectedLibraries, setUserLibraries } from '../actions'
+import { useRefreshOnEvents } from './useRefreshOnEvents'
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ marginTop: theme.spacing(3),
+ marginBottom: theme.spacing(3),
+ paddingLeft: theme.spacing(2),
+ paddingRight: theme.spacing(2),
+ display: 'flex',
+ justifyContent: 'center',
+ },
+ chip: {
+ borderRadius: theme.spacing(1),
+ height: theme.spacing(4.8),
+ fontSize: '1rem',
+ fontWeight: 'normal',
+ minWidth: '210px',
+ justifyContent: 'flex-start',
+ paddingLeft: theme.spacing(1),
+ paddingRight: theme.spacing(1),
+ marginTop: theme.spacing(0.1),
+ '& .MuiChip-label': {
+ paddingLeft: theme.spacing(2),
+ paddingRight: theme.spacing(1),
+ },
+ '& .MuiChip-icon': {
+ fontSize: '1.2rem',
+ marginLeft: theme.spacing(0.5),
+ },
+ },
+ popper: {
+ zIndex: 1300,
+ },
+ paper: {
+ padding: theme.spacing(2),
+ marginTop: theme.spacing(1),
+ minWidth: 300,
+ maxWidth: 400,
+ },
+ headerContainer: {
+ display: 'flex',
+ alignItems: 'center',
+ marginBottom: 0,
+ },
+ masterCheckbox: {
+ padding: '7px',
+ marginLeft: '-9px',
+ marginRight: 0,
+ },
+}))
+
+const LibrarySelector = () => {
+ const classes = useStyles()
+ const dispatch = useDispatch()
+ const dataProvider = useDataProvider()
+ const translate = useTranslate()
+ const refresh = useRefresh()
+ const [anchorEl, setAnchorEl] = useState(null)
+ const [open, setOpen] = useState(false)
+
+ const { userLibraries, selectedLibraries } = useSelector(
+ (state) => state.library,
+ )
+
+ // Load user's libraries when component mounts
+ const loadUserLibraries = useCallback(async () => {
+ const userId = localStorage.getItem('userId')
+ if (userId) {
+ try {
+ const { data } = await dataProvider.getOne('user', { id: userId })
+ const libraries = data.libraries || []
+ dispatch(setUserLibraries(libraries))
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ 'Could not load user libraries (this may be expected for non-admin users):',
+ error,
+ )
+ }
+ }
+ }, [dataProvider, dispatch])
+
+ // Initial load
+ useEffect(() => {
+ loadUserLibraries()
+ }, [loadUserLibraries])
+
+ // Reload user libraries when library changes occur
+ useRefreshOnEvents({
+ events: ['library', 'user'],
+ onRefresh: loadUserLibraries,
+ })
+
+ // Don't render if user has no libraries or only has one library
+ if (!userLibraries.length || userLibraries.length === 1) {
+ return null
+ }
+
+ const handleToggle = (event) => {
+ setAnchorEl(event.currentTarget)
+ const wasOpen = open
+ setOpen(!open)
+ // Refresh data when closing the dropdown
+ if (wasOpen) {
+ refresh()
+ }
+ }
+
+ const handleClose = () => {
+ setOpen(false)
+ refresh()
+ }
+
+ const handleLibraryToggle = (libraryId) => {
+ const newSelection = selectedLibraries.includes(libraryId)
+ ? selectedLibraries.filter((id) => id !== libraryId)
+ : [...selectedLibraries, libraryId]
+
+ dispatch(setSelectedLibraries(newSelection))
+ }
+
+ const handleMasterCheckboxChange = () => {
+ if (isAllSelected) {
+ dispatch(setSelectedLibraries([]))
+ } else {
+ const allIds = userLibraries.map((lib) => lib.id)
+ dispatch(setSelectedLibraries(allIds))
+ }
+ }
+
+ const selectedCount = selectedLibraries.length
+ const totalCount = userLibraries.length
+ const isAllSelected = selectedCount === totalCount
+ const isNoneSelected = selectedCount === 0
+ const isIndeterminate = selectedCount > 0 && selectedCount < totalCount
+
+ const displayText = isNoneSelected
+ ? translate('menu.librarySelector.none') + ` (0 of ${totalCount})`
+ : isAllSelected
+ ? translate('menu.librarySelector.allLibraries', { count: totalCount })
+ : translate('menu.librarySelector.multipleLibraries', {
+ selected: selectedCount,
+ total: totalCount,
+ })
+
+ return (
+
+ }
+ label={displayText}
+ onClick={handleToggle}
+ onDelete={open ? handleToggle : undefined}
+ deleteIcon={open ? : }
+ variant="outlined"
+ className={classes.chip}
+ />
+
+
+
+
+
+
+
+ {translate('menu.librarySelector.selectLibraries')}:
+
+
+
+
+
+ {userLibraries.map((library) => (
+ handleLibraryToggle(library.id)}
+ size="small"
+ />
+ }
+ label={library.name}
+ />
+ ))}
+
+
+
+
+
+
+ )
+}
+
+export default LibrarySelector
diff --git a/ui/src/common/LibrarySelector.test.jsx b/ui/src/common/LibrarySelector.test.jsx
new file mode 100644
index 000000000..13b607887
--- /dev/null
+++ b/ui/src/common/LibrarySelector.test.jsx
@@ -0,0 +1,517 @@
+import React from 'react'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import LibrarySelector from './LibrarySelector'
+
+// Mock dependencies
+const mockDispatch = vi.fn()
+const mockDataProvider = {
+ getOne: vi.fn(),
+}
+const mockIdentity = { username: 'testuser' }
+const mockRefresh = vi.fn()
+const mockTranslate = vi.fn((key, options = {}) => {
+ const translations = {
+ 'menu.librarySelector.allLibraries': `All Libraries (${options.count || 0})`,
+ 'menu.librarySelector.multipleLibraries': `${options.selected || 0} of ${options.total || 0} Libraries`,
+ 'menu.librarySelector.none': 'None',
+ 'menu.librarySelector.selectLibraries': 'Select Libraries',
+ }
+ return translations[key] || key
+})
+
+vi.mock('react-redux', () => ({
+ useDispatch: () => mockDispatch,
+ useSelector: vi.fn(),
+}))
+
+vi.mock('react-admin', () => ({
+ useDataProvider: () => mockDataProvider,
+ useGetIdentity: () => ({ identity: mockIdentity }),
+ useTranslate: () => mockTranslate,
+ useRefresh: () => mockRefresh,
+}))
+
+// Mock Material-UI components
+vi.mock('@material-ui/core', () => ({
+ Box: ({ children, className, ...props }) => (
+
+ {children}
+
+ ),
+ Chip: ({ label, onClick, onDelete, deleteIcon, icon, ...props }) => (
+
+ {icon}
+ {label}
+ {deleteIcon && {deleteIcon} }
+
+ ),
+ ClickAwayListener: ({ children, onClickAway }) => (
+
+ {children}
+
+ ),
+ Collapse: ({ children, in: inProp }) =>
+ inProp ? {children}
: null,
+ FormControl: ({ children }) => {children}
,
+ FormGroup: ({ children }) => {children}
,
+ FormControlLabel: ({ control, label }) => (
+
+ {control}
+ {label}
+
+ ),
+ Checkbox: ({
+ checked,
+ indeterminate,
+ onChange,
+ size,
+ className,
+ ...props
+ }) => (
+ {
+ if (el) el.indeterminate = indeterminate
+ }}
+ onChange={onChange}
+ className={className}
+ {...props}
+ />
+ ),
+ Typography: ({ children, variant, ...props }) => (
+ {children}
+ ),
+ Paper: ({ children, className }) => (
+ {children}
+ ),
+ Popper: ({ open, children, anchorEl, placement, className }) =>
+ open ? (
+
+ {children}
+
+ ) : null,
+ makeStyles: (styles) => () => {
+ if (typeof styles === 'function') {
+ return styles({
+ spacing: (value) => `${value * 8}px`,
+ palette: { divider: '#ccc' },
+ shape: { borderRadius: 4 },
+ })
+ }
+ return styles
+ },
+}))
+
+vi.mock('@material-ui/icons', () => ({
+ ExpandMore: () => ▼ ,
+ ExpandLess: () => ▲ ,
+ LibraryMusic: () => 🎵 ,
+}))
+
+// Mock actions
+vi.mock('../actions', () => ({
+ setSelectedLibraries: (libraries) => ({
+ type: 'SET_SELECTED_LIBRARIES',
+ data: libraries,
+ }),
+ setUserLibraries: (libraries) => ({
+ type: 'SET_USER_LIBRARIES',
+ data: libraries,
+ }),
+}))
+
+describe('LibrarySelector', () => {
+ const mockLibraries = [
+ { id: '1', name: 'Music Library', path: '/music' },
+ { id: '2', name: 'Podcasts', path: '/podcasts' },
+ { id: '3', name: 'Audiobooks', path: '/audiobooks' },
+ ]
+
+ const defaultState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1'],
+ }
+
+ let mockUseSelector
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ const { useSelector } = await import('react-redux')
+ mockUseSelector = vi.mocked(useSelector)
+ mockDataProvider.getOne.mockResolvedValue({
+ data: { libraries: mockLibraries },
+ })
+ // Setup localStorage mock
+ Object.defineProperty(window, 'localStorage', {
+ value: {
+ getItem: vi.fn(() => null), // Default to null to prevent API calls
+ setItem: vi.fn(),
+ },
+ writable: true,
+ })
+ })
+
+ const renderLibrarySelector = (selectorState = defaultState) => {
+ mockUseSelector.mockImplementation((selector) =>
+ selector({ library: selectorState }),
+ )
+
+ return render( )
+ }
+
+ describe('when user has no libraries', () => {
+ it('should not render anything', () => {
+ const { container } = renderLibrarySelector({
+ userLibraries: [],
+ selectedLibraries: [],
+ })
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ describe('when user has only one library', () => {
+ it('should not render anything', () => {
+ const singleLibrary = [mockLibraries[0]]
+ const { container } = renderLibrarySelector({
+ userLibraries: singleLibrary,
+ selectedLibraries: ['1'],
+ })
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ describe('when user has multiple libraries', () => {
+ it('should render the chip with correct label when one library is selected', () => {
+ renderLibrarySelector()
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ expect(screen.getByText('1 of 3 Libraries')).toBeInTheDocument()
+ expect(screen.getByTestId('library-music')).toBeInTheDocument()
+ expect(screen.getByTestId('expand-more')).toBeInTheDocument()
+ })
+
+ it('should render the chip with "All Libraries" when all libraries are selected', () => {
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2', '3'],
+ })
+
+ expect(screen.getByText('All Libraries (3)')).toBeInTheDocument()
+ })
+
+ it('should render the chip with "None" when no libraries are selected', () => {
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: [],
+ })
+
+ expect(screen.getByText('None (0 of 3)')).toBeInTheDocument()
+ })
+
+ it('should show expand less icon when dropdown is open', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ expect(screen.getByTestId('expand-less')).toBeInTheDocument()
+ })
+
+ it('should open dropdown when chip is clicked', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ expect(screen.getByTestId('popper')).toBeInTheDocument()
+ expect(screen.getByText('Select Libraries:')).toBeInTheDocument()
+ })
+
+ it('should display all library names in dropdown', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ expect(screen.getByText('Music Library')).toBeInTheDocument()
+ expect(screen.getByText('Podcasts')).toBeInTheDocument()
+ expect(screen.getByText('Audiobooks')).toBeInTheDocument()
+ })
+
+ it('should not display library paths', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ expect(screen.queryByText('/music')).not.toBeInTheDocument()
+ expect(screen.queryByText('/podcasts')).not.toBeInTheDocument()
+ expect(screen.queryByText('/audiobooks')).not.toBeInTheDocument()
+ })
+
+ describe('master checkbox', () => {
+ it('should be checked when all libraries are selected', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2', '3'],
+ })
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0] // First checkbox is the master checkbox
+ expect(masterCheckbox.checked).toBe(true)
+ expect(masterCheckbox.indeterminate).toBe(false)
+ })
+
+ it('should be unchecked when no libraries are selected', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: [],
+ })
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0]
+ expect(masterCheckbox.checked).toBe(false)
+ expect(masterCheckbox.indeterminate).toBe(false)
+ })
+
+ it('should be indeterminate when some libraries are selected', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2'],
+ })
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0]
+ expect(masterCheckbox.checked).toBe(false)
+ expect(masterCheckbox.indeterminate).toBe(true)
+ })
+
+ it('should select all libraries when clicked and none are selected', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: [],
+ })
+
+ // Clear the dispatch mock after initial mount (it sets user libraries)
+ mockDispatch.mockClear()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0]
+
+ // Use fireEvent.click to trigger the onChange event
+ fireEvent.click(masterCheckbox)
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'SET_SELECTED_LIBRARIES',
+ data: ['1', '2', '3'],
+ })
+ })
+
+ it('should deselect all libraries when clicked and all are selected', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2', '3'],
+ })
+
+ // Clear the dispatch mock after initial mount (it sets user libraries)
+ mockDispatch.mockClear()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0]
+
+ fireEvent.click(masterCheckbox)
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'SET_SELECTED_LIBRARIES',
+ data: [],
+ })
+ })
+
+ it('should select all libraries when clicked and some are selected', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1'],
+ })
+
+ // Clear the dispatch mock after initial mount (it sets user libraries)
+ mockDispatch.mockClear()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0]
+
+ fireEvent.click(masterCheckbox)
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'SET_SELECTED_LIBRARIES',
+ data: ['1', '2', '3'],
+ })
+ })
+ })
+
+ describe('individual library checkboxes', () => {
+ it('should show correct checked state for each library', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '3'],
+ })
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ // Skip master checkbox (index 0)
+ expect(checkboxes[1].checked).toBe(true) // Music Library
+ expect(checkboxes[2].checked).toBe(false) // Podcasts
+ expect(checkboxes[3].checked).toBe(true) // Audiobooks
+ })
+
+ it('should toggle library selection when individual checkbox is clicked', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector()
+
+ // Clear the dispatch mock after initial mount (it sets user libraries)
+ mockDispatch.mockClear()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const podcastsCheckbox = checkboxes[2] // Podcasts checkbox
+
+ fireEvent.click(podcastsCheckbox)
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'SET_SELECTED_LIBRARIES',
+ data: ['1', '2'],
+ })
+ })
+
+ it('should remove library from selection when clicking checked library', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2'],
+ })
+
+ // Clear the dispatch mock after initial mount (it sets user libraries)
+ mockDispatch.mockClear()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const musicCheckbox = checkboxes[1] // Music Library checkbox
+
+ fireEvent.click(musicCheckbox)
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'SET_SELECTED_LIBRARIES',
+ data: ['2'],
+ })
+ })
+ })
+
+ it('should close dropdown when clicking away', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector()
+
+ // Open dropdown
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ expect(screen.getByTestId('popper')).toBeInTheDocument()
+
+ // Click away
+ const clickAwayListener = screen.getByTestId('click-away-listener')
+ fireEvent.mouseDown(clickAwayListener)
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('popper')).not.toBeInTheDocument()
+ })
+
+ // Should trigger refresh when closing
+ expect(mockRefresh).toHaveBeenCalledTimes(1)
+ })
+
+ it('should load user libraries on mount', async () => {
+ // Override localStorage mock to return a userId for this test
+ window.localStorage.getItem.mockReturnValue('user123')
+
+ mockDataProvider.getOne.mockResolvedValue({
+ data: { libraries: mockLibraries },
+ })
+
+ renderLibrarySelector({ userLibraries: [], selectedLibraries: [] })
+
+ await waitFor(() => {
+ expect(mockDataProvider.getOne).toHaveBeenCalledWith('user', {
+ id: 'user123',
+ })
+ })
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'SET_USER_LIBRARIES',
+ data: mockLibraries,
+ })
+ })
+
+ it('should handle API error gracefully', async () => {
+ // Override localStorage mock to return a userId for this test
+ window.localStorage.getItem.mockReturnValue('user123')
+
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ mockDataProvider.getOne.mockRejectedValue(new Error('API Error'))
+
+ renderLibrarySelector({ userLibraries: [], selectedLibraries: [] })
+
+ await waitFor(() => {
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Could not load user libraries (this may be expected for non-admin users):',
+ expect.any(Error),
+ )
+ })
+
+ consoleSpy.mockRestore()
+ })
+
+ it('should not load libraries when userId is not available', () => {
+ window.localStorage.getItem.mockReturnValue(null)
+
+ renderLibrarySelector({ userLibraries: [], selectedLibraries: [] })
+
+ expect(mockDataProvider.getOne).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/ui/src/common/Linkify.test.jsx b/ui/src/common/Linkify.test.jsx
index cef50b228..cd19ffa03 100644
--- a/ui/src/common/Linkify.test.jsx
+++ b/ui/src/common/Linkify.test.jsx
@@ -1,6 +1,5 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
-import '@testing-library/jest-dom'
import Linkify from './Linkify'
const URL = 'http://www.example.com'
diff --git a/ui/src/common/LoveButton.jsx b/ui/src/common/LoveButton.jsx
index f42d92ff4..c940acf12 100644
--- a/ui/src/common/LoveButton.jsx
+++ b/ui/src/common/LoveButton.jsx
@@ -7,6 +7,7 @@ import { makeStyles } from '@material-ui/core/styles'
import { useToggleLove } from './useToggleLove'
import { useRecordContext } from 'react-admin'
import config from '../config'
+import { isDateSet } from '../utils/validations'
const useStyles = makeStyles({
love: {
@@ -46,8 +47,13 @@ export const LoveButton = ({
{record.starred ? (
diff --git a/ui/src/common/RatingField.jsx b/ui/src/common/RatingField.jsx
index b29c1eee8..f92b0d948 100644
--- a/ui/src/common/RatingField.jsx
+++ b/ui/src/common/RatingField.jsx
@@ -2,6 +2,7 @@ import React, { useCallback } from 'react'
import PropTypes from 'prop-types'
import Rating from '@material-ui/lab/Rating'
import { makeStyles } from '@material-ui/core/styles'
+import { isDateSet } from '../utils/validations'
import StarBorderIcon from '@material-ui/icons/StarBorder'
import clsx from 'clsx'
import { useRating } from './useRating'
@@ -45,7 +46,14 @@ export const RatingField = ({
)
return (
- stopPropagation(e)}>
+ stopPropagation(e)}
+ title={
+ isDateSet(record.ratedAt)
+ ? new Date(record.ratedAt).toLocaleString()
+ : undefined
+ }
+ >
({
+ root: {
+ width: '960px',
+ maxWidth: '100%',
+ },
+ headerContainer: {
+ display: 'flex',
+ alignItems: 'center',
+ marginBottom: theme.spacing(1),
+ paddingLeft: theme.spacing(1),
+ },
+ masterCheckbox: {
+ padding: '7px',
+ marginLeft: '-9px',
+ marginRight: theme.spacing(1),
+ },
+ libraryList: {
+ height: '120px',
+ overflow: 'auto',
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: theme.shape.borderRadius,
+ backgroundColor: theme.palette.background.paper,
+ },
+ listItem: {
+ paddingTop: 0,
+ paddingBottom: 0,
+ },
+ emptyMessage: {
+ padding: theme.spacing(2),
+ textAlign: 'center',
+ color: theme.palette.text.secondary,
+ },
+}))
+
+const EmptyLibraryMessage = () => {
+ const classes = useStyles()
+
+ return (
+
+ No libraries available
+
+ )
+}
+
+const LibraryListItem = ({ library, isSelected, onToggle }) => {
+ const classes = useStyles()
+
+ return (
+ onToggle(library)}
+ dense
+ >
+
+ }
+ checkedIcon={ }
+ checked={isSelected}
+ tabIndex={-1}
+ disableRipple
+ />
+
+
+
+ )
+}
+
+export const SelectLibraryInput = ({
+ onChange,
+ value = [],
+ isNewUser = false,
+}) => {
+ const classes = useStyles()
+ const translate = useTranslate()
+ const [selectedLibraryIds, setSelectedLibraryIds] = useState([])
+ const [hasInitialized, setHasInitialized] = useState(false)
+
+ const { ids, data, isLoading } = useGetList(
+ 'library',
+ { page: 1, perPage: -1 },
+ { field: 'name', order: 'ASC' },
+ )
+
+ const options = useMemo(
+ () => (ids && ids.map((id) => data[id])) || [],
+ [ids, data],
+ )
+
+ // Reset initialization state when isNewUser changes
+ useEffect(() => {
+ if (isNewUser) {
+ setHasInitialized(false)
+ }
+ }, [isNewUser])
+
+ // Pre-select default libraries for new users
+ useEffect(() => {
+ if (
+ isNewUser &&
+ !isLoading &&
+ options.length > 0 &&
+ !hasInitialized &&
+ Array.isArray(value) &&
+ value.length === 0
+ ) {
+ const defaultLibraryIds = options
+ .filter((lib) => lib.defaultNewUsers)
+ .map((lib) => lib.id)
+
+ if (defaultLibraryIds.length > 0) {
+ setSelectedLibraryIds(defaultLibraryIds)
+ onChange(defaultLibraryIds)
+ }
+
+ setHasInitialized(true)
+ }
+ }, [isNewUser, isLoading, options, hasInitialized, value, onChange])
+
+ // Update selectedLibraryIds when value prop changes (for editing mode and pre-selection)
+ useEffect(() => {
+ // For new users, only sync from value prop if it has actual data
+ // This prevents empty initial state from overriding our pre-selection
+ if (isNewUser && Array.isArray(value) && value.length === 0) {
+ return
+ }
+
+ if (Array.isArray(value)) {
+ const libraryIds = value.map((item) =>
+ typeof item === 'object' ? item.id : item,
+ )
+ setSelectedLibraryIds(libraryIds)
+ } else if (value.length === 0) {
+ // Handle case where value is explicitly set to empty array (for existing users)
+ setSelectedLibraryIds([])
+ }
+ }, [value, isNewUser, hasInitialized])
+
+ const isLibrarySelected = (library) => selectedLibraryIds.includes(library.id)
+
+ const handleLibraryToggle = (library) => {
+ const isSelected = selectedLibraryIds.includes(library.id)
+ let newSelection
+
+ if (isSelected) {
+ newSelection = selectedLibraryIds.filter((id) => id !== library.id)
+ } else {
+ newSelection = [...selectedLibraryIds, library.id]
+ }
+
+ setSelectedLibraryIds(newSelection)
+ onChange(newSelection)
+ }
+
+ const handleMasterCheckboxChange = () => {
+ const isAllSelected = selectedLibraryIds.length === options.length
+ const newSelection = isAllSelected ? [] : options.map((lib) => lib.id)
+
+ setSelectedLibraryIds(newSelection)
+ onChange(newSelection)
+ }
+
+ const selectedCount = selectedLibraryIds.length
+ const totalCount = options.length
+ const isAllSelected = selectedCount === totalCount && totalCount > 0
+ const isIndeterminate = selectedCount > 0 && selectedCount < totalCount
+
+ return (
+
+ {options.length > 1 && (
+
+
+
+ {translate('resources.user.message.selectAllLibraries')}
+
+
+ )}
+
+ {options.length === 0 ? (
+
+ ) : (
+ options.map((library) => (
+
+ ))
+ )}
+
+
+ )
+}
+
+SelectLibraryInput.propTypes = {
+ onChange: PropTypes.func.isRequired,
+ value: PropTypes.array,
+ isNewUser: PropTypes.bool,
+}
+
+LibraryListItem.propTypes = {
+ library: PropTypes.object.isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ onToggle: PropTypes.func.isRequired,
+}
diff --git a/ui/src/common/SelectLibraryInput.test.jsx b/ui/src/common/SelectLibraryInput.test.jsx
new file mode 100644
index 000000000..8a7e56d3e
--- /dev/null
+++ b/ui/src/common/SelectLibraryInput.test.jsx
@@ -0,0 +1,458 @@
+import * as React from 'react'
+import { render, screen, fireEvent, cleanup } from '@testing-library/react'
+import { SelectLibraryInput } from './SelectLibraryInput'
+import { useGetList } from 'react-admin'
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+
+// Mock Material-UI components
+vi.mock('@material-ui/core', () => ({
+ List: ({ children }) => {children}
,
+ ListItem: ({ children, button, onClick, dense, className }) => (
+
+ {children}
+
+ ),
+ ListItemIcon: ({ children }) => {children} ,
+ ListItemText: ({ primary }) => {primary} ,
+ Typography: ({ children, variant }) => {children} ,
+ Box: ({ children, className }) => {children}
,
+ Checkbox: ({
+ checked,
+ indeterminate,
+ onChange,
+ size,
+ className,
+ ...props
+ }) => (
+ {
+ if (el) el.indeterminate = indeterminate
+ }}
+ onChange={onChange}
+ className={className}
+ {...props}
+ />
+ ),
+ makeStyles: () => () => ({}),
+}))
+
+// Mock Material-UI icons
+vi.mock('@material-ui/icons', () => ({
+ CheckBox: () => ☑ ,
+ CheckBoxOutlineBlank: () => ☐ ,
+}))
+
+// Mock the react-admin hook
+vi.mock('react-admin', () => ({
+ useGetList: vi.fn(),
+ useTranslate: vi.fn(() => (key) => key), // Simple translation mock
+}))
+
+describe(' ', () => {
+ const mockOnChange = vi.fn()
+
+ beforeEach(() => {
+ // Reset the mock before each test
+ mockOnChange.mockClear()
+ })
+
+ afterEach(cleanup)
+
+ it('should render empty message when no libraries available', () => {
+ // Mock empty library response
+ useGetList.mockReturnValue({
+ ids: [],
+ data: {},
+ })
+
+ render( )
+ expect(screen.getByText('No libraries available')).not.toBeNull()
+ })
+
+ it('should render libraries when available', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ render( )
+ expect(screen.getByText('Library 1')).not.toBeNull()
+ expect(screen.getByText('Library 2')).not.toBeNull()
+ })
+
+ it('should toggle selection when a library is clicked', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+
+ // Test selecting an item
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+ render( )
+
+ // Find the library buttons by their text content
+ const library1Button = screen.getByText('Library 1').closest('button')
+ fireEvent.click(library1Button)
+ expect(mockOnChange).toHaveBeenCalledWith(['1'])
+
+ // Clean up to avoid DOM duplication
+ cleanup()
+ mockOnChange.mockClear()
+
+ // Test deselecting an item
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+ render( )
+
+ // Find the library button again and click to deselect
+ const library1ButtonDeselect = screen
+ .getByText('Library 1')
+ .closest('button')
+ fireEvent.click(library1ButtonDeselect)
+ expect(mockOnChange).toHaveBeenCalledWith([])
+ })
+
+ it('should correctly initialize with provided values', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ // Initial value as array of IDs
+ render( )
+
+ // Check that checkbox for Library 1 is checked
+ const checkboxes = screen.getAllByRole('checkbox')
+ // With master checkbox, individual checkboxes start at index 1
+ expect(checkboxes[1].checked).toBe(true) // Library 1
+ expect(checkboxes[2].checked).toBe(false) // Library 2
+ })
+
+ it('should handle value as array of objects', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ // Initial value as array of objects with id property
+ render( )
+
+ // Check that checkbox for Library 2 is checked
+ const checkboxes = screen.getAllByRole('checkbox')
+ // With master checkbox, index shifts by 1
+ expect(checkboxes[1].checked).toBe(false) // Library 1
+ expect(checkboxes[2].checked).toBe(true) // Library 2
+ })
+
+ it('should render master checkbox when there are multiple libraries', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ render( )
+
+ // Should render master checkbox plus individual checkboxes
+ const checkboxes = screen.getAllByRole('checkbox')
+ expect(checkboxes).toHaveLength(3) // 1 master + 2 individual
+ expect(
+ screen.getByText('resources.user.message.selectAllLibraries'),
+ ).not.toBeNull()
+ })
+
+ it('should not render master checkbox when there is only one library', () => {
+ // Mock single library
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1'],
+ data: mockLibraries,
+ })
+
+ render( )
+
+ // Should render only individual checkbox
+ const checkboxes = screen.getAllByRole('checkbox')
+ expect(checkboxes).toHaveLength(1) // Only 1 individual checkbox
+ })
+
+ it('should handle master checkbox selection and deselection', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ render( )
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0] // Master is first
+
+ // Click master checkbox to select all
+ fireEvent.click(masterCheckbox)
+ expect(mockOnChange).toHaveBeenCalledWith(['1', '2'])
+
+ // Clean up and test deselect all
+ cleanup()
+ mockOnChange.mockClear()
+
+ render( )
+ const checkboxes2 = screen.getAllByRole('checkbox')
+ const masterCheckbox2 = checkboxes2[0]
+
+ // Click master checkbox to deselect all
+ fireEvent.click(masterCheckbox2)
+ expect(mockOnChange).toHaveBeenCalledWith([])
+ })
+
+ it('should show master checkbox as indeterminate when some libraries are selected', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ render( )
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0] // Master is first
+
+ // Master checkbox should not be checked when only some libraries are selected
+ expect(masterCheckbox.checked).toBe(false)
+ // Note: Testing indeterminate property directly through JSDOM can be unreliable
+ // The important behavior is that it's not checked when only some are selected
+ })
+
+ describe('New User Default Library Selection', () => {
+ // Helper function to create mock libraries with configurable default settings
+ const createMockLibraries = (libraryConfigs) => {
+ const libraries = {}
+ const ids = []
+
+ libraryConfigs.forEach(({ id, name, defaultNewUsers }) => {
+ libraries[id] = {
+ id,
+ name,
+ ...(defaultNewUsers !== undefined && { defaultNewUsers }),
+ }
+ ids.push(id)
+ })
+
+ return { libraries, ids }
+ }
+
+ // Helper function to setup useGetList mock
+ const setupMockLibraries = (libraryConfigs, isLoading = false) => {
+ const { libraries, ids } = createMockLibraries(libraryConfigs)
+ useGetList.mockReturnValue({
+ ids,
+ data: libraries,
+ isLoading,
+ })
+ return { libraries, ids }
+ }
+
+ beforeEach(() => {
+ mockOnChange.mockClear()
+ })
+
+ it('should pre-select default libraries for new users', () => {
+ setupMockLibraries([
+ { id: '1', name: 'Library 1', defaultNewUsers: true },
+ { id: '2', name: 'Library 2', defaultNewUsers: false },
+ { id: '3', name: 'Library 3', defaultNewUsers: true },
+ ])
+
+ render(
+ ,
+ )
+
+ expect(mockOnChange).toHaveBeenCalledWith(['1', '3'])
+ })
+
+ it('should not pre-select default libraries if new user already has values', () => {
+ setupMockLibraries([
+ { id: '1', name: 'Library 1', defaultNewUsers: true },
+ { id: '2', name: 'Library 2', defaultNewUsers: false },
+ ])
+
+ render(
+ ,
+ )
+
+ expect(mockOnChange).not.toHaveBeenCalled()
+ })
+
+ it('should not pre-select libraries while data is still loading', () => {
+ setupMockLibraries(
+ [{ id: '1', name: 'Library 1', defaultNewUsers: true }],
+ true,
+ ) // isLoading = true
+
+ render(
+ ,
+ )
+
+ expect(mockOnChange).not.toHaveBeenCalled()
+ })
+
+ it('should not pre-select anything if no libraries have defaultNewUsers flag', () => {
+ setupMockLibraries([
+ { id: '1', name: 'Library 1', defaultNewUsers: false },
+ { id: '2', name: 'Library 2', defaultNewUsers: false },
+ ])
+
+ render(
+ ,
+ )
+
+ expect(mockOnChange).not.toHaveBeenCalled()
+ })
+
+ it('should reset initialization state when isNewUser prop changes', () => {
+ setupMockLibraries([
+ { id: '1', name: 'Library 1', defaultNewUsers: true },
+ ])
+
+ const { rerender } = render(
+ ,
+ )
+
+ expect(mockOnChange).not.toHaveBeenCalled()
+
+ // Change to new user
+ rerender(
+ ,
+ )
+
+ expect(mockOnChange).toHaveBeenCalledWith(['1'])
+ })
+
+ it('should not override pre-selection when value prop is empty for new users', () => {
+ setupMockLibraries([
+ { id: '1', name: 'Library 1', defaultNewUsers: true },
+ { id: '2', name: 'Library 2', defaultNewUsers: false },
+ ])
+
+ const { rerender } = render(
+ ,
+ )
+
+ expect(mockOnChange).toHaveBeenCalledWith(['1'])
+ mockOnChange.mockClear()
+
+ // Re-render with empty value prop (simulating form state update)
+ rerender(
+ ,
+ )
+
+ expect(mockOnChange).not.toHaveBeenCalled()
+ })
+
+ it('should sync from value prop for existing users even when empty', () => {
+ setupMockLibraries([
+ { id: '1', name: 'Library 1', defaultNewUsers: true },
+ ])
+
+ render(
+ ,
+ )
+
+ // Check that no libraries are selected (checkboxes should be unchecked)
+ const checkboxes = screen.getAllByRole('checkbox')
+ // Only one checkbox since there's only one library and no master checkbox for single library
+ expect(checkboxes[0].checked).toBe(false)
+ })
+
+ it('should handle libraries with missing defaultNewUsers property', () => {
+ setupMockLibraries([
+ { id: '1', name: 'Library 1', defaultNewUsers: true },
+ { id: '2', name: 'Library 2' }, // Missing defaultNewUsers property
+ { id: '3', name: 'Library 3', defaultNewUsers: false },
+ ])
+
+ render(
+ ,
+ )
+
+ expect(mockOnChange).toHaveBeenCalledWith(['1'])
+ })
+ })
+})
diff --git a/ui/src/common/SongContextMenu.jsx b/ui/src/common/SongContextMenu.jsx
index 32fbeb243..f8b0bba5e 100644
--- a/ui/src/common/SongContextMenu.jsx
+++ b/ui/src/common/SongContextMenu.jsx
@@ -233,19 +233,29 @@ export const SongContextMenu = ({
open={open}
onClose={handleMainMenuClose}
>
- {Object.keys(options).map(
- (key) =>
+ {Object.keys(options).map((key) => {
+ const showInPlaylistDisabled =
+ key === 'showInPlaylist' && !playlists.length
+ return (
options[key].enabled && (
e.stopPropagation()
+ : handleItemClick
+ }
+ disabled={showInPlaylistDisabled}
+ style={
+ showInPlaylistDisabled ? { pointerEvents: 'auto' } : undefined
+ }
>
{options[key].label}
- ),
- )}
+ )
+ )
+ })}
({
vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() }))
+const getPlaylistsMock = vi.fn()
+
vi.mock('react-admin', async (importOriginal) => {
const actual = await importOriginal()
return {
@@ -18,9 +20,7 @@ vi.mock('react-admin', async (importOriginal) => {
window.location.hash = `#${url}`
},
useDataProvider: () => ({
- getPlaylists: vi.fn().mockResolvedValue({
- data: [{ id: 'pl1', name: 'Pl 1' }],
- }),
+ getPlaylists: getPlaylistsMock,
inspect: vi.fn().mockResolvedValue({
data: { rawTags: {} },
}),
@@ -32,6 +32,9 @@ describe('SongContextMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
window.location.hash = ''
+ getPlaylistsMock.mockResolvedValue({
+ data: [{ id: 'pl1', name: 'Pl 1' }],
+ })
})
it('navigates to playlist when selected', async () => {
@@ -79,4 +82,26 @@ describe('SongContextMenu', () => {
expect(mockOnClick).not.toHaveBeenCalled()
})
+
+ it('does nothing when "Show in Playlist" is disabled', async () => {
+ getPlaylistsMock.mockResolvedValue({ data: [] })
+ const mockOnClick = vi.fn()
+ render(
+
+
+
+
+ ,
+ )
+
+ fireEvent.click(screen.getAllByRole('button')[1])
+ await waitFor(() =>
+ screen.getByText(/resources\.song\.actions\.showInPlaylist/),
+ )
+
+ fireEvent.click(
+ screen.getByText(/resources\.song\.actions\.showInPlaylist/),
+ )
+ expect(mockOnClick).not.toHaveBeenCalled()
+ })
})
diff --git a/ui/src/common/SongInfo.jsx b/ui/src/common/SongInfo.jsx
index 9b9ca18cd..1b1a014f1 100644
--- a/ui/src/common/SongInfo.jsx
+++ b/ui/src/common/SongInfo.jsx
@@ -59,6 +59,7 @@ export const SongInfo = (props) => {
]
const data = {
path: ,
+ libraryName: ,
album: (
),
diff --git a/ui/src/common/index.js b/ui/src/common/index.js
index 1a43047c1..f64d4fe0c 100644
--- a/ui/src/common/index.js
+++ b/ui/src/common/index.js
@@ -27,6 +27,7 @@ export * from './useAlbumsPerPage'
export * from './useGetHandleArtistClick'
export * from './useInterval'
export * from './useResourceRefresh'
+export * from './useRefreshOnEvents'
export * from './useToggleLove'
export * from './useTraceUpdate'
export * from './Writable'
diff --git a/ui/src/common/useLibrarySelection.js b/ui/src/common/useLibrarySelection.js
new file mode 100644
index 000000000..c5d84a61f
--- /dev/null
+++ b/ui/src/common/useLibrarySelection.js
@@ -0,0 +1,44 @@
+import { useSelector } from 'react-redux'
+
+/**
+ * Hook to get the currently selected library IDs
+ * Returns an array of library IDs that should be used for filtering data
+ * If no libraries are selected (empty array), returns all user accessible libraries
+ */
+export const useSelectedLibraries = () => {
+ const { userLibraries, selectedLibraries } = useSelector(
+ (state) => state.library,
+ )
+
+ // If no specific selection, default to all accessible libraries
+ if (selectedLibraries.length === 0 && userLibraries.length > 0) {
+ return userLibraries.map((lib) => lib.id)
+ }
+
+ return selectedLibraries
+}
+
+/**
+ * Hook to get library filter parameters for data provider queries
+ * Returns an object that can be spread into query parameters
+ */
+export const useLibraryFilter = () => {
+ const selectedLibraryIds = useSelectedLibraries()
+
+ // If user has access to only one library or no specific selection, no filter needed
+ if (selectedLibraryIds.length <= 1) {
+ return {}
+ }
+
+ return {
+ libraryIds: selectedLibraryIds,
+ }
+}
+
+/**
+ * Hook to check if a specific library is currently selected
+ */
+export const useIsLibrarySelected = (libraryId) => {
+ const selectedLibraryIds = useSelectedLibraries()
+ return selectedLibraryIds.includes(libraryId)
+}
diff --git a/ui/src/common/useLibrarySelection.test.js b/ui/src/common/useLibrarySelection.test.js
new file mode 100644
index 000000000..30f109dc6
--- /dev/null
+++ b/ui/src/common/useLibrarySelection.test.js
@@ -0,0 +1,204 @@
+import { renderHook } from '@testing-library/react-hooks'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import {
+ useSelectedLibraries,
+ useLibraryFilter,
+ useIsLibrarySelected,
+} from './useLibrarySelection'
+
+// Mock dependencies
+vi.mock('react-redux', () => ({
+ useSelector: vi.fn(),
+}))
+
+describe('Library Selection Hooks', () => {
+ const mockLibraries = [
+ { id: '1', name: 'Music Library' },
+ { id: '2', name: 'Podcasts' },
+ { id: '3', name: 'Audiobooks' },
+ ]
+
+ let mockUseSelector
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ const { useSelector } = await import('react-redux')
+ mockUseSelector = vi.mocked(useSelector)
+ })
+
+ const setupSelector = (
+ userLibraries = mockLibraries,
+ selectedLibraries = [],
+ ) => {
+ mockUseSelector.mockImplementation((selector) =>
+ selector({
+ library: {
+ userLibraries,
+ selectedLibraries,
+ },
+ }),
+ )
+ }
+
+ describe('useSelectedLibraries', () => {
+ it('should return selected library IDs when libraries are explicitly selected', async () => {
+ setupSelector(mockLibraries, ['1', '2'])
+
+ const { result } = renderHook(() => useSelectedLibraries())
+
+ expect(result.current).toEqual(['1', '2'])
+ })
+
+ it('should return all user library IDs when no libraries are selected and user has libraries', async () => {
+ setupSelector(mockLibraries, [])
+
+ const { result } = renderHook(() => useSelectedLibraries())
+
+ expect(result.current).toEqual(['1', '2', '3'])
+ })
+
+ it('should return empty array when no libraries are selected and user has no libraries', async () => {
+ setupSelector([], [])
+
+ const { result } = renderHook(() => useSelectedLibraries())
+
+ expect(result.current).toEqual([])
+ })
+
+ it('should return selected libraries even if they are all user libraries', async () => {
+ setupSelector(mockLibraries, ['1', '2', '3'])
+
+ const { result } = renderHook(() => useSelectedLibraries())
+
+ expect(result.current).toEqual(['1', '2', '3'])
+ })
+
+ it('should return single selected library', async () => {
+ setupSelector(mockLibraries, ['2'])
+
+ const { result } = renderHook(() => useSelectedLibraries())
+
+ expect(result.current).toEqual(['2'])
+ })
+ })
+
+ describe('useLibraryFilter', () => {
+ it('should return empty object when user has only one library', async () => {
+ setupSelector([mockLibraries[0]], ['1'])
+
+ const { result } = renderHook(() => useLibraryFilter())
+
+ expect(result.current).toEqual({})
+ })
+
+ it('should return empty object when no libraries are selected (defaults to all)', async () => {
+ setupSelector([mockLibraries[0]], [])
+
+ const { result } = renderHook(() => useLibraryFilter())
+
+ expect(result.current).toEqual({})
+ })
+
+ it('should return libraryIds filter when multiple libraries are available and some are selected', async () => {
+ setupSelector(mockLibraries, ['1', '2'])
+
+ const { result } = renderHook(() => useLibraryFilter())
+
+ expect(result.current).toEqual({
+ libraryIds: ['1', '2'],
+ })
+ })
+
+ it('should return libraryIds filter when multiple libraries are available and all are selected', async () => {
+ setupSelector(mockLibraries, ['1', '2', '3'])
+
+ const { result } = renderHook(() => useLibraryFilter())
+
+ expect(result.current).toEqual({
+ libraryIds: ['1', '2', '3'],
+ })
+ })
+
+ it('should return empty object when user has no libraries', async () => {
+ setupSelector([], [])
+
+ const { result } = renderHook(() => useLibraryFilter())
+
+ expect(result.current).toEqual({})
+ })
+
+ it('should return libraryIds filter for default selection when multiple libraries available', async () => {
+ setupSelector(mockLibraries, []) // No explicit selection, should default to all
+
+ const { result } = renderHook(() => useLibraryFilter())
+
+ expect(result.current).toEqual({
+ libraryIds: ['1', '2', '3'],
+ })
+ })
+ })
+
+ describe('useIsLibrarySelected', () => {
+ it('should return true when library is explicitly selected', async () => {
+ setupSelector(mockLibraries, ['1', '3'])
+
+ const { result: result1 } = renderHook(() => useIsLibrarySelected('1'))
+ const { result: result2 } = renderHook(() => useIsLibrarySelected('3'))
+
+ expect(result1.current).toBe(true)
+ expect(result2.current).toBe(true)
+ })
+
+ it('should return false when library is not explicitly selected', async () => {
+ setupSelector(mockLibraries, ['1', '3'])
+
+ const { result } = renderHook(() => useIsLibrarySelected('2'))
+
+ expect(result.current).toBe(false)
+ })
+
+ it('should return true when no explicit selection (defaults to all) and library exists', async () => {
+ setupSelector(mockLibraries, [])
+
+ const { result: result1 } = renderHook(() => useIsLibrarySelected('1'))
+ const { result: result2 } = renderHook(() => useIsLibrarySelected('2'))
+ const { result: result3 } = renderHook(() => useIsLibrarySelected('3'))
+
+ expect(result1.current).toBe(true)
+ expect(result2.current).toBe(true)
+ expect(result3.current).toBe(true)
+ })
+
+ it('should return false when library does not exist in user libraries', async () => {
+ setupSelector(mockLibraries, [])
+
+ const { result } = renderHook(() => useIsLibrarySelected('999'))
+
+ expect(result.current).toBe(false)
+ })
+
+ it('should return false when user has no libraries', async () => {
+ setupSelector([], [])
+
+ const { result } = renderHook(() => useIsLibrarySelected('1'))
+
+ expect(result.current).toBe(false)
+ })
+
+ it('should handle undefined libraryId', async () => {
+ setupSelector(mockLibraries, ['1'])
+
+ const { result } = renderHook(() => useIsLibrarySelected(undefined))
+
+ expect(result.current).toBe(false)
+ })
+
+ it('should handle null libraryId', async () => {
+ setupSelector(mockLibraries, ['1'])
+
+ const { result } = renderHook(() => useIsLibrarySelected(null))
+
+ expect(result.current).toBe(false)
+ })
+ })
+})
diff --git a/ui/src/common/useRefreshOnEvents.jsx b/ui/src/common/useRefreshOnEvents.jsx
new file mode 100644
index 000000000..b5f1b1ede
--- /dev/null
+++ b/ui/src/common/useRefreshOnEvents.jsx
@@ -0,0 +1,109 @@
+import { useEffect, useState } from 'react'
+import { useSelector } from 'react-redux'
+
+/**
+ * A reusable hook for triggering custom reload logic when specific SSE events occur.
+ *
+ * This hook is ideal when:
+ * - Your component displays derived/related data that isn't directly managed by react-admin
+ * - You need custom loading logic that goes beyond simple dataProvider.getMany() calls
+ * - Your data comes from non-standard endpoints or requires special processing
+ * - You want to reload parent/related resources when child resources change
+ *
+ * @param {Object} options - Configuration options
+ * @param {Array} options.events - Array of event types to listen for (e.g., ['library', 'user', '*'])
+ * @param {Function} options.onRefresh - Async function to call when events occur.
+ * Should be wrapped in useCallback with appropriate dependencies to avoid unnecessary re-renders.
+ *
+ * @example
+ * // Example 1: LibrarySelector - Reload user data when library changes
+ * const loadUserLibraries = useCallback(async () => {
+ * const userId = localStorage.getItem('userId')
+ * if (userId) {
+ * const { data } = await dataProvider.getOne('user', { id: userId })
+ * dispatch(setUserLibraries(data.libraries || []))
+ * }
+ * }, [dataProvider, dispatch])
+ *
+ * useRefreshOnEvents({
+ * events: ['library', 'user'],
+ * onRefresh: loadUserLibraries
+ * })
+ *
+ * @example
+ * // Example 2: Statistics Dashboard - Reload stats when any music data changes
+ * const loadStats = useCallback(async () => {
+ * const stats = await dataProvider.customRequest('GET', 'stats')
+ * setDashboardStats(stats)
+ * }, [dataProvider, setDashboardStats])
+ *
+ * useRefreshOnEvents({
+ * events: ['album', 'song', 'artist'],
+ * onRefresh: loadStats
+ * })
+ *
+ * @example
+ * // Example 3: Permission-based UI - Reload permissions when user changes
+ * const loadPermissions = useCallback(async () => {
+ * const authData = await authProvider.getPermissions()
+ * setUserPermissions(authData)
+ * }, [authProvider, setUserPermissions])
+ *
+ * useRefreshOnEvents({
+ * events: ['user'],
+ * onRefresh: loadPermissions
+ * })
+ *
+ * @example
+ * // Example 4: Listen to all events (use sparingly)
+ * const reloadAll = useCallback(async () => {
+ * // This will trigger on ANY refresh event
+ * await reloadEverything()
+ * }, [reloadEverything])
+ *
+ * useRefreshOnEvents({
+ * events: ['*'],
+ * onRefresh: reloadAll
+ * })
+ */
+export const useRefreshOnEvents = ({ events, onRefresh }) => {
+ const [lastRefreshTime, setLastRefreshTime] = useState(Date.now())
+
+ const refreshData = useSelector(
+ (state) => state.activity?.refresh || { lastReceived: lastRefreshTime },
+ )
+
+ useEffect(() => {
+ const { resources, lastReceived } = refreshData
+
+ // Only process if we have new events
+ if (lastReceived <= lastRefreshTime) {
+ return
+ }
+
+ // Check if any of the events we're interested in occurred
+ const shouldRefresh =
+ resources &&
+ // Global refresh event
+ (resources['*'] === '*' ||
+ // Check for specific events we're listening to
+ events.some((eventType) => {
+ if (eventType === '*') {
+ return true // Listen to all events
+ }
+ return resources[eventType] // Check if this specific event occurred
+ }))
+
+ if (shouldRefresh) {
+ setLastRefreshTime(lastReceived)
+
+ // Call the custom refresh function
+ if (onRefresh) {
+ onRefresh().catch((error) => {
+ // eslint-disable-next-line no-console
+ console.warn('Error in useRefreshOnEvents onRefresh callback:', error)
+ })
+ }
+ }
+ }, [refreshData, lastRefreshTime, events, onRefresh])
+}
diff --git a/ui/src/common/useRefreshOnEvents.test.js b/ui/src/common/useRefreshOnEvents.test.js
new file mode 100644
index 000000000..2306cd3c9
--- /dev/null
+++ b/ui/src/common/useRefreshOnEvents.test.js
@@ -0,0 +1,233 @@
+import { vi } from 'vitest'
+import * as React from 'react'
+import * as Redux from 'react-redux'
+import { useRefreshOnEvents } from './useRefreshOnEvents'
+
+vi.mock('react', async () => {
+ const actual = await vi.importActual('react')
+ return {
+ ...actual,
+ useState: vi.fn(),
+ useEffect: vi.fn(),
+ }
+})
+
+vi.mock('react-redux', async () => {
+ const actual = await vi.importActual('react-redux')
+ return {
+ ...actual,
+ useSelector: vi.fn(),
+ }
+})
+
+describe('useRefreshOnEvents', () => {
+ const setState = vi.fn()
+ const useStateMock = (initState) => [initState, setState]
+ const onRefresh = vi.fn().mockResolvedValue()
+ let lastTime
+ let mockUseEffect
+
+ beforeEach(() => {
+ vi.spyOn(React, 'useState').mockImplementation(useStateMock)
+ mockUseEffect = vi.spyOn(React, 'useEffect')
+ lastTime = new Date(new Date().valueOf() + 1000)
+ onRefresh.mockClear()
+ setState.mockClear()
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('stores last time checked, to avoid redundant runs', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { library: ['lib-1'] }, // Need some resources to trigger the update
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ // Mock useEffect to immediately call the effect callback
+ mockUseEffect.mockImplementation((callback) => callback())
+
+ useRefreshOnEvents({
+ events: ['library'],
+ onRefresh,
+ })
+
+ expect(setState).toHaveBeenCalledWith(lastTime)
+ })
+
+ it("does not run again if lastTime didn't change", () => {
+ vi.spyOn(React, 'useState').mockImplementation(() => [lastTime, setState])
+ const useSelectorMock = () => ({ lastReceived: lastTime })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ // Mock useEffect to immediately call the effect callback
+ mockUseEffect.mockImplementation((callback) => callback())
+
+ useRefreshOnEvents({
+ events: ['library'],
+ onRefresh,
+ })
+
+ expect(setState).not.toHaveBeenCalled()
+ expect(onRefresh).not.toHaveBeenCalled()
+ })
+
+ describe('Event listening and refresh triggering', () => {
+ beforeEach(() => {
+ // Mock useEffect to immediately call the effect callback
+ mockUseEffect.mockImplementation((callback) => callback())
+ })
+
+ it('triggers refresh when a watched event occurs', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { library: ['lib-1', 'lib-2'] },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ useRefreshOnEvents({
+ events: ['library'],
+ onRefresh,
+ })
+
+ expect(onRefresh).toHaveBeenCalledTimes(1)
+ expect(setState).toHaveBeenCalledWith(lastTime)
+ })
+
+ it('triggers refresh when multiple watched events occur', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: {
+ library: ['lib-1'],
+ user: ['user-1'],
+ album: ['album-1'], // This shouldn't trigger since it's not watched
+ },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ useRefreshOnEvents({
+ events: ['library', 'user'],
+ onRefresh,
+ })
+
+ expect(onRefresh).toHaveBeenCalledTimes(1)
+ expect(setState).toHaveBeenCalledWith(lastTime)
+ })
+
+ it('does not trigger refresh when unwatched events occur', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { album: ['album-1'], song: ['song-1'] },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ useRefreshOnEvents({
+ events: ['library', 'user'],
+ onRefresh,
+ })
+
+ expect(onRefresh).not.toHaveBeenCalled()
+ expect(setState).not.toHaveBeenCalled()
+ })
+
+ it('triggers refresh on global refresh event', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { '*': '*' },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ useRefreshOnEvents({
+ events: ['library'],
+ onRefresh,
+ })
+
+ expect(onRefresh).toHaveBeenCalledTimes(1)
+ expect(setState).toHaveBeenCalledWith(lastTime)
+ })
+
+ it('triggers refresh when listening to all events with "*"', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { song: ['song-1'] },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ useRefreshOnEvents({
+ events: ['*'],
+ onRefresh,
+ })
+
+ expect(onRefresh).toHaveBeenCalledTimes(1)
+ expect(setState).toHaveBeenCalledWith(lastTime)
+ })
+
+ it('handles empty events array gracefully', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { library: ['lib-1'] },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ useRefreshOnEvents({
+ events: [],
+ onRefresh,
+ })
+
+ expect(onRefresh).not.toHaveBeenCalled()
+ expect(setState).not.toHaveBeenCalled()
+ })
+
+ it('handles missing onRefresh function gracefully', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { library: ['lib-1'] },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ expect(() => {
+ useRefreshOnEvents({
+ events: ['library'],
+ // onRefresh is undefined
+ })
+ }).not.toThrow()
+
+ expect(setState).toHaveBeenCalledWith(lastTime)
+ })
+
+ it('handles onRefresh errors gracefully', async () => {
+ const consoleWarnSpy = vi
+ .spyOn(console, 'warn')
+ .mockImplementation(() => {})
+ const failingRefresh = vi
+ .fn()
+ .mockRejectedValue(new Error('Refresh failed'))
+
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { library: ['lib-1'] },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ useRefreshOnEvents({
+ events: ['library'],
+ onRefresh: failingRefresh,
+ })
+
+ expect(failingRefresh).toHaveBeenCalledTimes(1)
+ expect(setState).toHaveBeenCalledWith(lastTime)
+
+ // Wait for the promise to be rejected and handled
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ 'Error in useRefreshOnEvents onRefresh callback:',
+ expect.any(Error),
+ )
+
+ consoleWarnSpy.mockRestore()
+ })
+ })
+})
diff --git a/ui/src/common/useResourceRefresh.jsx b/ui/src/common/useResourceRefresh.jsx
index d9f6aee52..eabff6f92 100644
--- a/ui/src/common/useResourceRefresh.jsx
+++ b/ui/src/common/useResourceRefresh.jsx
@@ -2,6 +2,67 @@ import { useSelector } from 'react-redux'
import { useState } from 'react'
import { useRefresh, useDataProvider } from 'react-admin'
+/**
+ * A hook that automatically refreshes react-admin managed resources when refresh events are received via SSE.
+ *
+ * This hook is designed for components that display react-admin managed resources (like lists, shows, edits)
+ * and need to stay in sync when those resources are modified elsewhere in the application.
+ *
+ * **When to use this hook:**
+ * - Your component displays react-admin resources (albums, songs, artists, playlists, etc.)
+ * - You want automatic refresh when those resources are created/updated/deleted
+ * - Your data comes from standard dataProvider.getMany() calls
+ * - You're using react-admin's data management (queries, mutations, caching)
+ *
+ * **When NOT to use this hook:**
+ * - Your component displays derived/custom data not directly managed by react-admin
+ * - You need custom reload logic beyond dataProvider.getMany()
+ * - Your data comes from non-standard endpoints
+ * - Use `useRefreshOnEvents` instead for these scenarios
+ *
+ * @param {...string} visibleResources - Resource names to watch for changes.
+ * If no resources specified, watches all resources.
+ * If '*' is included in resources, triggers full page refresh.
+ *
+ * @example
+ * // Example 1: Album list - refresh when albums change
+ * const AlbumList = () => {
+ * useResourceRefresh('album')
+ * return ...
+ * }
+ *
+ * @example
+ * // Example 2: Album show page - refresh when album or its songs change
+ * const AlbumShow = () => {
+ * useResourceRefresh('album', 'song')
+ * return ...
+ * }
+ *
+ * @example
+ * // Example 3: Dashboard - refresh when any resource changes
+ * const Dashboard = () => {
+ * useResourceRefresh() // No parameters = watch all resources
+ * return ...
+ * }
+ *
+ * @example
+ * // Example 4: Library management page - watch library resources
+ * const LibraryList = () => {
+ * useResourceRefresh('library')
+ * return ...
+ * }
+ *
+ * **How it works:**
+ * - Listens to refresh events from the SSE connection
+ * - When events arrive, checks if they match the specified visible resources
+ * - For specific resource IDs: calls dataProvider.getMany(resource, {ids: [...]})
+ * - For global refreshes: calls refresh() to reload the entire page
+ * - Uses react-admin's built-in data management and caching
+ *
+ * **Event format expected:**
+ * - Global refresh: { '*': '*' } or { someResource: ['*'] }
+ * - Specific resources: { album: ['id1', 'id2'], song: ['id3'] }
+ */
export const useResourceRefresh = (...visibleResources) => {
const [lastTime, setLastTime] = useState(Date.now())
const refresh = useRefresh()
diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js
index 257a274e8..268d3668d 100644
--- a/ui/src/dataProvider/wrapperDataProvider.js
+++ b/ui/src/dataProvider/wrapperDataProvider.js
@@ -9,25 +9,73 @@ const isAdmin = () => {
return role === 'admin'
}
+const getSelectedLibraries = () => {
+ try {
+ const state = JSON.parse(localStorage.getItem('state'))
+ const selectedLibraries = state?.library?.selectedLibraries || []
+ const userLibraries = state?.library?.userLibraries || []
+
+ // Validate selected libraries against current user libraries
+ const userLibraryIds = userLibraries.map((lib) => lib.id)
+ const validatedSelection = selectedLibraries.filter((id) =>
+ userLibraryIds.includes(id),
+ )
+
+ // If user has only one library, return empty array (no filter needed)
+ if (userLibraryIds.length === 1) {
+ return []
+ }
+
+ return validatedSelection
+ } catch (err) {
+ return []
+ }
+}
+
+// Function to apply library filtering to appropriate resources
+const applyLibraryFilter = (resource, params) => {
+ // Content resources that should be filtered by selected libraries
+ const filteredResources = ['album', 'song', 'artist', 'playlistTrack', 'tag']
+
+ // Get selected libraries from localStorage
+ const selectedLibraries = getSelectedLibraries()
+
+ // Add library filter for content resources if libraries are selected
+ if (filteredResources.includes(resource) && selectedLibraries.length > 0) {
+ if (!params.filter) {
+ params.filter = {}
+ }
+ params.filter.library_id = selectedLibraries
+ }
+
+ return params
+}
+
const mapResource = (resource, params) => {
switch (resource) {
+ // /api/playlistTrack?playlist_id=123 => /api/playlist/123/tracks
case 'playlistTrack': {
- // /api/playlistTrack?playlist_id=123 => /api/playlist/123/tracks
+ params.filter = params.filter || {}
+
let plsId = '0'
- if (params.filter) {
- plsId = params.filter.playlist_id
- if (!isAdmin()) {
- params.filter.missing = false
- }
+ plsId = params.filter.playlist_id
+ if (!isAdmin()) {
+ params.filter.missing = false
}
+ params = applyLibraryFilter(resource, params)
+
return [`playlist/${plsId}/tracks`, params]
}
case 'album':
case 'song':
- case 'artist': {
- if (params.filter && !isAdmin()) {
+ case 'artist':
+ case 'tag': {
+ params.filter = params.filter || {}
+ if (!isAdmin()) {
params.filter.missing = false
}
+ params = applyLibraryFilter(resource, params)
+
return [resource, params]
}
default:
@@ -43,6 +91,60 @@ const callDeleteMany = (resource, params) => {
}).then((response) => ({ data: response.json.ids || [] }))
}
+// Helper function to handle user-library associations
+const handleUserLibraryAssociation = async (userId, libraryIds) => {
+ if (!libraryIds || libraryIds.length === 0) {
+ return // Admin users or users without library assignments
+ }
+
+ try {
+ await httpClient(`${REST_URL}/user/${userId}/library`, {
+ method: 'PUT',
+ body: JSON.stringify({ libraryIds }),
+ })
+ } catch (error) {
+ console.error('Error setting user libraries:', error) //eslint-disable-line no-console
+ throw error
+ }
+}
+
+// Enhanced user creation that handles library associations
+const createUser = async (params) => {
+ const { data } = params
+ const { libraryIds, ...userData } = data
+
+ // First create the user
+ const userResponse = await dataProvider.create('user', { data: userData })
+ const userId = userResponse.data.id
+
+ // Then set library associations for non-admin users
+ if (!userData.isAdmin && libraryIds && libraryIds.length > 0) {
+ await handleUserLibraryAssociation(userId, libraryIds)
+ }
+
+ return userResponse
+}
+
+// Enhanced user update that handles library associations
+const updateUser = async (params) => {
+ const { data } = params
+ const { libraryIds, ...userData } = data
+ const userId = params.id
+
+ // First update the user
+ const userResponse = await dataProvider.update('user', {
+ ...params,
+ data: userData,
+ })
+
+ // Then handle library associations for non-admin users
+ if (!userData.isAdmin && libraryIds !== undefined) {
+ await handleUserLibraryAssociation(userId, libraryIds)
+ }
+
+ return userResponse
+}
+
const wrapperDataProvider = {
...dataProvider,
getList: (resource, params) => {
@@ -51,7 +153,19 @@ const wrapperDataProvider = {
},
getOne: (resource, params) => {
const [r, p] = mapResource(resource, params)
- return dataProvider.getOne(r, p)
+ const response = dataProvider.getOne(r, p)
+
+ // Transform user data to ensure libraryIds is present for form compatibility
+ if (resource === 'user') {
+ return response.then((result) => {
+ if (result.data.libraries && Array.isArray(result.data.libraries)) {
+ result.data.libraryIds = result.data.libraries.map((lib) => lib.id)
+ }
+ return result
+ })
+ }
+
+ return response
},
getMany: (resource, params) => {
const [r, p] = mapResource(resource, params)
@@ -62,6 +176,9 @@ const wrapperDataProvider = {
return dataProvider.getManyReference(r, p)
},
update: (resource, params) => {
+ if (resource === 'user') {
+ return updateUser(params)
+ }
const [r, p] = mapResource(resource, params)
return dataProvider.update(r, p)
},
@@ -70,6 +187,9 @@ const wrapperDataProvider = {
return dataProvider.updateMany(r, p)
},
create: (resource, params) => {
+ if (resource === 'user') {
+ return createUser(params)
+ }
const [r, p] = mapResource(resource, params)
return dataProvider.create(r, p)
},
diff --git a/ui/src/dialogs/SelectPlaylistInput.jsx b/ui/src/dialogs/SelectPlaylistInput.jsx
index 0e0636924..d401dd822 100644
--- a/ui/src/dialogs/SelectPlaylistInput.jsx
+++ b/ui/src/dialogs/SelectPlaylistInput.jsx
@@ -226,7 +226,7 @@ const SelectedPlaylistChip = ({ playlist, onRemove }) => {
onClick={() => onRemove(playlist)}
title={translate('resources.playlist.actions.removeFromSelection')}
>
- {translate('resources.playlist.actions.removeSymbol')}
+ {'×'}
)
diff --git a/ui/src/dialogs/SelectPlaylistInput.test.jsx b/ui/src/dialogs/SelectPlaylistInput.test.jsx
index 93a14b325..4ffcdf0b6 100644
--- a/ui/src/dialogs/SelectPlaylistInput.test.jsx
+++ b/ui/src/dialogs/SelectPlaylistInput.test.jsx
@@ -205,9 +205,7 @@ describe('SelectPlaylistInput', () => {
})
// Find and click the remove button (translation key)
- const removeButton = screen.getByText(
- 'resources.playlist.actions.removeSymbol',
- )
+ const removeButton = screen.getByText('×')
fireEvent.click(removeButton)
await waitFor(() => {
@@ -480,9 +478,7 @@ describe('SelectPlaylistInput', () => {
})
// Remove the first selected playlist via chip
- const removeButtons = screen.getAllByText(
- 'resources.playlist.actions.removeSymbol',
- )
+ const removeButtons = screen.getAllByText('×')
fireEvent.click(removeButtons[0])
await waitFor(() => {
diff --git a/ui/src/eventStream.test.js b/ui/src/eventStream.test.js
index 77d061c19..27f53c872 100644
--- a/ui/src/eventStream.test.js
+++ b/ui/src/eventStream.test.js
@@ -25,13 +25,15 @@ describe('startEventStream', () => {
beforeEach(() => {
dispatch = vi.fn()
- global.EventSource = vi.fn((url) => {
+ global.EventSource = vi.fn().mockImplementation(function (url) {
instance = new MockEventSource(url)
return instance
})
localStorage.setItem('is-authenticated', 'true')
localStorage.setItem('token', 'abc')
config.devNewEventStream = true
+ // Mock console.log to suppress output during tests
+ vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(() => {
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 7bd124ec6..9ef65d668 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -12,6 +12,7 @@
"artist": "Artist",
"album": "Album",
"path": "File path",
+ "libraryName": "Library",
"genre": "Genre",
"compilation": "Compilation",
"year": "Year",
@@ -58,6 +59,7 @@
"playCount": "Plays",
"size": "Size",
"name": "Name",
+ "libraryName": "Library",
"genre": "Genre",
"compilation": "Compilation",
"year": "Year",
@@ -147,19 +149,26 @@
"changePassword": "Change Password?",
"currentPassword": "Current Password",
"newPassword": "New Password",
- "token": "Token"
+ "token": "Token",
+ "libraries": "Libraries"
},
"helperTexts": {
- "name": "Changes to your name will only be reflected on next login"
+ "name": "Changes to your name will only be reflected on next login",
+ "libraries": "Select specific libraries for this user, or leave empty to use default libraries"
},
"notifications": {
"created": "User created",
"updated": "User updated",
"deleted": "User deleted"
},
+ "validation": {
+ "librariesRequired": "At least one library must be selected for non-admin users"
+ },
"message": {
"listenBrainzToken": "Enter your ListenBrainz user token.",
- "clickHereForToken": "Click here to get your token"
+ "clickHereForToken": "Click here to get your token",
+ "selectAllLibraries": "Select all libraries",
+ "adminAutoLibraries": "Admin users automatically have access to all libraries"
}
},
"player": {
@@ -207,8 +216,7 @@
"makePrivate": "Make Private",
"searchOrCreate": "Search playlists or type to create new...",
"pressEnterToCreate": "Press Enter to create new playlist",
- "removeFromSelection": "Remove from selection",
- "removeSymbol": "×"
+ "removeFromSelection": "Remove from selection"
},
"message": {
"duplicate_song": "Add duplicated songs",
@@ -255,6 +263,7 @@
"fields": {
"path": "Path",
"size": "Size",
+ "libraryName": "Library",
"updatedAt": "Disappeared on"
},
"actions": {
@@ -264,6 +273,63 @@
"notifications": {
"removed": "Missing file(s) removed"
}
+ },
+ "library": {
+ "name": "Library |||| Libraries",
+ "fields": {
+ "name": "Name",
+ "path": "Path",
+ "remotePath": "Remote Path",
+ "lastScanAt": "Last Scan",
+ "songCount": "Songs",
+ "albumCount": "Albums",
+ "artistCount": "Artists",
+ "totalSongs": "Songs",
+ "totalAlbums": "Albums",
+ "totalArtists": "Artists",
+ "totalFolders": "Folders",
+ "totalFiles": "Files",
+ "totalMissingFiles": "Missing Files",
+ "totalSize": "Total Size",
+ "totalDuration": "Duration",
+ "defaultNewUsers": "Default for New Users",
+ "createdAt": "Created",
+ "updatedAt": "Updated"
+ },
+ "sections": {
+ "basic": "Basic Information",
+ "statistics": "Statistics"
+ },
+ "actions": {
+ "scan": "Scan Library",
+ "quickScan": "Quick Scan",
+ "fullScan": "Full Scan",
+ "manageUsers": "Manage User Access",
+ "viewDetails": "View Details"
+ },
+ "notifications": {
+ "created": "Library created successfully",
+ "updated": "Library updated successfully",
+ "deleted": "Library deleted successfully",
+ "scanStarted": "Library scan started",
+ "quickScanStarted": "Quick scan started",
+ "fullScanStarted": "Full scan started",
+ "scanError": "Error starting scan. Check logs",
+ "scanCompleted": "Library scan completed"
+ },
+ "validation": {
+ "nameRequired": "Library name is required",
+ "pathRequired": "Library path is required",
+ "pathNotDirectory": "Library path must be a directory",
+ "pathNotFound": "Library path not found",
+ "pathNotAccessible": "Library path is not accessible",
+ "pathInvalid": "Invalid library path"
+ },
+ "messages": {
+ "deleteConfirm": "Are you sure you want to delete this library? This will remove all associated data and user access.",
+ "scanInProgress": "Scan in progress...",
+ "noLibrariesAssigned": "No libraries assigned to this user"
+ }
}
},
"ra": {
@@ -451,6 +517,12 @@
},
"menu": {
"library": "Library",
+ "librarySelector": {
+ "allLibraries": "All Libraries (%{count})",
+ "multipleLibraries": "%{selected} of %{total} Libraries",
+ "selectLibraries": "Select Libraries",
+ "none": "None"
+ },
"settings": "Settings",
"version": "Version",
"theme": "Theme",
@@ -533,11 +605,12 @@
"activity": {
"title": "Activity",
"totalScanned": "Total Folders Scanned",
- "quickScan": "Quick Scan",
- "fullScan": "Full Scan",
+ "quickScan": "Quick",
+ "fullScan": "Full",
+ "selectiveScan": "Selective",
"serverUptime": "Server Uptime",
"serverDown": "OFFLINE",
- "scanType": "Type",
+ "scanType": "Last Scan",
"status": "Scan Error",
"elapsedTime": "Elapsed Time"
},
diff --git a/ui/src/layout/ActivityPanel.jsx b/ui/src/layout/ActivityPanel.jsx
index 6b50cee0c..6d5d32d31 100644
--- a/ui/src/layout/ActivityPanel.jsx
+++ b/ui/src/layout/ActivityPanel.jsx
@@ -75,14 +75,25 @@ const ActivityPanel = () => {
scanStatus.scanning,
scanStatus.elapsedTime,
)
- const classes = useStyles({ up: up && !scanStatus.error })
+ const [acknowledgedError, setAcknowledgedError] = useState(null)
+ const isErrorVisible =
+ scanStatus.error && scanStatus.error !== acknowledgedError
+ const classes = useStyles({
+ up: up && (!scanStatus.error || !isErrorVisible),
+ })
const translate = useTranslate()
const notify = useNotify()
const [anchorEl, setAnchorEl] = useState(null)
const open = Boolean(anchorEl)
useInitialScanStatus()
- const handleMenuOpen = (event) => setAnchorEl(event.currentTarget)
+ const handleMenuOpen = (event) => {
+ if (scanStatus.error) {
+ setAcknowledgedError(scanStatus.error)
+ }
+ setAnchorEl(event.currentTarget)
+ }
+
const handleMenuClose = () => setAnchorEl(null)
const triggerScan = (full) => () => subsonic.startScan({ fullScan: full })
@@ -102,6 +113,9 @@ const ActivityPanel = () => {
return translate('activity.fullScan')
case 'quick':
return translate('activity.quickScan')
+ case 'full-selective':
+ case 'quick-selective':
+ return translate('activity.selectiveScan')
default:
return ''
}
@@ -111,10 +125,10 @@ const ActivityPanel = () => {
- {!up || scanStatus.error ? (
-
+ {!up || isErrorVisible ? (
+
) : (
-
+
)}
diff --git a/ui/src/layout/ActivityPanel.test.jsx b/ui/src/layout/ActivityPanel.test.jsx
new file mode 100644
index 000000000..c506fd08b
--- /dev/null
+++ b/ui/src/layout/ActivityPanel.test.jsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { createStore, combineReducers } from 'redux'
+import { describe, it, beforeEach } from 'vitest'
+
+import ActivityPanel from './ActivityPanel'
+import { activityReducer } from '../reducers'
+import config from '../config'
+import subsonic from '../subsonic'
+
+vi.mock('../subsonic', () => ({
+ default: {
+ getScanStatus: vi.fn(() =>
+ Promise.resolve({
+ json: {
+ 'subsonic-response': {
+ status: 'ok',
+ scanStatus: { error: 'Scan failed' },
+ },
+ },
+ }),
+ ),
+ startScan: vi.fn(),
+ },
+}))
+
+describe('
', () => {
+ let store
+
+ beforeEach(() => {
+ store = createStore(combineReducers({ activity: activityReducer }), {
+ activity: {
+ scanStatus: {
+ scanning: false,
+ folderCount: 0,
+ count: 0,
+ error: 'Scan failed',
+ elapsedTime: 0,
+ },
+ serverStart: { version: config.version, startTime: Date.now() },
+ },
+ })
+ })
+
+ it('clears the error icon after opening the panel', () => {
+ render(
+
+
+ ,
+ )
+
+ const button = screen.getByRole('button')
+ expect(screen.getByTestId('activity-error-icon')).toBeInTheDocument()
+
+ fireEvent.click(button)
+
+ expect(screen.getByTestId('activity-ok-icon')).toBeInTheDocument()
+ expect(screen.getByText('Scan failed')).toBeInTheDocument()
+ })
+})
diff --git a/ui/src/layout/Menu.jsx b/ui/src/layout/Menu.jsx
index bd1e37ee0..45f40b26d 100644
--- a/ui/src/layout/Menu.jsx
+++ b/ui/src/layout/Menu.jsx
@@ -9,6 +9,7 @@ import SubMenu from './SubMenu'
import { humanize, pluralize } from 'inflection'
import albumLists from '../album/albumLists'
import PlaylistsSubMenu from './PlaylistsSubMenu'
+import LibrarySelector from '../common/LibrarySelector'
import config from '../config'
const useStyles = makeStyles((theme) => ({
@@ -111,6 +112,7 @@ const Menu = ({ dense = false }) => {
[classes.closed]: !open,
})}
>
+ {open &&
}
handleToggle('menuAlbumList')}
isOpen={state.menuAlbumList}
diff --git a/ui/src/library/DeleteLibraryButton.jsx b/ui/src/library/DeleteLibraryButton.jsx
new file mode 100644
index 000000000..8d9ff6ed2
--- /dev/null
+++ b/ui/src/library/DeleteLibraryButton.jsx
@@ -0,0 +1,80 @@
+import React from 'react'
+import DeleteIcon from '@material-ui/icons/Delete'
+import { makeStyles, alpha } from '@material-ui/core/styles'
+import clsx from 'clsx'
+import {
+ useNotify,
+ useDeleteWithConfirmController,
+ Button,
+ Confirm,
+ useTranslate,
+ useRedirect,
+} from 'react-admin'
+
+const useStyles = makeStyles(
+ (theme) => ({
+ deleteButton: {
+ color: theme.palette.error.main,
+ '&:hover': {
+ backgroundColor: alpha(theme.palette.error.main, 0.12),
+ // Reset on mouse devices
+ '@media (hover: none)': {
+ backgroundColor: 'transparent',
+ },
+ },
+ },
+ }),
+ { name: 'RaDeleteWithConfirmButton' },
+)
+
+const DeleteLibraryButton = ({
+ record,
+ resource,
+ basePath,
+ className,
+ ...props
+}) => {
+ const translate = useTranslate()
+ const notify = useNotify()
+ const redirect = useRedirect()
+
+ const onSuccess = () => {
+ notify('resources.library.notifications.deleted', 'info', {
+ smart_count: 1,
+ })
+ redirect('/library')
+ }
+
+ const { open, loading, handleDialogOpen, handleDialogClose, handleDelete } =
+ useDeleteWithConfirmController({
+ resource,
+ record,
+ basePath,
+ onSuccess,
+ })
+
+ const classes = useStyles(props)
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
+
+export default DeleteLibraryButton
diff --git a/ui/src/library/LibraryCreate.jsx b/ui/src/library/LibraryCreate.jsx
new file mode 100644
index 000000000..0e69964b6
--- /dev/null
+++ b/ui/src/library/LibraryCreate.jsx
@@ -0,0 +1,84 @@
+import React, { useCallback } from 'react'
+import {
+ Create,
+ SimpleForm,
+ TextInput,
+ BooleanInput,
+ required,
+ useTranslate,
+ useMutation,
+ useNotify,
+ useRedirect,
+} from 'react-admin'
+import { Title } from '../common'
+
+const LibraryCreate = (props) => {
+ const translate = useTranslate()
+ const [mutate] = useMutation()
+ const notify = useNotify()
+ const redirect = useRedirect()
+ const resourceName = translate('resources.library.name', { smart_count: 1 })
+ const title = translate('ra.page.create', {
+ name: `${resourceName}`,
+ })
+
+ const save = useCallback(
+ async (values) => {
+ try {
+ await mutate(
+ {
+ type: 'create',
+ resource: 'library',
+ payload: { data: values },
+ },
+ { returnPromise: true },
+ )
+ notify('resources.library.notifications.created', 'info', {
+ smart_count: 1,
+ })
+ redirect('/library')
+ } catch (error) {
+ // Handle validation errors with proper field mapping
+ if (error.body && error.body.errors) {
+ return error.body.errors
+ }
+
+ // Handle other structured errors from the server
+ if (error.body && error.body.error) {
+ const errorMsg = error.body.error
+
+ // Handle database constraint violations
+ if (errorMsg.includes('UNIQUE constraint failed: library.name')) {
+ return { name: 'ra.validation.unique' }
+ }
+ if (errorMsg.includes('UNIQUE constraint failed: library.path')) {
+ return { path: 'ra.validation.unique' }
+ }
+
+ // Show a general notification for other server errors
+ notify(errorMsg, 'error')
+ return
+ }
+
+ // Fallback for unexpected error formats
+ const fallbackMessage =
+ error.message ||
+ (typeof error === 'string' ? error : 'An unexpected error occurred')
+ notify(fallbackMessage, 'error')
+ }
+ },
+ [mutate, notify, redirect],
+ )
+
+ return (
+ } {...props}>
+
+
+
+
+
+
+ )
+}
+
+export default LibraryCreate
diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx
new file mode 100644
index 000000000..7e89c892c
--- /dev/null
+++ b/ui/src/library/LibraryEdit.jsx
@@ -0,0 +1,273 @@
+import React, { useCallback } from 'react'
+import {
+ Edit,
+ FormWithRedirect,
+ TextInput,
+ BooleanInput,
+ required,
+ SaveButton,
+ DateField,
+ useTranslate,
+ useMutation,
+ useNotify,
+ useRedirect,
+ Toolbar,
+} from 'react-admin'
+import { Typography, Box } from '@material-ui/core'
+import { makeStyles } from '@material-ui/core/styles'
+import DeleteLibraryButton from './DeleteLibraryButton'
+import { Title } from '../common'
+import { formatBytes, formatDuration2, formatNumber } from '../utils/index.js'
+
+const useStyles = makeStyles({
+ toolbar: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ },
+})
+
+const LibraryTitle = ({ record }) => {
+ const translate = useTranslate()
+ const resourceName = translate('resources.library.name', { smart_count: 1 })
+ return (
+
+ )
+}
+
+const CustomToolbar = ({ showDelete, ...props }) => (
+
+
+ {showDelete && (
+
+ )}
+
+)
+
+const LibraryEdit = (props) => {
+ const translate = useTranslate()
+ const [mutate] = useMutation()
+ const notify = useNotify()
+ const redirect = useRedirect()
+
+ // Library ID 1 is protected (main library)
+ const canDelete = props.id !== '1'
+ const canEditPath = props.id !== '1'
+
+ const save = useCallback(
+ async (values) => {
+ try {
+ await mutate(
+ {
+ type: 'update',
+ resource: 'library',
+ payload: { id: values.id, data: values },
+ },
+ { returnPromise: true },
+ )
+ notify('resources.library.notifications.updated', 'info', {
+ smart_count: 1,
+ })
+ redirect('/library')
+ } catch (error) {
+ if (error.body && error.body.errors) {
+ return error.body.errors
+ }
+ }
+ },
+ [mutate, notify, redirect],
+ )
+
+ return (
+ } undoable={false} {...props}>
+ (
+
+ )}
+ />
+
+ )
+}
+
+export default LibraryEdit
diff --git a/ui/src/library/LibraryList.jsx b/ui/src/library/LibraryList.jsx
new file mode 100644
index 000000000..aa1294882
--- /dev/null
+++ b/ui/src/library/LibraryList.jsx
@@ -0,0 +1,56 @@
+import React from 'react'
+import {
+ Datagrid,
+ Filter,
+ SearchInput,
+ SimpleList,
+ TextField,
+ NumberField,
+ BooleanField,
+} from 'react-admin'
+import { useMediaQuery } from '@material-ui/core'
+import { List, DateField, useResourceRefresh, SizeField } from '../common'
+import LibraryListBulkActions from './LibraryListBulkActions'
+import LibraryListActions from './LibraryListActions'
+
+const LibraryFilter = (props) => (
+
+
+
+)
+
+const LibraryList = (props) => {
+ const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
+ useResourceRefresh('library')
+
+ return (
+
}
+ filters={ }
+ actions={ }
+ >
+ {isXsmall ? (
+ record.name}
+ secondaryText={(record) => record.path}
+ />
+ ) : (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
+export default LibraryList
diff --git a/ui/src/library/LibraryListActions.jsx b/ui/src/library/LibraryListActions.jsx
new file mode 100644
index 000000000..f4d0913df
--- /dev/null
+++ b/ui/src/library/LibraryListActions.jsx
@@ -0,0 +1,31 @@
+import React, { cloneElement } from 'react'
+import { sanitizeListRestProps, TopToolbar, CreateButton } from 'react-admin'
+import LibraryScanButton from './LibraryScanButton'
+
+const LibraryListActions = ({
+ className,
+ filters,
+ resource,
+ showFilter,
+ displayedFilters,
+ filterValues,
+ ...rest
+}) => {
+ return (
+
+ {filters &&
+ cloneElement(filters, {
+ resource,
+ showFilter,
+ displayedFilters,
+ filterValues,
+ context: 'button',
+ })}
+
+
+
+
+ )
+}
+
+export default LibraryListActions
diff --git a/ui/src/library/LibraryListBulkActions.jsx b/ui/src/library/LibraryListBulkActions.jsx
new file mode 100644
index 000000000..8862a4f51
--- /dev/null
+++ b/ui/src/library/LibraryListBulkActions.jsx
@@ -0,0 +1,11 @@
+import React from 'react'
+import LibraryScanButton from './LibraryScanButton'
+
+const LibraryListBulkActions = (props) => (
+ <>
+
+
+ >
+)
+
+export default LibraryListBulkActions
diff --git a/ui/src/library/LibraryScanButton.jsx b/ui/src/library/LibraryScanButton.jsx
new file mode 100644
index 000000000..50d90e615
--- /dev/null
+++ b/ui/src/library/LibraryScanButton.jsx
@@ -0,0 +1,77 @@
+import React, { useState } from 'react'
+import PropTypes from 'prop-types'
+import {
+ Button,
+ useNotify,
+ useRefresh,
+ useTranslate,
+ useUnselectAll,
+} from 'react-admin'
+import { useSelector } from 'react-redux'
+import SyncIcon from '@material-ui/icons/Sync'
+import CachedIcon from '@material-ui/icons/Cached'
+import subsonic from '../subsonic'
+
+const LibraryScanButton = ({ fullScan, selectedIds, className }) => {
+ const [loading, setLoading] = useState(false)
+ const notify = useNotify()
+ const refresh = useRefresh()
+ const translate = useTranslate()
+ const unselectAll = useUnselectAll()
+ const scanStatus = useSelector((state) => state.activity.scanStatus)
+
+ const handleClick = async () => {
+ setLoading(true)
+ try {
+ // Build scan options
+ const options = { fullScan }
+
+ // If specific libraries are selected, scan only those
+ // Format: "libraryID:" to scan entire library (no folder path specified)
+ if (selectedIds && selectedIds.length > 0) {
+ options.target = selectedIds.map((id) => `${id}:`)
+ }
+
+ await subsonic.startScan(options)
+ const notificationKey = fullScan
+ ? 'resources.library.notifications.fullScanStarted'
+ : 'resources.library.notifications.quickScanStarted'
+ notify(notificationKey, 'info')
+ refresh()
+
+ // Unselect all items after successful scan
+ unselectAll('library')
+ } catch (error) {
+ notify('resources.library.notifications.scanError', 'warning')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const isDisabled = loading || scanStatus.scanning
+
+ const label = fullScan
+ ? translate('resources.library.actions.fullScan')
+ : translate('resources.library.actions.quickScan')
+
+ const icon = fullScan ? :
+
+ return (
+
+ {icon}
+
+ )
+}
+
+LibraryScanButton.propTypes = {
+ fullScan: PropTypes.bool.isRequired,
+ selectedIds: PropTypes.array,
+ className: PropTypes.string,
+}
+
+export default LibraryScanButton
diff --git a/ui/src/library/index.js b/ui/src/library/index.js
new file mode 100644
index 000000000..3a8b71b52
--- /dev/null
+++ b/ui/src/library/index.js
@@ -0,0 +1,11 @@
+import { MdLibraryMusic } from 'react-icons/md'
+import LibraryList from './LibraryList'
+import LibraryEdit from './LibraryEdit'
+import LibraryCreate from './LibraryCreate'
+
+export default {
+ icon: MdLibraryMusic,
+ list: LibraryList,
+ edit: LibraryEdit,
+ create: LibraryCreate,
+}
diff --git a/ui/src/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx
index 74711eed0..87d9f629f 100644
--- a/ui/src/missing/MissingFilesList.jsx
+++ b/ui/src/missing/MissingFilesList.jsx
@@ -5,10 +5,15 @@ import {
TextField,
downloadCSV,
Pagination,
+ Filter,
+ ReferenceInput,
+ useTranslate,
+ SelectInput,
} from 'react-admin'
import jsonExport from 'jsonexport/dist'
import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx'
import MissingListActions from './MissingListActions.jsx'
+import React from 'react'
const exporter = (files) => {
const filesToExport = files.map((file) => {
@@ -20,6 +25,24 @@ const exporter = (files) => {
})
}
+const MissingFilesFilter = (props) => {
+ const translate = useTranslate()
+ return (
+
+ ({ name: [searchText] })}
+ alwaysOn
+ >
+
+
+
+ )
+}
+
const BulkActionButtons = (props) => (
<>
@@ -38,11 +61,13 @@ const MissingFilesList = (props) => {
sort={{ field: 'updated_at', order: 'DESC' }}
exporter={exporter}
actions={ }
+ filters={ }
bulkActionButtons={ }
perPage={50}
pagination={ }
>
+
diff --git a/ui/src/playlist/PlaylistSongs.jsx b/ui/src/playlist/PlaylistSongs.jsx
index 4292562ab..bbe38b4d5 100644
--- a/ui/src/playlist/PlaylistSongs.jsx
+++ b/ui/src/playlist/PlaylistSongs.jsx
@@ -169,6 +169,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
quality: isDesktop && ,
channels: isDesktop && ,
bpm: isDesktop && ,
+ genre: ,
rating: config.enableStarRating && (
{
'playCount',
'playDate',
'albumArtist',
+ 'genre',
'rating',
],
})
diff --git a/ui/src/reducers/index.js b/ui/src/reducers/index.js
index b9414c864..3db0b1dff 100644
--- a/ui/src/reducers/index.js
+++ b/ui/src/reducers/index.js
@@ -1,3 +1,4 @@
+export * from './libraryReducer'
export * from './themeReducer'
export * from './dialogReducer'
export * from './playerReducer'
diff --git a/ui/src/reducers/libraryReducer.js b/ui/src/reducers/libraryReducer.js
new file mode 100644
index 000000000..ef613260f
--- /dev/null
+++ b/ui/src/reducers/libraryReducer.js
@@ -0,0 +1,52 @@
+import { SET_SELECTED_LIBRARIES, SET_USER_LIBRARIES } from '../actions'
+
+const initialState = {
+ userLibraries: [],
+ selectedLibraries: [], // Empty means "all accessible libraries"
+}
+
+export const libraryReducer = (previousState = initialState, payload) => {
+ const { type, data } = payload
+ switch (type) {
+ case SET_USER_LIBRARIES: {
+ const newUserLibraryIds = data.map((lib) => lib.id)
+
+ // Validate and filter selected libraries to only include IDs that exist in new user libraries
+ const validatedSelection = previousState.selectedLibraries.filter((id) =>
+ newUserLibraryIds.includes(id),
+ )
+
+ // Determine the final selection:
+ // 1. If first time setting libraries (no previous user libraries), select all
+ // 2. If user now has only one library, reset to empty (no filter needed)
+ // 3. Otherwise, use validated selection (may be empty if all previous selections were invalid)
+ let finalSelection
+ if (
+ previousState.selectedLibraries.length === 0 &&
+ previousState.userLibraries.length === 0
+ ) {
+ // First time: select all libraries
+ finalSelection = newUserLibraryIds
+ } else if (newUserLibraryIds.length === 1) {
+ // Single library: reset selection (empty means "all accessible")
+ finalSelection = []
+ } else {
+ // Multiple libraries: use validated selection
+ finalSelection = validatedSelection
+ }
+
+ return {
+ ...previousState,
+ userLibraries: data,
+ selectedLibraries: finalSelection,
+ }
+ }
+ case SET_SELECTED_LIBRARIES:
+ return {
+ ...previousState,
+ selectedLibraries: data,
+ }
+ default:
+ return previousState
+ }
+}
diff --git a/ui/src/reducers/libraryReducer.test.js b/ui/src/reducers/libraryReducer.test.js
new file mode 100644
index 000000000..b962c1036
--- /dev/null
+++ b/ui/src/reducers/libraryReducer.test.js
@@ -0,0 +1,186 @@
+import { describe, it, expect } from 'vitest'
+import { libraryReducer } from './libraryReducer'
+import { SET_SELECTED_LIBRARIES, SET_USER_LIBRARIES } from '../actions'
+
+describe('libraryReducer', () => {
+ const mockLibraries = [
+ { id: '1', name: 'Music Library' },
+ { id: '2', name: 'Podcasts' },
+ { id: '3', name: 'Audiobooks' },
+ ]
+
+ const initialState = {
+ userLibraries: [],
+ selectedLibraries: [],
+ }
+
+ describe('SET_USER_LIBRARIES', () => {
+ it('should set user libraries and select all on first load', () => {
+ const action = {
+ type: SET_USER_LIBRARIES,
+ data: mockLibraries,
+ }
+
+ const result = libraryReducer(initialState, action)
+
+ expect(result.userLibraries).toEqual(mockLibraries)
+ expect(result.selectedLibraries).toEqual(['1', '2', '3'])
+ })
+
+ it('should reset selection to empty when user has only one library', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2'],
+ }
+
+ const action = {
+ type: SET_USER_LIBRARIES,
+ data: [mockLibraries[0]], // Only one library now
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.userLibraries).toEqual([mockLibraries[0]])
+ expect(result.selectedLibraries).toEqual([]) // Reset for single library
+ })
+
+ it('should filter out invalid library IDs from selection', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2', '3'],
+ }
+
+ const action = {
+ type: SET_USER_LIBRARIES,
+ data: [mockLibraries[0], mockLibraries[1]], // Only libraries 1 and 2 remain
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.userLibraries).toEqual([mockLibraries[0], mockLibraries[1]])
+ expect(result.selectedLibraries).toEqual(['1', '2']) // Library 3 removed
+ })
+
+ it('should keep valid selection when libraries change', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1'],
+ }
+
+ const action = {
+ type: SET_USER_LIBRARIES,
+ data: mockLibraries, // Same libraries
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.userLibraries).toEqual(mockLibraries)
+ expect(result.selectedLibraries).toEqual(['1']) // Selection preserved
+ })
+
+ it('should handle selection becoming empty after filtering invalid IDs', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2'],
+ }
+
+ const newLibraries = [{ id: '4', name: 'New Library' }]
+ const action = {
+ type: SET_USER_LIBRARIES,
+ data: newLibraries,
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.userLibraries).toEqual(newLibraries)
+ expect(result.selectedLibraries).toEqual([]) // All selected IDs were invalid
+ })
+
+ it('should handle transition from multiple to single library with invalid selection', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['2', '3'], // User had libraries 2 and 3 selected
+ }
+
+ const action = {
+ type: SET_USER_LIBRARIES,
+ data: [mockLibraries[0]], // Now only has access to library 1
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.userLibraries).toEqual([mockLibraries[0]])
+ expect(result.selectedLibraries).toEqual([]) // Reset for single library
+ })
+
+ it('should handle empty library list', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2'],
+ }
+
+ const action = {
+ type: SET_USER_LIBRARIES,
+ data: [],
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.userLibraries).toEqual([])
+ expect(result.selectedLibraries).toEqual([]) // All selections filtered out
+ })
+ })
+
+ describe('SET_SELECTED_LIBRARIES', () => {
+ it('should update selected libraries', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1'],
+ }
+
+ const action = {
+ type: SET_SELECTED_LIBRARIES,
+ data: ['2', '3'],
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.selectedLibraries).toEqual(['2', '3'])
+ expect(result.userLibraries).toEqual(mockLibraries) // Unchanged
+ })
+
+ it('should allow setting empty selection', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2'],
+ }
+
+ const action = {
+ type: SET_SELECTED_LIBRARIES,
+ data: [],
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.selectedLibraries).toEqual([])
+ })
+ })
+
+ describe('unknown action', () => {
+ it('should return previous state for unknown action', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1'],
+ }
+
+ const action = {
+ type: 'UNKNOWN_ACTION',
+ data: null,
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result).toBe(previousState) // Same reference
+ })
+ })
+})
diff --git a/ui/src/share/SharePlayer.jsx b/ui/src/share/SharePlayer.jsx
index 2c50275ed..a3a15e50a 100644
--- a/ui/src/share/SharePlayer.jsx
+++ b/ui/src/share/SharePlayer.jsx
@@ -53,6 +53,7 @@ const SharePlayer = () => {
remove: false,
spaceBar: true,
volumeFade: { fadeIn: 200, fadeOut: 200 },
+ sortableOptions: { delay: 200, delayOnTouchOnly: true },
}
return (
{
),
comment: ,
path: ,
- createdAt: ,
+ createdAt: (
+
+ ),
}
}, [isDesktop, classes.ratingField])
diff --git a/ui/src/store/createAdminStore.js b/ui/src/store/createAdminStore.js
index e4877eb14..4888e49e4 100644
--- a/ui/src/store/createAdminStore.js
+++ b/ui/src/store/createAdminStore.js
@@ -57,6 +57,7 @@ const createAdminStore = ({
const state = store.getState()
saveState({
theme: state.theme,
+ library: state.library,
player: (({ queue, volume, savedPlayIndex }) => ({
queue,
volume,
diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js
index ad7a391e0..cfcc01043 100644
--- a/ui/src/subsonic/index.js
+++ b/ui/src/subsonic/index.js
@@ -23,7 +23,13 @@ const url = (command, id, options) => {
delete options.ts
}
Object.keys(options).forEach((k) => {
- params.append(k, options[k])
+ const value = options[k]
+ // Handle array parameters by appending each value separately
+ if (Array.isArray(value)) {
+ value.forEach((v) => params.append(k, v))
+ } else {
+ params.append(k, value)
+ }
})
}
return `/rest/${command}?${params.toString()}`
diff --git a/ui/src/themes/SquiddiesGlass.css.js b/ui/src/themes/SquiddiesGlass.css.js
new file mode 100644
index 000000000..2c8e4f1d6
--- /dev/null
+++ b/ui/src/themes/SquiddiesGlass.css.js
@@ -0,0 +1,175 @@
+const stylesheet = `
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
+ background: #c231ab
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track,
+.react-jinke-music-player-mobile-progress .rc-slider-track {
+ background: linear-gradient(to left, #c231ab, #380eff)
+}
+
+.react-jinke-music-player-mobile {
+ background-color: #171717 !important;
+}
+
+.react-jinke-music-player-mobile-progress .rc-slider-handle {
+ background: #c231ab;
+ height: 20px;
+ width: 20px;
+ margin-top: -9px;
+}
+
+.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
+ background-color: #c231ab;
+}
+
+.react-jinke-music-player-pause-icon {
+ background-color: #c231ab;
+ border-radius: 50%;
+ outline: auto;
+ color: white;
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .player-content {
+ z-index: 99999;
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-btn svg {
+ border-radius: 50%;
+ outline: auto;
+ color: white;
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-btn svg:hover {
+ background-color: #c231ab;
+ border-radius: 50%;
+ outline: auto;
+ color: white;
+}
+
+.react-jinke-music-player-main svg:hover {
+ color: #c231ab;
+}
+
+.react-jinke-music-player .music-player-controller {
+ color: #c231ab;
+ border: 1px solid #e14ac2;
+}
+
+.react-jinke-music-player .music-player-controller.music-player-playing:before {
+ border: 1px solid rgba(194, 49, 171, 0.3);
+}
+
+.react-jinke-music-player .music-player .destroy-btn {
+ background-color: #c2c1c2;
+ top: -7px;
+ border-radius: 50%;
+ display: flex;
+}
+
+.react-jinke-music-player .music-player .destroy-btn svg {
+ font-size: 20px;
+}
+
+@media screen and (max-width: 767px) {
+ .react-jinke-music-player .music-player .destroy-btn {
+ right: -12px;
+ }
+}
+
+.react-jinke-music-player-mobile-header-right {
+ right: 0;
+ top: 0;
+}
+
+@media screen and (max-width: 767px) {
+ .react-jinke-music-player-main svg {
+ font-size: 32px;
+ }
+}
+
+@keyframes gradientFlow {
+ 0% { background-position: 0% 50%; }
+ 50% { background-position: 100% 50%; }
+ 100% { background-position: 0% 50%; }
+}
+
+.RaBulkActionsToolbar .MuiButton-label {
+ color: white;
+}
+
+a[aria-current="page"] {
+ color: #c231ab !important;
+ font-weight: bold;
+}
+
+a[aria-current="page"] .MuiListItemIcon-root {
+ color: #c231ab !important;
+}
+
+.panel-content {
+ position: relative;
+ overflow: hidden;
+ background: linear-gradient(90deg, #311f2f, #0a0912, #2f0c28);
+ background-size: 300% 300%;
+ animation: gradientFlow 10s ease-in-out infinite;
+}
+
+/* Equalizer bars */
+.panel-content::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: repeating-linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0.05) 0px,
+ rgba(255, 255, 255, 0.05) 2px,
+ transparent 1px,
+ transparent 3px
+ );
+ animation: equalizer 1.8s infinite ease-in-out;
+ filter: blur(1px);
+ opacity: 0.5;
+}
+
+@keyframes backgroundFlow {
+ 0% {
+ background-position: 0% 50%;
+ }
+ 50% {
+ background-position: 100% 50%;
+ }
+ 100% {
+ background-position: 0% 50%;
+ }
+}
+
+/* Vertical movement, equalizer type */
+@keyframes equalizer {
+ 0%, 100% {
+ transform: scaleY(1);
+ opacity: 0.2;
+ }
+ 25% {
+ transform: scaleY(1.4);
+ opacity: 0.9;
+ }
+ 50% {
+ transform: scaleY(0.7);
+ opacity: 0.2;
+ }
+ 75% {
+ transform: scaleY(1.2);
+ opacity: 0.8;
+ }
+}
+
+@keyframes pulse {
+ 0% { opacity: 0.5; }
+ 100% { opacity: 1; }
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+`
+
+export default stylesheet
diff --git a/ui/src/themes/SquiddiesGlass.js b/ui/src/themes/SquiddiesGlass.js
new file mode 100644
index 000000000..5c3844074
--- /dev/null
+++ b/ui/src/themes/SquiddiesGlass.js
@@ -0,0 +1,608 @@
+import stylesheet from './SquiddiesGlass.css.js'
+
+/**
+ * Color constants used throughout the Squiddies Glass theme.
+ * Provides a consistent color palette with pink, gray, purple, and basic colors.
+ * @type {Object}
+ */
+const colors = {
+ pink: {
+ 100: '#fbe3f4',
+ 200: '#f5b9e3',
+ 300: '#ec7cd6',
+ 400: '#e14ac2',
+ 500: '#c231ab', // base
+ 600: '#a31a92',
+ 700: '#8b0f7e',
+ 800: '#7a006d',
+ 900: '#670066',
+ },
+ gray: {
+ 50: '#c2c1c2',
+ 100: '#b3b3b3', // light gray
+ 200: '#282828', // medium dark
+ 300: '#1d1d1d', // darker
+ 400: '#181818', // even darker
+ 500: '#171717', // darkest
+ },
+ purple: {
+ 400: '#524590',
+ 500: '#4d3249',
+ 600: '#6d1c5e',
+ },
+ black: '#000',
+ white: '#fff',
+ dark: '#121212',
+}
+
+/**
+ * Shared style object for music list action buttons.
+ * Defines common styling for buttons in music lists, including hover effects and responsive scaling.
+ * @type {Object}
+ */
+const musicListActions = {
+ padding: '1rem 0',
+ alignItems: 'center',
+ '@global': {
+ button: {
+ border: '1px solid transparent',
+ backgroundColor: 'inherit',
+ color: colors.gray[100],
+ '&:hover': {
+ border: `1px solid ${colors.gray[100]}`,
+ backgroundColor: 'inherit !important',
+ },
+ },
+ 'button:first-child:not(:only-child)': {
+ '@media screen and (max-width: 720px)': {
+ transform: 'scale(1.3)',
+ margin: '1em',
+ '&:hover': {
+ transform: 'scale(1.2) !important',
+ },
+ },
+ transform: 'scale(1.3)',
+ margin: '1em',
+ minWidth: 0,
+ padding: 5,
+ transition: 'transform .3s ease',
+ background: colors.pink[500],
+ color: `${colors.black} !important`,
+ borderRadius: 500,
+ border: 0,
+ '&:hover': {
+ transform: 'scale(1.2)',
+ backgroundColor: `${colors.pink[500]} !important`,
+ border: 0,
+ },
+ },
+ 'button:only-child': {
+ marginTop: '0.3em',
+ },
+ 'button:first-child>span:first-child': {
+ padding: 0,
+ color: `${colors.black} !important`,
+ },
+ 'button:first-child>span:first-child>span': {
+ display: 'none',
+ },
+ 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
+ {
+ color: colors.gray[100],
+ },
+ },
+}
+
+/**
+ * Squiddies Glass theme configuration object.
+ * Defines the complete theme structure including typography, palette, component overrides, and player settings.
+ * @type {Object}
+ */
+export default {
+ /**
+ * The name of the theme.
+ * @type {string}
+ */
+ themeName: 'Squiddies Glass',
+
+ /**
+ * Typography settings for the theme.
+ * Specifies font family and heading sizes.
+ * @type {Object}
+ */
+ typography: {
+ fontFamily: "system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif",
+ h6: {
+ fontSize: '1rem', // AppBar title
+ },
+ },
+
+ /**
+ * Color palette configuration.
+ * Defines primary, secondary, and background colors for the theme.
+ * @type {Object}
+ */
+ palette: {
+ primary: {
+ light: colors.pink[300],
+ main: colors.pink[500],
+ },
+ secondary: {
+ main: colors.white,
+ contrastText: colors.white,
+ },
+ background: {
+ default: colors.dark,
+ paper: colors.dark,
+ },
+ type: 'dark',
+ },
+
+ /**
+ * Component overrides for Material-UI and custom Navidrome components.
+ * Customizes the appearance and behavior of various UI components.
+ * @type {Object}
+ */
+ overrides: {
+ // Material-UI Components
+ MuiAppBar: {
+ positionFixed: {
+ backgroundColor: `${colors.black} !important`,
+ boxShadow: 'none',
+ },
+ },
+ MuiButton: {
+ root: {
+ background: colors.pink[500],
+ color: colors.white,
+ border: '1px solid transparent',
+ borderRadius: 500,
+ '&:hover': {
+ background: `${colors.pink[900]} !important`,
+ },
+ },
+ textSecondary: {
+ border: `1px solid ${colors.gray[100]}`,
+ background: colors.black,
+ '&:hover': {
+ border: `1px solid ${colors.white} !important`,
+ background: `${colors.black} !important`,
+ },
+ },
+ label: {
+ color: colors.white,
+ paddingRight: '1rem',
+ paddingLeft: '0.7rem',
+ },
+ },
+ MuiCardMedia: {
+ root: {
+ position: 'relative',
+ overflow: 'hidden',
+ boxShadow: `0 2px 32px rgba(0,0,0,0.5), 0px 1px 5px rgba(0,0,0,0.1)`,
+ },
+ },
+ MuiDivider: {
+ root: {
+ margin: '.75rem 0',
+ },
+ },
+ MuiDrawer: {
+ root: {
+ background: colors.gray[500],
+ paddingTop: '10px',
+ },
+ },
+ MuiFormGroup: {
+ root: {
+ color: colors.pink[500],
+ },
+ },
+ MuiMenuItem: {
+ root: {
+ fontSize: '0.875rem',
+ },
+ },
+ MuiTableCell: {
+ root: {
+ borderBottom: `1px solid ${colors.gray[300]}`,
+ padding: '10px !important',
+ color: `${colors.gray[100]} !important`,
+ '& img': {
+ filter:
+ 'brightness(0) saturate(100%) invert(36%) sepia(93%) saturate(7463%) hue-rotate(289deg) brightness(95%) contrast(102%);',
+ },
+ '& img + span': {
+ color: colors.pink[500],
+ },
+ },
+ head: {
+ borderBottom: `1px solid ${colors.gray[200]}`,
+ fontSize: '0.75rem',
+ textTransform: 'uppercase',
+ letterSpacing: 1.2,
+ },
+ },
+ MuiTableRow: {
+ root: {
+ padding: '10px 0',
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: `${colors.gray[300]} !important`,
+ },
+ '@global': {
+ 'td:nth-child(4)': {
+ color: `${colors.white} !important`,
+ },
+ },
+ },
+ },
+
+ // React Admin Components
+ RaBulkActionsToolbar: {
+ topToolbar: {
+ gap: '8px',
+ },
+ },
+ RaFilter: {
+ form: {
+ '& .MuiOutlinedInput-input:-webkit-autofill': {
+ '-webkit-box-shadow': `0 0 0 100px ${colors.gray[50]} inset`,
+ '-webkit-text-fill-color': colors.white,
+ },
+ },
+ },
+ RaFilterButton: {
+ root: {
+ marginRight: '1rem',
+ },
+ },
+ RaLayout: {
+ content: {
+ padding: '0 !important',
+ background: `linear-gradient(${colors.dark}, ${colors.gray[500]})`,
+ borderTopRightRadius: '8px',
+ borderTopLeftRadius: '8px',
+ },
+ contentWithSidebar: {
+ gap: '2px',
+ },
+ },
+ RaList: {
+ content: {
+ backgroundColor: 'inherit',
+ },
+ bulkActionsDisplayed: {
+ marginTop: '-20px',
+ },
+ },
+ RaListToolbar: {
+ toolbar: {
+ padding: '0 .55rem !important',
+ },
+ },
+ RaPaginationActions: {
+ currentPageButton: {
+ border: `1px solid ${colors.gray[100]}`,
+ },
+ button: {
+ backgroundColor: 'inherit',
+ minWidth: 48,
+ margin: '0 4px',
+ border: `1px solid ${colors.gray[200]}`,
+ '@global': {
+ '> .MuiButton-label': {
+ padding: 0,
+ },
+ },
+ },
+ actions: {
+ '@global': {
+ '.next-page': {
+ marginLeft: 8,
+ marginRight: 8,
+ },
+ '.previous-page': {
+ marginRight: 8,
+ },
+ },
+ },
+ },
+ RaSearchInput: {
+ input: {
+ paddingLeft: '.9rem',
+ border: 0,
+ '& .MuiInputBase-root': {
+ backgroundColor: `${colors.white} !important`,
+ borderRadius: '20px !important',
+ color: colors.black,
+ border: '0px',
+ '& fieldset': {
+ borderColor: colors.white,
+ },
+ '&:hover fieldset': {
+ borderColor: colors.white,
+ },
+ '&.Mui-focused fieldset': {
+ borderColor: colors.white,
+ },
+ '& svg': {
+ color: `${colors.black} !important`,
+ },
+ '& .MuiOutlinedInput-input:-webkit-autofill': {
+ borderRadius: '20px 0px 0px 20px',
+ '-webkit-box-shadow': `0 0 0 100px ${colors.gray[50]} inset`,
+ '-webkit-text-fill-color': colors.black,
+ },
+ },
+ },
+ },
+ RaSidebar: {
+ root: {
+ height: 'initial',
+ borderTopRightRadius: '8px',
+ borderTopLeftRadius: '8px',
+ },
+ },
+
+ // Navidrome Custom Components
+ NDAlbumDetails: {
+ root: {
+ boxShadow: 'none',
+ background: `linear-gradient(45deg, ${colors.purple[500]}, ${colors.purple[400]}, ${colors.purple[600]})`,
+ backgroundSize: '200% 200%',
+ animation: 'gradientFlow 8s ease-in-out infinite',
+ position: 'relative',
+ '&:before': {
+ content: '""',
+ position: 'absolute',
+ top: '0',
+ left: '0',
+ width: '100%',
+ height: '100%',
+ background: `linear-gradient(to bottom, transparent, ${colors.dark})`,
+ },
+ },
+ cardContents: {
+ alignItems: 'flex-start',
+ },
+ coverParent: {
+ zIndex: '99999',
+ position: 'relative',
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ '&::before': {
+ content: '""',
+ position: 'absolute',
+ inset: '0',
+ width: '100%',
+ height: '100%',
+ borderRadius: '50%',
+ animation: 'pulse 1.5s ease-in-out infinite alternate',
+ zIndex: -1,
+ },
+ '&::after': {
+ content: '""',
+ position: 'absolute',
+ inset: '0',
+ zIndex: '-1',
+ borderRadius: '50%',
+ background:
+ 'repeating-conic-gradient(from 0deg, rgba(255,255,255,0.08) 0deg, rgba(255,255,255,0.08) 0.5deg, rgba(0,0,0,1) 1deg)',
+ filter: 'contrast(999) sepia(1)',
+ boxShadow:
+ 'inset 0 0 25px rgba(255,255,255,0.05), inset 0 0 95px rgba(0,0,0,0.9)',
+ animation: 'spin 6s linear infinite',
+ },
+ },
+ details: {
+ zIndex: '99999',
+ },
+ recordName: {
+ fontSize: 'calc(1rem + 1.5vw)',
+ fontWeight: 900,
+ },
+ recordArtist: {
+ fontSize: '1.5rem',
+ fontWeight: 700,
+ textShadow: '0 2px 16px rgba(0, 0, 0, 0.3)',
+ },
+ recordMeta: {
+ fontSize: '.875rem',
+ color: `rgba(${colors.white}, 0.8)`,
+ },
+ content: {
+ paddingBottom: '0px !important',
+ paddingTop: '0px',
+ },
+ },
+ RaSingleFieldList: {
+ root: {
+ '& a:first-of-type > .MuiChip-root': {
+ marginLeft: '0px',
+ },
+ '& a > .MuiChip-root': {
+ backgroundColor: colors.pink[500],
+ fontSize: '0.6rem',
+ height: '20px',
+ '& .MuiChip-label': {
+ color: colors.white,
+ paddingLeft: '5px',
+ paddingRight: '5px',
+ },
+ },
+ },
+ },
+ MuiGridListTile: {
+ tile: {
+ '&:hover': {
+ boxShadow: '0 2px 32px rgba(0,0,0,0.5), 0px 1px 5px rgba(0,0,0,0.1)',
+ },
+ },
+ },
+ NDAlbumGridView: {
+ tileBar: {
+ background:
+ 'linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.4) 50%, rgba(0, 0, 0, 0) 100%)',
+ marginBottom: '2px',
+ },
+ albumName: {
+ marginTop: '0.5rem',
+ fontWeight: 700,
+ textTransform: 'none',
+ color: colors.white,
+ },
+ albumSubtitle: {
+ color: colors.gray[100],
+ },
+ albumContainer: {
+ backgroundColor: colors.gray[400],
+ borderRadius: '.5rem',
+ padding: '.75rem',
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: colors.gray[200],
+ },
+ },
+ albumPlayButton: {
+ color: colors.black,
+ backgroundColor: colors.pink[500],
+ borderRadius: '50%',
+ boxShadow: '0 8px 8px rgb(0 0 0 / 30%)',
+ padding: '0.35rem',
+ transition: 'padding .3s ease',
+ '&:hover': {
+ background: `${colors.pink[500]} !important`,
+ padding: '0.45rem',
+ },
+ },
+ },
+ NDAlbumShow: {
+ albumActions: musicListActions,
+ },
+ NDArtistShow: {
+ actions: {
+ padding: '2rem 0',
+ alignItems: 'center',
+ overflow: 'visible',
+ minHeight: '120px',
+ '@global': {
+ button: {
+ border: '1px solid transparent',
+ backgroundColor: 'inherit',
+ color: colors.gray[100],
+ margin: '0 0.5rem',
+ '&:hover': {
+ border: `1px solid ${colors.gray[100]}`,
+ backgroundColor: 'inherit !important',
+ },
+ },
+ // Hide shuffle button label (first button)
+ 'button:first-child>span:first-child>span': {
+ display: 'none',
+ },
+ // Style shuffle button (first button)
+ 'button:first-child': {
+ '@media screen and (max-width: 720px)': {
+ transform: 'scale(1.5)',
+ margin: '1rem',
+ '&:hover': {
+ transform: 'scale(1.6) !important',
+ },
+ },
+ transform: 'scale(2)',
+ margin: '1.5rem',
+ minWidth: 0,
+ padding: 5,
+ transition: 'transform .3s ease',
+ background: colors.pink[500],
+ color: colors.white,
+ borderRadius: 500,
+ border: 0,
+ '&:hover': {
+ transform: 'scale(2.1)',
+ backgroundColor: `${colors.pink[500]} !important`,
+ border: 0,
+ },
+ },
+ 'button:first-child>span:first-child': {
+ padding: 0,
+ color: `${colors.black} !important`,
+ },
+ 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
+ {
+ color: colors.gray[100],
+ },
+ },
+ },
+ actionsContainer: {
+ overflow: 'visible',
+ },
+ },
+ NDAudioPlayer: {
+ audioTitle: {
+ color: colors.white,
+ fontSize: '1.5rem',
+ '& span:nth-child(3)': {
+ fontSize: '0.8rem',
+ },
+ },
+ songTitle: {
+ fontWeight: 900,
+ },
+ songInfo: {
+ fontSize: '0.9rem',
+ color: colors.gray[100],
+ },
+ },
+ NDCollapsibleComment: {
+ commentBlock: {
+ fontSize: '.875rem',
+ color: `rgba(${colors.white}, 0.8)`,
+ },
+ },
+ NDLogin: {
+ main: {
+ boxShadow: `inset 0 0 0 2000px rgba(${colors.black}, .75)`,
+ },
+ systemNameLink: {
+ color: colors.white,
+ },
+ card: {
+ border: `1px solid ${colors.gray[200]}`,
+ },
+ avatar: {
+ marginBottom: 0,
+ },
+ },
+ NDPlaylistDetails: {
+ container: {
+ background: `linear-gradient(${colors.gray[300]}, transparent)`,
+ borderRadius: 0,
+ paddingTop: '2.5rem !important',
+ boxShadow: 'none',
+ },
+ title: {
+ fontSize: 'calc(1.5rem + 1.5vw)',
+ fontWeight: 700,
+ color: colors.white,
+ },
+ details: {
+ fontSize: '.875rem',
+ color: `rgba(${colors.white}, 0.8)`,
+ },
+ },
+ NDPlaylistShow: {
+ playlistActions: musicListActions,
+ },
+ },
+
+ /**
+ * Player configuration settings.
+ * Specifies the player theme and associated stylesheet.
+ * @type {Object}
+ */
+ player: {
+ theme: 'dark',
+ stylesheet,
+ },
+}
diff --git a/ui/src/themes/amusic.css.js b/ui/src/themes/amusic.css.js
new file mode 100644
index 000000000..9430a6c00
--- /dev/null
+++ b/ui/src/themes/amusic.css.js
@@ -0,0 +1,87 @@
+const stylesheet = `
+.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
+ color: #D60017
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle,
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track {
+ background-color: #ff4e6b
+}
+.react-jinke-music-player-main ::-webkit-scrollbar-thumb,
+.react-jinke-music-player-mobile-progress .rc-slider-handle,
+.react-jinke-music-player-mobile-progress .rc-slider-track {
+ background-color: #ff4e6b
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
+ box-shadow: 0 0 2px #ff4e6b
+}
+.audio-lists-panel-content .audio-item.playing,
+.react-jinke-music-player-main .audio-item.playing svg,
+.react-jinke-music-player-main .group player-delete {
+ color: #ff4e6b
+}
+.audio-lists-panel-content .audio-item:hover,
+.audio-lists-panel-content .audio-item:hover svg
+.audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg{
+ color: #D60017
+}
+.react-jinke-music-player-main .audio-item.playing .player-singer {
+ color: #ff4e6b !important
+}
+.react-jinke-music-player-main .lyric-btn,
+.react-jinke-music-player-main .lyric-btn-active svg{
+ color: #ff4e6b !important
+}
+.react-jinke-music-player-main .lyric-btn-active {
+ color: #D60017 !important
+}
+.react-jinke-music-player-main .loading svg {
+ color: #ff4e6b !important
+}
+.react-jinke-music-player .music-player-controller .music-player-controller-setting{
+ background: #ff4e6b4d
+}
+.react-jinke-music-player-main .music-player-lyric{
+ color: #ff4e6b !important;
+ text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000
+}
+.react-jinke-music-player-main .music-player-panel,
+.react-jinke-music-player-mobile,
+.ril__outer{
+ background-color: #1a1a1a;
+ border: 1px solid #fff1;
+}
+.ril__toolbarItem{
+ font-size: 100%;
+ color: #eee
+}
+.audio-lists-panel,
+.ril__toolbar{
+ background-color: #1f1f1f;
+ border: 1px solid #fff1;
+ border-radius: 6px 6px 0 0;
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .img-rotate,
+.react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover img.cover,
+.react-jinke-music-player-mobile-cover {
+ border-radius: 6px !important;
+ animation-duration: 0s !important
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .img-content{
+ width: 60px;
+ height: 60px
+}
+.react-jinke-music-player-main .songTitle{
+ color: #eee
+}
+.react-jinke-music-player .music-player-controller{
+ color: #ff4e6b
+}
+.audio-lists-panel-mobile .audio-item:not(.audio-lists-panel-sortable-highlight-bg){
+ background: unset
+}
+.lastfm-icon,
+.musicbrainz-icon{
+ color: #eee
+}
+`
+export default stylesheet
diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js
new file mode 100644
index 000000000..4181d1780
--- /dev/null
+++ b/ui/src/themes/amusic.js
@@ -0,0 +1,224 @@
+import stylesheet from './amusic.css.js'
+
+export default {
+ themeName: 'AMusic',
+ typography: {
+ fontFamily:
+ '-apple-system, BlinkMacSystemFont, Apple Color Emoji, SF Pro, SF Pro Icons, Helvetica Neue, Helvetica, Arial, sans-serif',
+ h6: {
+ fontSize: '1rem', // AppBar title
+ },
+ h5: {
+ fontSize: '2em',
+ fontWeight: '600',
+ },
+ },
+ palette: {
+ primary: {
+ main: '#ff4e6b',
+ },
+ secondary: {
+ main: '#D60017',
+ contrastText: '#eee',
+ },
+ background: {
+ default: '#1a1a1a',
+ paper: '#1a1a1a',
+ },
+ type: 'dark',
+ },
+ overrides: {
+ MuiFormGroup: {
+ root: {
+ color: 'white',
+ },
+ },
+ MuiAppBar: {
+ positionFixed: {
+ backgroundColor: '#1d1d1d !important',
+ boxShadow: 'none',
+ borderBottom: '1px solid #fff1',
+ },
+ colorSecondary: {
+ color: '#eee',
+ },
+ },
+ MuiDrawer: {
+ root: {
+ background: '#1d1d1d',
+ borderRight: '1px solid #fff1',
+ },
+ },
+ MuiToolbar: {
+ root: {
+ background: 'transparent !important',
+ },
+ },
+ MuiCardMedia: {
+ img: {
+ borderRadius: '10px',
+ boxShadow: '5px 5px 20px #111',
+ },
+ },
+ MuiButton: {
+ root: {
+ background: '#D60017',
+ color: '#fff',
+ borderRadius: '6px',
+ paddingRight: '0.5rem',
+ paddingLeft: '0.5rem',
+ marginLeft: '0.5rem',
+ marginBottom: '0.5rem',
+ textTransform: 'capitalize',
+ fontWeight: 600,
+ },
+ textPrimary: {
+ color: '#eee',
+ },
+ textSecondary: {
+ color: '#eee',
+ backgroundColor: '#ff4e6b',
+ },
+ textSizeSmall: {
+ fontSize: '0.8rem',
+ paddingRight: '0.5rem',
+ paddingLeft: '0.5rem',
+ },
+ label: {
+ paddingRight: '1rem',
+ paddingLeft: '0.7rem',
+ },
+ },
+ MuiListItemIcon: {
+ root: {
+ color: '#ff4e6b',
+ },
+ },
+ MuiChip: {
+ root: {
+ borderRadius: '6px',
+ },
+ },
+ MuiIconButton: {
+ root: {
+ color: '#ff4e6b',
+ },
+ },
+ MuiTableBody: {
+ root: {
+ '&>tr:nth-child(odd)': {
+ background: 'rgba(255, 255, 255, 0.025)',
+ },
+ },
+ },
+ MuiTableRow: {
+ root: {
+ background: 'transparent',
+ },
+ },
+ MuiTableCell: {
+ root: {
+ borderBottom: '0 none !important',
+ padding: '10px !important',
+ color: '#b3b3b3 !important',
+ },
+ head: {
+ color: '#b3b3b3 !important',
+ },
+ },
+ MuiMenuItem: {
+ root: {
+ fontSize: '0.875rem',
+ borderRadius: '10px',
+ color: '#eee',
+ },
+ },
+ NDAlbumGridView: {
+ albumName: {
+ color: '#eee',
+ },
+ albumPlayButton: {
+ color: '#ff4e6b',
+ },
+ albumArtistName: {
+ color: '#ccc',
+ },
+ cover: {
+ borderRadius: '6px',
+ },
+ },
+ NDLogin: {
+ systemNameLink: {
+ color: '#ff4e6b',
+ },
+ welcome: {
+ color: '#eee',
+ },
+ card: {
+ minWidth: 300,
+ backgroundColor: '#1d1d1d',
+ },
+ icon: {
+ filter: 'hue-rotate(115deg)',
+ },
+ },
+ MuiPaper: {
+ elevation1: {
+ boxShadow: 'none',
+ },
+ root: {
+ color: '#eee',
+ },
+ rounded: {
+ borderRadius: '6px',
+ },
+ },
+ NDMobileArtistDetails: {
+ bgContainer: {
+ background: '#1a1a1a',
+ },
+ artistName: {
+ fontWeight: '600',
+ fontSize: '2em',
+ },
+ },
+ NDDesktopArtistDetails: {
+ artistName: {
+ fontWeight: '600',
+ fontSize: '2em',
+ },
+ artistDetail: {
+ padding: 'unset',
+ paddingBottom: '1rem',
+ },
+ },
+ RaDeleteWithConfirmButton: {
+ deleteButton: {
+ color: 'unset',
+ },
+ },
+ RaPaginationActions: {
+ currentPageButton: {
+ border: '2px solid #D60017',
+ background: 'transparent',
+ },
+ button: {
+ border: '2px solid #D60017',
+ },
+ actions: {
+ '@global': {
+ '.next-page': {
+ border: '0 none',
+ },
+ '.previous-page': {
+ border: '0 none',
+ },
+ },
+ },
+ },
+ },
+ player: {
+ theme: 'dark',
+ stylesheet,
+ },
+}
diff --git a/ui/src/themes/dark.js b/ui/src/themes/dark.js
index 2f06b4337..15d8aa365 100644
--- a/ui/src/themes/dark.js
+++ b/ui/src/themes/dark.js
@@ -16,6 +16,11 @@ export default {
color: 'white',
},
},
+ MuiButton: {
+ textPrimary: {
+ color: '#fff',
+ },
+ },
NDLogin: {
systemNameLink: {
color: '#0085ff',
diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js
index b576e7713..b1a2e4c90 100644
--- a/ui/src/themes/gruvboxDark.js
+++ b/ui/src/themes/gruvboxDark.js
@@ -40,6 +40,11 @@ export default {
color: '#ebdbb2',
},
},
+ MuiIconButton: {
+ root: {
+ color: '#ebdbb2',
+ },
+ },
MuiChip: {
clickable: {
background: '#49483e',
diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js
index 0234d416b..5f9060383 100644
--- a/ui/src/themes/index.js
+++ b/ui/src/themes/index.js
@@ -10,6 +10,8 @@ import NordTheme from './nord'
import GruvboxDarkTheme from './gruvboxDark'
import CatppuccinMacchiatoTheme from './catppuccinMacchiato'
import NuclearTheme from './nuclear'
+import AmusicTheme from './amusic'
+import SquiddiesGlassTheme from './SquiddiesGlass'
export default {
// Classic default themes
@@ -17,6 +19,7 @@ export default {
DarkTheme,
// New themes should be added here, in alphabetic order
+ AmusicTheme,
CatppuccinMacchiatoTheme,
ElectricPurpleTheme,
ExtraDarkTheme,
@@ -27,4 +30,5 @@ export default {
NordTheme,
NuclearTheme,
SpotifyTheme,
+ SquiddiesGlassTheme,
}
diff --git a/ui/src/themes/ligera.js b/ui/src/themes/ligera.js
index 824cf7e67..363a379bc 100644
--- a/ui/src/themes/ligera.js
+++ b/ui/src/themes/ligera.js
@@ -70,7 +70,7 @@ export default {
},
background: {
default: '#f0f2f5',
- paper: 'inherit',
+ paper: bLight['500'],
},
text: {
secondary: '#232323',
@@ -448,15 +448,28 @@ export default {
backgroundColor: bLight['500'],
},
},
+ RaButton: {
+ button: {
+ margin: '0 5px 0 5px',
+ },
+ },
RaPaginationActions: {
button: {
- backgroundColor: 'inherit',
+ backgroundColor: '#fff',
+ color: '#000',
minWidth: 48,
margin: '0 4px',
- border: '1px solid #282828',
+ border: '1px solid #cccccc',
'@global': {
'> .MuiButton-label': {
padding: 0,
+ color: '#656565',
+ '&:hover': {
+ color: '#fff !important',
+ },
+ },
+ '> .MuiButton-label > svg': {
+ color: '#656565',
},
},
},
diff --git a/ui/src/themes/spotify.js b/ui/src/themes/spotify.js
index c40ed20aa..725831cc7 100644
--- a/ui/src/themes/spotify.js
+++ b/ui/src/themes/spotify.js
@@ -389,6 +389,11 @@ export default {
marginRight: '1rem',
},
},
+ RaButton: {
+ button: {
+ margin: '0 5px 0 5px',
+ },
+ },
RaPaginationActions: {
currentPageButton: {
border: '1px solid #b3b3b3',
diff --git a/ui/src/user/LibrarySelectionField.jsx b/ui/src/user/LibrarySelectionField.jsx
new file mode 100644
index 000000000..4967720cd
--- /dev/null
+++ b/ui/src/user/LibrarySelectionField.jsx
@@ -0,0 +1,55 @@
+import { useInput, useTranslate, useRecordContext } from 'react-admin'
+import { Box, FormControl, FormLabel, Typography } from '@material-ui/core'
+import { SelectLibraryInput } from '../common/SelectLibraryInput.jsx'
+import React, { useMemo } from 'react'
+
+export const LibrarySelectionField = () => {
+ const translate = useTranslate()
+ const record = useRecordContext()
+
+ const {
+ input: { name, onChange, value },
+ meta: { error, touched },
+ } = useInput({ source: 'libraryIds' })
+
+ // Extract library IDs from either 'libraries' array or 'libraryIds' array
+ const libraryIds = useMemo(() => {
+ // First check if form has libraryIds (create mode or already transformed)
+ if (value && Array.isArray(value)) {
+ return value
+ }
+
+ // Then check if record has libraries array (edit mode from backend)
+ if (record?.libraries && Array.isArray(record.libraries)) {
+ return record.libraries.map((lib) => lib.id)
+ }
+
+ return []
+ }, [value, record])
+
+ // Determine if this is a new user (no ID means new record)
+ const isNewUser = !record?.id
+
+ return (
+
+
+ {translate('resources.user.fields.libraries')}
+
+
+
+
+ {touched && error && (
+
+ {error}
+
+ )}
+
+ {translate('resources.user.helperTexts.libraries')}
+
+
+ )
+}
diff --git a/ui/src/user/LibrarySelectionField.test.jsx b/ui/src/user/LibrarySelectionField.test.jsx
new file mode 100644
index 000000000..9777bab99
--- /dev/null
+++ b/ui/src/user/LibrarySelectionField.test.jsx
@@ -0,0 +1,168 @@
+import * as React from 'react'
+import { render, screen, cleanup } from '@testing-library/react'
+import { LibrarySelectionField } from './LibrarySelectionField'
+import { useInput, useTranslate, useRecordContext } from 'react-admin'
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { SelectLibraryInput } from '../common/SelectLibraryInput'
+
+// Mock the react-admin hooks
+vi.mock('react-admin', () => ({
+ useInput: vi.fn(),
+ useTranslate: vi.fn(),
+ useRecordContext: vi.fn(),
+}))
+
+// Mock the SelectLibraryInput component
+vi.mock('../common/SelectLibraryInput.jsx', () => ({
+ SelectLibraryInput: vi.fn(() =>
),
+}))
+
+describe(' ', () => {
+ const defaultProps = {
+ input: {
+ name: 'libraryIds',
+ value: [],
+ onChange: vi.fn(),
+ },
+ meta: {
+ touched: false,
+ error: undefined,
+ },
+ }
+
+ const mockTranslate = vi.fn((key) => key)
+
+ beforeEach(() => {
+ useInput.mockReturnValue(defaultProps)
+ useTranslate.mockReturnValue(mockTranslate)
+ useRecordContext.mockReturnValue({})
+ SelectLibraryInput.mockClear()
+ })
+
+ afterEach(cleanup)
+
+ it('should render field label from translations', () => {
+ render( )
+ expect(screen.getByText('resources.user.fields.libraries')).not.toBeNull()
+ })
+
+ it('should render helper text from translations', () => {
+ render( )
+ expect(
+ screen.getByText('resources.user.helperTexts.libraries'),
+ ).not.toBeNull()
+ })
+
+ it('should render SelectLibraryInput with correct props', () => {
+ render( )
+ expect(screen.getByTestId('select-library-input')).not.toBeNull()
+ expect(SelectLibraryInput).toHaveBeenCalledWith(
+ expect.objectContaining({
+ onChange: defaultProps.input.onChange,
+ value: defaultProps.input.value,
+ }),
+ expect.anything(),
+ )
+ })
+
+ it('should render error message when touched and has error', () => {
+ useInput.mockReturnValue({
+ ...defaultProps,
+ meta: {
+ touched: true,
+ error: 'This field is required',
+ },
+ })
+
+ render( )
+ expect(screen.getByText('This field is required')).not.toBeNull()
+ })
+
+ it('should not render error message when not touched', () => {
+ useInput.mockReturnValue({
+ ...defaultProps,
+ meta: {
+ touched: false,
+ error: 'This field is required',
+ },
+ })
+
+ render( )
+ expect(screen.queryByText('This field is required')).toBeNull()
+ })
+
+ it('should initialize with empty array when value is null', () => {
+ useInput.mockReturnValue({
+ ...defaultProps,
+ input: {
+ ...defaultProps.input,
+ value: null,
+ },
+ })
+
+ render( )
+ expect(SelectLibraryInput).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: [],
+ }),
+ expect.anything(),
+ )
+ })
+
+ it('should extract library IDs from record libraries array when editing user', () => {
+ // Mock a record with libraries array (from backend during edit)
+ useRecordContext.mockReturnValue({
+ id: 'user123',
+ name: 'John Doe',
+ libraries: [
+ { id: 1, name: 'Music Library 1', path: '/music1' },
+ { id: 3, name: 'Music Library 3', path: '/music3' },
+ ],
+ })
+
+ // Mock input without libraryIds (edit mode scenario)
+ useInput.mockReturnValue({
+ ...defaultProps,
+ input: {
+ ...defaultProps.input,
+ value: undefined,
+ },
+ })
+
+ render( )
+ expect(SelectLibraryInput).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: [1, 3], // Should extract IDs from libraries array
+ }),
+ expect.anything(),
+ )
+ })
+
+ it('should prefer libraryIds when both libraryIds and libraries are present', () => {
+ // Mock a record with libraries array
+ useRecordContext.mockReturnValue({
+ id: 'user123',
+ libraries: [
+ { id: 1, name: 'Music Library 1', path: '/music1' },
+ { id: 3, name: 'Music Library 3', path: '/music3' },
+ ],
+ })
+
+ // Mock input with explicit libraryIds (create mode or already transformed)
+ useInput.mockReturnValue({
+ ...defaultProps,
+ input: {
+ ...defaultProps.input,
+ value: [2, 4], // Different IDs than in libraries
+ },
+ })
+
+ render( )
+ expect(SelectLibraryInput).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: [2, 4], // Should prefer libraryIds over libraries
+ }),
+ expect.anything(),
+ )
+ })
+})
diff --git a/ui/src/user/UserCreate.jsx b/ui/src/user/UserCreate.jsx
index 42ea1ce94..ce69b6542 100644
--- a/ui/src/user/UserCreate.jsx
+++ b/ui/src/user/UserCreate.jsx
@@ -2,17 +2,20 @@ import React, { useCallback } from 'react'
import {
BooleanInput,
Create,
- TextInput,
+ email,
+ FormDataConsumer,
PasswordInput,
required,
- email,
SimpleForm,
- useTranslate,
+ TextInput,
useMutation,
useNotify,
useRedirect,
+ useTranslate,
} from 'react-admin'
+import { Typography } from '@material-ui/core'
import { Title } from '../common'
+import { LibrarySelectionField } from './LibrarySelectionField.jsx'
const UserCreate = (props) => {
const translate = useTranslate()
@@ -48,9 +51,17 @@ const UserCreate = (props) => {
[mutate, notify, redirect],
)
+ // Custom validation function
+ const validateUserForm = (values) => {
+ const errors = {}
+ // Library selection is optional for non-admin users since they will be auto-assigned to default libraries
+ // No validation required for library selection
+ return errors
+ }
+
return (
} {...props}>
-
+
{
validate={[required()]}
/>
+
+ {/* Conditional Library Selection */}
+
+ {({ formData }) => (
+ <>
+ {!formData.isAdmin && }
+
+ {formData.isAdmin && (
+
+ {translate('resources.user.message.adminAutoLibraries')}
+
+ )}
+ >
+ )}
+
)
diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx
index 445f9c6fd..2283dd8bc 100644
--- a/ui/src/user/UserEdit.jsx
+++ b/ui/src/user/UserEdit.jsx
@@ -18,9 +18,13 @@ import {
useRefresh,
FormDataConsumer,
usePermissions,
+ useRecordContext,
} from 'react-admin'
+import { Typography } from '@material-ui/core'
import { Title } from '../common'
import DeleteUserButton from './DeleteUserButton'
+import { LibrarySelectionField } from './LibrarySelectionField.jsx'
+import { validateUserForm } from './userValidation'
const useStyles = makeStyles({
toolbar: {
@@ -100,12 +104,18 @@ const UserEdit = (props) => {
[mutate, notify, permissions, redirect, refresh],
)
+ // Custom validation function
+ const validateForm = (values) => {
+ return validateUserForm(values, translate)
+ }
+
return (
} undoable={false} {...props}>
}
save={save}
+ validate={validateForm}
>
{permissions === 'admin' && (
{
{permissions === 'admin' && (
)}
+
+ {/* Conditional Library Selection for Admin Users Only */}
+ {permissions === 'admin' && (
+
+ {({ formData }) => (
+ <>
+ {!formData.isAdmin && }
+
+ {formData.isAdmin && (
+
+ {translate('resources.user.message.adminAutoLibraries')}
+
+ )}
+ >
+ )}
+
+ )}
+
diff --git a/ui/src/user/UserEdit.test.jsx b/ui/src/user/UserEdit.test.jsx
new file mode 100644
index 000000000..75a9a1ada
--- /dev/null
+++ b/ui/src/user/UserEdit.test.jsx
@@ -0,0 +1,130 @@
+import * as React from 'react'
+import { render, screen } from '@testing-library/react'
+import UserEdit from './UserEdit'
+import { describe, it, expect, vi } from 'vitest'
+
+const defaultUser = {
+ id: 'user1',
+ userName: 'testuser',
+ name: 'Test User',
+ email: 'test@example.com',
+ isAdmin: false,
+ libraries: [
+ { id: 1, name: 'Library 1', path: '/music1' },
+ { id: 2, name: 'Library 2', path: '/music2' },
+ ],
+ lastLoginAt: '2023-01-01T12:00:00Z',
+ lastAccessAt: '2023-01-02T12:00:00Z',
+ updatedAt: '2023-01-03T12:00:00Z',
+ createdAt: '2023-01-04T12:00:00Z',
+}
+
+const adminUser = {
+ ...defaultUser,
+ id: 'admin1',
+ userName: 'admin',
+ name: 'Admin User',
+ isAdmin: true,
+}
+
+// Mock React-Admin completely with simpler implementations
+vi.mock('react-admin', () => ({
+ Edit: ({ children, title }) => (
+
+ {title}
+ {children}
+
+ ),
+ SimpleForm: ({ children }) => (
+
+ ),
+ TextInput: ({ source }) => ,
+ BooleanInput: ({ source }) => (
+
+ ),
+ DateField: ({ source }) => (
+ Date
+ ),
+ PasswordInput: ({ source }) => (
+
+ ),
+ Toolbar: ({ children }) => {children}
,
+ SaveButton: () => Save ,
+ FormDataConsumer: ({ children }) => children({ formData: {} }),
+ Typography: ({ children }) => {children}
,
+ required: () => () => null,
+ email: () => () => null,
+ useMutation: () => [vi.fn()],
+ useNotify: () => vi.fn(),
+ useRedirect: () => vi.fn(),
+ useRefresh: () => vi.fn(),
+ usePermissions: () => ({ permissions: 'admin' }),
+ useTranslate: () => (key) => key,
+}))
+
+vi.mock('./LibrarySelectionField.jsx', () => ({
+ LibrarySelectionField: () =>
,
+}))
+
+vi.mock('./DeleteUserButton', () => ({
+ __esModule: true,
+ default: () => Delete ,
+}))
+
+vi.mock('../common', () => ({
+ Title: ({ subTitle }) => {subTitle}
,
+}))
+
+// Mock Material-UI
+vi.mock('@material-ui/core/styles', () => ({
+ makeStyles: () => () => ({}),
+}))
+
+vi.mock('@material-ui/core', () => ({
+ Typography: ({ children }) => {children}
,
+}))
+
+describe(' ', () => {
+ it('should render the user edit form', () => {
+ render( )
+
+ // Check if the edit component renders
+ expect(screen.getByTestId('edit-component')).toBeInTheDocument()
+ expect(screen.getByTestId('simple-form')).toBeInTheDocument()
+ })
+
+ it('should render text inputs for admin users', () => {
+ render( )
+
+ // Should render username input for admin
+ expect(screen.getByTestId('text-input-userName')).toBeInTheDocument()
+ expect(screen.getByTestId('text-input-name')).toBeInTheDocument()
+ expect(screen.getByTestId('text-input-email')).toBeInTheDocument()
+ })
+
+ it('should render admin checkbox for admin permissions', () => {
+ render( )
+
+ // Should render isAdmin checkbox for admin users
+ expect(screen.getByTestId('boolean-input-isAdmin')).toBeInTheDocument()
+ })
+
+ it('should render date fields', () => {
+ render( )
+
+ expect(screen.getByTestId('date-field-lastLoginAt')).toBeInTheDocument()
+ expect(screen.getByTestId('date-field-lastAccessAt')).toBeInTheDocument()
+ expect(screen.getByTestId('date-field-updatedAt')).toBeInTheDocument()
+ expect(screen.getByTestId('date-field-createdAt')).toBeInTheDocument()
+ })
+
+ it('should not render username input for non-admin users', () => {
+ render( )
+
+ // Should not render username input for non-admin
+ expect(screen.queryByTestId('text-input-userName')).not.toBeInTheDocument()
+ // But should still render name and email
+ expect(screen.getByTestId('text-input-name')).toBeInTheDocument()
+ expect(screen.getByTestId('text-input-email')).toBeInTheDocument()
+ })
+})
diff --git a/ui/src/user/userValidation.js b/ui/src/user/userValidation.js
new file mode 100644
index 000000000..e90fd2acb
--- /dev/null
+++ b/ui/src/user/userValidation.js
@@ -0,0 +1,19 @@
+// User form validation utilities
+export const validateUserForm = (values, translate) => {
+ const errors = {}
+
+ // Only require library selection for non-admin users
+ if (!values.isAdmin) {
+ // Check both libraryIds (array of IDs) and libraries (array of objects)
+ const hasLibraryIds = values.libraryIds && values.libraryIds.length > 0
+ const hasLibraries = values.libraries && values.libraries.length > 0
+
+ if (!hasLibraryIds && !hasLibraries) {
+ errors.libraryIds = translate(
+ 'resources.user.validation.librariesRequired',
+ )
+ }
+ }
+
+ return errors
+}
diff --git a/ui/src/user/userValidation.test.js b/ui/src/user/userValidation.test.js
new file mode 100644
index 000000000..2ee473910
--- /dev/null
+++ b/ui/src/user/userValidation.test.js
@@ -0,0 +1,70 @@
+import { describe, it, expect, vi } from 'vitest'
+import { validateUserForm } from './userValidation'
+
+describe('User Validation Utilities', () => {
+ const mockTranslate = vi.fn((key) => key)
+
+ describe('validateUserForm', () => {
+ it('should not return errors for admin users', () => {
+ const values = {
+ isAdmin: true,
+ libraryIds: [],
+ }
+ const errors = validateUserForm(values, mockTranslate)
+ expect(errors).toEqual({})
+ })
+
+ it('should not return errors for non-admin users with libraries', () => {
+ const values = {
+ isAdmin: false,
+ libraryIds: [1, 2, 3],
+ }
+ const errors = validateUserForm(values, mockTranslate)
+ expect(errors).toEqual({})
+ })
+
+ it('should return error for non-admin users without libraries', () => {
+ const values = {
+ isAdmin: false,
+ libraryIds: [],
+ }
+ const errors = validateUserForm(values, mockTranslate)
+ expect(errors.libraryIds).toBe(
+ 'resources.user.validation.librariesRequired',
+ )
+ })
+
+ it('should return error for non-admin users with undefined libraryIds', () => {
+ const values = {
+ isAdmin: false,
+ }
+ const errors = validateUserForm(values, mockTranslate)
+ expect(errors.libraryIds).toBe(
+ 'resources.user.validation.librariesRequired',
+ )
+ })
+
+ it('should not return errors for non-admin users with libraries array', () => {
+ const values = {
+ isAdmin: false,
+ libraries: [
+ { id: 1, name: 'Library 1' },
+ { id: 2, name: 'Library 2' },
+ ],
+ }
+ const errors = validateUserForm(values, mockTranslate)
+ expect(errors).toEqual({})
+ })
+
+ it('should return error for non-admin users with empty libraries array', () => {
+ const values = {
+ isAdmin: false,
+ libraries: [],
+ }
+ const errors = validateUserForm(values, mockTranslate)
+ expect(errors.libraryIds).toBe(
+ 'resources.user.validation.librariesRequired',
+ )
+ })
+ })
+})
diff --git a/ui/src/utils/formatters.js b/ui/src/utils/formatters.js
index ae27f230f..cfcb84b05 100644
--- a/ui/src/utils/formatters.js
+++ b/ui/src/utils/formatters.js
@@ -25,6 +25,42 @@ export const formatDuration = (d) => {
return `${days > 0 ? days + ':' : ''}${f}`
}
+export const formatDuration2 = (totalSeconds) => {
+ if (totalSeconds == null || totalSeconds < 0) {
+ return '0s'
+ }
+ const days = Math.floor(totalSeconds / 86400)
+ const hours = Math.floor((totalSeconds % 86400) / 3600)
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
+ const seconds = Math.floor(totalSeconds % 60)
+
+ const parts = []
+
+ if (days > 0) {
+ // When days are present, show only d h m (3 levels max)
+ parts.push(`${days}d`)
+ if (hours > 0) {
+ parts.push(`${hours}h`)
+ }
+ if (minutes > 0) {
+ parts.push(`${minutes}m`)
+ }
+ } else {
+ // When no days, show h m s (3 levels max)
+ if (hours > 0) {
+ parts.push(`${hours}h`)
+ }
+ if (minutes > 0) {
+ parts.push(`${minutes}m`)
+ }
+ if (seconds > 0 || parts.length === 0) {
+ parts.push(`${seconds}s`)
+ }
+ }
+
+ return parts.join(' ')
+}
+
export const formatShortDuration = (ns) => {
// Convert nanoseconds to seconds
const seconds = ns / 1e9
@@ -58,3 +94,8 @@ export const formatFullDate = (date, locale) => {
}
return new Date(date).toLocaleDateString(locale, options)
}
+
+export const formatNumber = (value, locale) => {
+ if (value === null || value === undefined) return '0'
+ return value.toLocaleString(locale)
+}
diff --git a/ui/src/utils/formatters.test.js b/ui/src/utils/formatters.test.js
index 87b40f16b..d633e96f2 100644
--- a/ui/src/utils/formatters.test.js
+++ b/ui/src/utils/formatters.test.js
@@ -1,7 +1,9 @@
import {
formatBytes,
formatDuration,
+ formatDuration2,
formatFullDate,
+ formatNumber,
formatShortDuration,
} from './formatters'
@@ -64,11 +66,90 @@ describe('formatShortDuration', () => {
})
})
+describe('formatDuration2', () => {
+ it('handles null and undefined values', () => {
+ expect(formatDuration2(null)).toEqual('0s')
+ expect(formatDuration2(undefined)).toEqual('0s')
+ })
+
+ it('handles negative values', () => {
+ expect(formatDuration2(-10)).toEqual('0s')
+ expect(formatDuration2(-1)).toEqual('0s')
+ })
+
+ it('formats zero seconds', () => {
+ expect(formatDuration2(0)).toEqual('0s')
+ })
+
+ it('formats seconds only', () => {
+ expect(formatDuration2(1)).toEqual('1s')
+ expect(formatDuration2(30)).toEqual('30s')
+ expect(formatDuration2(59)).toEqual('59s')
+ })
+
+ it('formats minutes and seconds', () => {
+ expect(formatDuration2(60)).toEqual('1m')
+ expect(formatDuration2(90)).toEqual('1m 30s')
+ expect(formatDuration2(119)).toEqual('1m 59s')
+ expect(formatDuration2(120)).toEqual('2m')
+ })
+
+ it('formats hours, minutes and seconds', () => {
+ expect(formatDuration2(3600)).toEqual('1h')
+ expect(formatDuration2(3661)).toEqual('1h 1m 1s')
+ expect(formatDuration2(7200)).toEqual('2h')
+ expect(formatDuration2(7260)).toEqual('2h 1m')
+ expect(formatDuration2(7261)).toEqual('2h 1m 1s')
+ })
+
+ it('handles decimal values by flooring', () => {
+ expect(formatDuration2(59.9)).toEqual('59s')
+ expect(formatDuration2(60.1)).toEqual('1m')
+ expect(formatDuration2(3600.9)).toEqual('1h')
+ })
+
+ it('formats days with maximum 3 levels (d h m)', () => {
+ expect(formatDuration2(86400)).toEqual('1d')
+ expect(formatDuration2(86461)).toEqual('1d 1m') // seconds dropped when days present
+ expect(formatDuration2(90061)).toEqual('1d 1h 1m') // seconds dropped when days present
+ expect(formatDuration2(172800)).toEqual('2d')
+ expect(formatDuration2(176400)).toEqual('2d 1h')
+ expect(formatDuration2(176460)).toEqual('2d 1h 1m')
+ expect(formatDuration2(176461)).toEqual('2d 1h 1m') // seconds dropped when days present
+ })
+})
+
+describe('formatNumber', () => {
+ it('handles null and undefined values', () => {
+ expect(formatNumber(null, 'en-CA')).toEqual('0')
+ expect(formatNumber(undefined, 'en-CA')).toEqual('0')
+ })
+
+ it('formats integers', () => {
+ expect(formatNumber(0, 'en-CA')).toEqual('0')
+ expect(formatNumber(1, 'en-CA')).toEqual('1')
+ expect(formatNumber(123, 'en-CA')).toEqual('123')
+ expect(formatNumber(1000, 'en-CA')).toEqual('1,000')
+ expect(formatNumber(1234567, 'en-CA')).toEqual('1,234,567')
+ })
+
+ it('formats decimal numbers', () => {
+ expect(formatNumber(123.45, 'en-CA')).toEqual('123.45')
+ expect(formatNumber(1234.567, 'en-CA')).toEqual('1,234.567')
+ })
+
+ it('formats negative numbers', () => {
+ expect(formatNumber(-123, 'en-CA')).toEqual('-123')
+ expect(formatNumber(-1234, 'en-CA')).toEqual('-1,234')
+ expect(formatNumber(-123.45, 'en-CA')).toEqual('-123.45')
+ })
+})
+
describe('formatFullDate', () => {
it('format dates', () => {
- expect(formatFullDate('2011', 'en-US')).toEqual('2011')
- expect(formatFullDate('2011-06', 'en-US')).toEqual('Jun 2011')
- expect(formatFullDate('1985-01-01', 'en-US')).toEqual('Jan 1, 1985')
+ expect(formatFullDate('2011', 'en-CA')).toEqual('2011')
+ expect(formatFullDate('2011-06', 'en-CA')).toEqual('Jun 2011')
+ expect(formatFullDate('1985-01-01', 'en-CA')).toEqual('Jan 1, 1985')
expect(formatFullDate('199704')).toEqual('')
})
})
diff --git a/ui/src/utils/validations.js b/ui/src/utils/validations.js
index 8b163c156..792726e70 100644
--- a/ui/src/utils/validations.js
+++ b/ui/src/utils/validations.js
@@ -10,3 +10,16 @@ export const urlValidate = (value) => {
return 'ra.validation.url'
}
}
+
+export function isDateSet(date) {
+ if (!date) {
+ return false
+ }
+ if (typeof date === 'string') {
+ return date !== '0001-01-01T00:00:00Z'
+ }
+ if (date instanceof Date) {
+ return date.toISOString() !== '0001-01-01T00:00:00Z'
+ }
+ return !!date
+}
diff --git a/ui/src/utils/validations.test.js b/ui/src/utils/validations.test.js
new file mode 100644
index 000000000..10f67d186
--- /dev/null
+++ b/ui/src/utils/validations.test.js
@@ -0,0 +1,73 @@
+import { isDateSet, urlValidate } from './validations'
+
+describe('urlValidate', () => {
+ it('returns undefined for valid URLs', () => {
+ expect(urlValidate('https://example.com')).toBeUndefined()
+ expect(urlValidate('http://localhost:3000')).toBeUndefined()
+ expect(urlValidate('ftp://files.example.com')).toBeUndefined()
+ })
+
+ it('returns undefined for empty values', () => {
+ expect(urlValidate('')).toBeUndefined()
+ expect(urlValidate(null)).toBeUndefined()
+ expect(urlValidate(undefined)).toBeUndefined()
+ })
+
+ it('returns error for invalid URLs', () => {
+ expect(urlValidate('not-a-url')).toEqual('ra.validation.url')
+ expect(urlValidate('example.com')).toEqual('ra.validation.url')
+ expect(urlValidate('://missing-protocol')).toEqual('ra.validation.url')
+ })
+})
+
+describe('isDateSet', () => {
+ describe('with falsy values', () => {
+ it('returns false for null', () => {
+ expect(isDateSet(null)).toBe(false)
+ })
+
+ it('returns false for undefined', () => {
+ expect(isDateSet(undefined)).toBe(false)
+ })
+
+ it('returns false for empty string', () => {
+ expect(isDateSet('')).toBe(false)
+ })
+ })
+
+ describe('with Go zero date string', () => {
+ it('returns false for Go zero date', () => {
+ expect(isDateSet('0001-01-01T00:00:00Z')).toBe(false)
+ })
+ })
+
+ describe('with valid date strings', () => {
+ it('returns true for ISO date strings', () => {
+ expect(isDateSet('2024-01-15T10:30:00Z')).toBe(true)
+ expect(isDateSet('2023-12-25T00:00:00Z')).toBe(true)
+ })
+
+ it('returns true for other date formats', () => {
+ expect(isDateSet('2024-01-15')).toBe(true)
+ })
+ })
+
+ describe('with Date objects', () => {
+ it('returns true for valid Date objects', () => {
+ expect(isDateSet(new Date())).toBe(true)
+ expect(isDateSet(new Date('2024-01-15T10:30:00Z'))).toBe(true)
+ })
+
+ // Note: Date objects representing Go zero date would return true because
+ // toISOString() adds milliseconds (0001-01-01T00:00:00.000Z).
+ // In practice, dates from the API come as strings, not Date objects,
+ // so this edge case doesn't occur.
+ })
+
+ describe('with other truthy values', () => {
+ it('returns true for non-date truthy values', () => {
+ expect(isDateSet(123)).toBe(true)
+ expect(isDateSet({})).toBe(true)
+ })
+ })
+})
diff --git a/utils/files_test.go b/utils/files_test.go
new file mode 100644
index 000000000..dcb28aafb
--- /dev/null
+++ b/utils/files_test.go
@@ -0,0 +1,178 @@
+package utils_test
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/navidrome/navidrome/utils"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("TempFileName", func() {
+ It("creates a temporary file name with prefix and suffix", func() {
+ prefix := "test-"
+ suffix := ".tmp"
+ result := utils.TempFileName(prefix, suffix)
+
+ Expect(result).To(ContainSubstring(prefix))
+ Expect(result).To(HaveSuffix(suffix))
+ Expect(result).To(ContainSubstring(os.TempDir()))
+ })
+
+ It("creates unique file names on multiple calls", func() {
+ prefix := "unique-"
+ suffix := ".test"
+
+ result1 := utils.TempFileName(prefix, suffix)
+ result2 := utils.TempFileName(prefix, suffix)
+
+ Expect(result1).NotTo(Equal(result2))
+ })
+
+ It("handles empty prefix and suffix", func() {
+ result := utils.TempFileName("", "")
+
+ Expect(result).To(ContainSubstring(os.TempDir()))
+ Expect(len(result)).To(BeNumerically(">", len(os.TempDir())))
+ })
+
+ It("creates proper file path separators", func() {
+ prefix := "path-test-"
+ suffix := ".ext"
+ result := utils.TempFileName(prefix, suffix)
+
+ expectedDir := os.TempDir()
+ Expect(result).To(HavePrefix(expectedDir))
+ Expect(strings.Count(result, string(filepath.Separator))).To(BeNumerically(">=", strings.Count(expectedDir, string(filepath.Separator))))
+ })
+})
+
+var _ = Describe("BaseName", func() {
+ It("extracts basename from a simple filename", func() {
+ result := utils.BaseName("test.mp3")
+ Expect(result).To(Equal("test"))
+ })
+
+ It("extracts basename from a file path", func() {
+ result := utils.BaseName("/path/to/file.txt")
+ Expect(result).To(Equal("file"))
+ })
+
+ It("handles files without extension", func() {
+ result := utils.BaseName("/path/to/filename")
+ Expect(result).To(Equal("filename"))
+ })
+
+ It("handles files with multiple dots", func() {
+ result := utils.BaseName("archive.tar.gz")
+ Expect(result).To(Equal("archive.tar"))
+ })
+
+ It("handles hidden files", func() {
+ // For hidden files without additional extension, path.Ext returns the entire name
+ // So basename becomes empty string after TrimSuffix
+ result := utils.BaseName(".hidden")
+ Expect(result).To(Equal(""))
+ })
+
+ It("handles hidden files with extension", func() {
+ result := utils.BaseName(".config.json")
+ Expect(result).To(Equal(".config"))
+ })
+
+ It("handles empty string", func() {
+ // The actual behavior returns empty string for empty input
+ result := utils.BaseName("")
+ Expect(result).To(Equal(""))
+ })
+
+ It("handles path ending with separator", func() {
+ result := utils.BaseName("/path/to/dir/")
+ Expect(result).To(Equal("dir"))
+ })
+
+ It("handles complex nested path", func() {
+ result := utils.BaseName("/very/long/path/to/my/favorite/song.mp3")
+ Expect(result).To(Equal("song"))
+ })
+})
+
+var _ = Describe("FileExists", func() {
+ var tempFile *os.File
+ var tempDir string
+
+ BeforeEach(func() {
+ var err error
+ tempFile, err = os.CreateTemp("", "fileexists-test-*.txt")
+ Expect(err).NotTo(HaveOccurred())
+
+ tempDir, err = os.MkdirTemp("", "fileexists-test-dir-*")
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ AfterEach(func() {
+ if tempFile != nil {
+ os.Remove(tempFile.Name())
+ tempFile.Close()
+ }
+ if tempDir != "" {
+ os.RemoveAll(tempDir)
+ }
+ })
+
+ It("returns true for existing file", func() {
+ Expect(utils.FileExists(tempFile.Name())).To(BeTrue())
+ })
+
+ It("returns true for existing directory", func() {
+ Expect(utils.FileExists(tempDir)).To(BeTrue())
+ })
+
+ It("returns false for non-existing file", func() {
+ nonExistentPath := filepath.Join(tempDir, "does-not-exist.txt")
+ Expect(utils.FileExists(nonExistentPath)).To(BeFalse())
+ })
+
+ It("returns false for empty path", func() {
+ Expect(utils.FileExists("")).To(BeFalse())
+ })
+
+ It("handles nested non-existing path", func() {
+ nonExistentPath := "/this/path/definitely/does/not/exist/file.txt"
+ Expect(utils.FileExists(nonExistentPath)).To(BeFalse())
+ })
+
+ Context("when file is deleted after creation", func() {
+ It("returns false after file deletion", func() {
+ filePath := tempFile.Name()
+ Expect(utils.FileExists(filePath)).To(BeTrue())
+
+ err := os.Remove(filePath)
+ Expect(err).NotTo(HaveOccurred())
+ tempFile = nil // Prevent cleanup attempt
+
+ Expect(utils.FileExists(filePath)).To(BeFalse())
+ })
+ })
+
+ Context("when directory is deleted after creation", func() {
+ It("returns false after directory deletion", func() {
+ dirPath := tempDir
+ Expect(utils.FileExists(dirPath)).To(BeTrue())
+
+ err := os.RemoveAll(dirPath)
+ Expect(err).NotTo(HaveOccurred())
+ tempDir = "" // Prevent cleanup attempt
+
+ Expect(utils.FileExists(dirPath)).To(BeFalse())
+ })
+ })
+
+ It("handles permission denied scenarios gracefully", func() {
+ // This test might be platform specific, but we test the general case
+ result := utils.FileExists("/root/.ssh/id_rsa") // Likely to not exist or be inaccessible
+ Expect(result).To(Or(BeTrue(), BeFalse())) // Should not panic
+ })
+})
diff --git a/utils/ioutils/ioutils.go b/utils/ioutils/ioutils.go
new file mode 100644
index 000000000..89d3997f3
--- /dev/null
+++ b/utils/ioutils/ioutils.go
@@ -0,0 +1,33 @@
+package ioutils
+
+import (
+ "io"
+ "os"
+
+ "golang.org/x/text/encoding/unicode"
+ "golang.org/x/text/transform"
+)
+
+// UTF8Reader wraps an io.Reader to handle Byte Order Mark (BOM) properly.
+// It strips UTF-8 BOM if present, and converts UTF-16 (LE/BE) to UTF-8.
+// This is particularly useful for reading user-provided text files (like LRC lyrics,
+// playlists) that may have been created on Windows, which often adds BOM markers.
+//
+// Reference: https://en.wikipedia.org/wiki/Byte_order_mark
+func UTF8Reader(r io.Reader) io.Reader {
+ return transform.NewReader(r, unicode.BOMOverride(unicode.UTF8.NewDecoder()))
+}
+
+// UTF8ReadFile reads the named file and returns its contents as a byte slice,
+// automatically handling BOM markers. It's similar to os.ReadFile but strips
+// UTF-8 BOM and converts UTF-16 encoded files to UTF-8.
+func UTF8ReadFile(filename string) ([]byte, error) {
+ file, err := os.Open(filename)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ reader := UTF8Reader(file)
+ return io.ReadAll(reader)
+}
diff --git a/utils/ioutils/ioutils_test.go b/utils/ioutils/ioutils_test.go
new file mode 100644
index 000000000..7f5483879
--- /dev/null
+++ b/utils/ioutils/ioutils_test.go
@@ -0,0 +1,117 @@
+package ioutils
+
+import (
+ "bytes"
+ "io"
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestIOUtils(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "IO Utils Suite")
+}
+
+var _ = Describe("UTF8Reader", func() {
+ Context("when reading text with UTF-8 BOM", func() {
+ It("strips the UTF-8 BOM marker", func() {
+ // UTF-8 BOM is EF BB BF
+ input := []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'}
+ reader := UTF8Reader(bytes.NewReader(input))
+
+ output, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(output)).To(Equal("hello"))
+ })
+
+ It("strips UTF-8 BOM from multi-line text", func() {
+ // Test with the actual LRC file format
+ input := []byte{0xEF, 0xBB, 0xBF, '[', '0', '0', ':', '0', '0', '.', '0', '0', ']', ' ', 't', 'e', 's', 't'}
+ reader := UTF8Reader(bytes.NewReader(input))
+
+ output, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(output)).To(Equal("[00:00.00] test"))
+ })
+ })
+
+ Context("when reading text without BOM", func() {
+ It("passes through unchanged", func() {
+ input := []byte("hello world")
+ reader := UTF8Reader(bytes.NewReader(input))
+
+ output, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(output)).To(Equal("hello world"))
+ })
+ })
+
+ Context("when reading UTF-16 LE encoded text", func() {
+ It("converts to UTF-8 and strips BOM", func() {
+ // UTF-16 LE BOM (FF FE) followed by "hi" in UTF-16 LE
+ input := []byte{0xFF, 0xFE, 'h', 0x00, 'i', 0x00}
+ reader := UTF8Reader(bytes.NewReader(input))
+
+ output, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(output)).To(Equal("hi"))
+ })
+ })
+
+ Context("when reading UTF-16 BE encoded text", func() {
+ It("converts to UTF-8 and strips BOM", func() {
+ // UTF-16 BE BOM (FE FF) followed by "hi" in UTF-16 BE
+ input := []byte{0xFE, 0xFF, 0x00, 'h', 0x00, 'i'}
+ reader := UTF8Reader(bytes.NewReader(input))
+
+ output, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(output)).To(Equal("hi"))
+ })
+ })
+
+ Context("when reading empty content", func() {
+ It("returns empty string", func() {
+ reader := UTF8Reader(bytes.NewReader([]byte{}))
+
+ output, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(output)).To(Equal(""))
+ })
+ })
+})
+
+var _ = Describe("UTF8ReadFile", func() {
+ Context("when reading a file with UTF-8 BOM", func() {
+ It("strips the BOM marker", func() {
+ // Use the actual fixture from issue #4631
+ contents, err := UTF8ReadFile("../../tests/fixtures/bom-test.lrc")
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should NOT start with BOM
+ Expect(contents[0]).ToNot(Equal(byte(0xEF)))
+ // Should start with '['
+ Expect(contents[0]).To(Equal(byte('[')))
+ Expect(string(contents)).To(HavePrefix("[00:00.00]"))
+ })
+ })
+
+ Context("when reading a file without BOM", func() {
+ It("reads the file normally", func() {
+ contents, err := UTF8ReadFile("../../tests/fixtures/test.lrc")
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should contain the expected content
+ Expect(string(contents)).To(ContainSubstring("We're no strangers to love"))
+ })
+ })
+
+ Context("when reading a non-existent file", func() {
+ It("returns an error", func() {
+ _, err := UTF8ReadFile("../../tests/fixtures/nonexistent.lrc")
+ Expect(err).To(HaveOccurred())
+ })
+ })
+})
diff --git a/utils/slice/slice.go b/utils/slice/slice.go
index 1d7c64f50..b1f50afcc 100644
--- a/utils/slice/slice.go
+++ b/utils/slice/slice.go
@@ -171,3 +171,14 @@ func SeqFunc[I, O any](s []I, f func(I) O) iter.Seq[O] {
}
}
}
+
+// Filter returns a new slice containing only the elements of s for which filterFunc returns true
+func Filter[T any](s []T, filterFunc func(T) bool) []T {
+ var result []T
+ for _, item := range s {
+ if filterFunc(item) {
+ result = append(result, item)
+ }
+ }
+ return result
+}
diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go
index c6d4be1e0..65e5f0934 100644
--- a/utils/slice/slice_test.go
+++ b/utils/slice/slice_test.go
@@ -172,4 +172,42 @@ var _ = Describe("Slice Utils", func() {
Expect(result).To(ConsistOf("2", "4", "6", "8"))
})
})
+
+ Describe("Filter", func() {
+ It("returns empty slice for an empty input", func() {
+ filterFunc := func(v int) bool { return v > 0 }
+ result := slice.Filter([]int{}, filterFunc)
+ Expect(result).To(BeEmpty())
+ })
+
+ It("returns all elements when filter matches all", func() {
+ filterFunc := func(v int) bool { return v > 0 }
+ result := slice.Filter([]int{1, 2, 3, 4}, filterFunc)
+ Expect(result).To(HaveExactElements(1, 2, 3, 4))
+ })
+
+ It("returns empty slice when filter matches none", func() {
+ filterFunc := func(v int) bool { return v > 10 }
+ result := slice.Filter([]int{1, 2, 3, 4}, filterFunc)
+ Expect(result).To(BeEmpty())
+ })
+
+ It("returns only matching elements", func() {
+ filterFunc := func(v int) bool { return v%2 == 0 }
+ result := slice.Filter([]int{1, 2, 3, 4, 5, 6}, filterFunc)
+ Expect(result).To(HaveExactElements(2, 4, 6))
+ })
+
+ It("works with string slices", func() {
+ filterFunc := func(s string) bool { return len(s) > 3 }
+ result := slice.Filter([]string{"a", "abc", "abcd", "ab", "abcde"}, filterFunc)
+ Expect(result).To(HaveExactElements("abcd", "abcde"))
+ })
+
+ It("preserves order of elements", func() {
+ filterFunc := func(v int) bool { return v%2 == 1 }
+ result := slice.Filter([]int{9, 8, 7, 6, 5, 4, 3, 2, 1}, filterFunc)
+ Expect(result).To(HaveExactElements(9, 7, 5, 3, 1))
+ })
+ })
})
diff --git a/utils/str/str.go b/utils/str/str.go
index 8a94488de..f662473da 100644
--- a/utils/str/str.go
+++ b/utils/str/str.go
@@ -2,6 +2,7 @@ package str
import (
"strings"
+ "unicode/utf8"
)
var utf8ToAscii = func() *strings.Replacer {
@@ -39,3 +40,25 @@ func LongestCommonPrefix(list []string) string {
}
return list[0]
}
+
+// TruncateRunes truncates a string to a maximum number of runes, adding a suffix if truncated.
+// The suffix is included in the rune count, so if maxRunes is 30 and suffix is "...", the actual
+// string content will be truncated to fit within the maxRunes limit including the suffix.
+func TruncateRunes(s string, maxRunes int, suffix string) string {
+ if utf8.RuneCountInString(s) <= maxRunes {
+ return s
+ }
+
+ suffixRunes := utf8.RuneCountInString(suffix)
+ truncateAt := maxRunes - suffixRunes
+ if truncateAt < 0 {
+ truncateAt = 0
+ }
+
+ runes := []rune(s)
+ if truncateAt >= len(runes) {
+ return s + suffix
+ }
+
+ return string(runes[:truncateAt]) + suffix
+}
diff --git a/utils/str/str_test.go b/utils/str/str_test.go
index 0c3524e4e..511805831 100644
--- a/utils/str/str_test.go
+++ b/utils/str/str_test.go
@@ -31,6 +31,72 @@ var _ = Describe("String Utils", func() {
Expect(str.LongestCommonPrefix(albums)).To(Equal("/artist/album"))
})
})
+
+ Describe("TruncateRunes", func() {
+ It("returns string unchanged if under max runes", func() {
+ Expect(str.TruncateRunes("hello", 10, "...")).To(Equal("hello"))
+ })
+
+ It("returns string unchanged if exactly at max runes", func() {
+ Expect(str.TruncateRunes("hello", 5, "...")).To(Equal("hello"))
+ })
+
+ It("truncates and adds suffix when over max runes", func() {
+ Expect(str.TruncateRunes("hello world", 8, "...")).To(Equal("hello..."))
+ })
+
+ It("handles unicode characters correctly", func() {
+ // 6 emoji characters, maxRunes=5, suffix="..." (3 runes)
+ // So content gets 5-3=2 runes
+ Expect(str.TruncateRunes("😀😁😂😃😄😅", 5, "...")).To(Equal("😀😁..."))
+ })
+
+ It("handles multi-byte UTF-8 characters", func() {
+ // Characters like é are single runes
+ Expect(str.TruncateRunes("Café au Lait", 5, "...")).To(Equal("Ca..."))
+ })
+
+ It("works with empty suffix", func() {
+ Expect(str.TruncateRunes("hello world", 5, "")).To(Equal("hello"))
+ })
+
+ It("accounts for suffix length in truncation", func() {
+ // maxRunes=10, suffix="..." (3 runes) -> leaves 7 runes for content
+ result := str.TruncateRunes("hello world this is long", 10, "...")
+ Expect(result).To(Equal("hello w..."))
+ // Verify total rune count is <= maxRunes
+ runeCount := len([]rune(result))
+ Expect(runeCount).To(BeNumerically("<=", 10))
+ })
+
+ It("handles very long suffix gracefully", func() {
+ // If suffix is longer than maxRunes, we still add it
+ // but the content will be truncated to 0
+ result := str.TruncateRunes("hello world", 5, "... (truncated)")
+ // Result will be just the suffix (since truncateAt=0)
+ Expect(result).To(Equal("... (truncated)"))
+ })
+
+ It("handles empty string", func() {
+ Expect(str.TruncateRunes("", 10, "...")).To(Equal(""))
+ })
+
+ It("uses custom suffix", func() {
+ // maxRunes=11, suffix=" [...]" (6 runes) -> content gets 5 runes
+ // "hello world" is 11 runes exactly, so we need a longer string
+ Expect(str.TruncateRunes("hello world extra", 11, " [...]")).To(Equal("hello [...]"))
+ })
+
+ DescribeTable("truncates at rune boundaries (not byte boundaries)",
+ func(input string, maxRunes int, suffix string, expected string) {
+ Expect(str.TruncateRunes(input, maxRunes, suffix)).To(Equal(expected))
+ },
+ Entry("ASCII", "abcdefghij", 5, "...", "ab..."),
+ Entry("Mixed ASCII and Unicode", "ab😀cd", 4, ".", "ab😀."),
+ Entry("All emoji", "😀😁😂😃😄", 3, "…", "😀😁…"),
+ Entry("Japanese", "こんにちは世界", 3, "…", "こん…"),
+ )
+ })
})
var testPaths = []string{