mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-01 11:28:04 -05:00
Compare commits
85 Commits
v0.57.0
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d05b5f5eb2 | ||
|
|
69527085db | ||
|
|
9bb933c0d6 | ||
|
|
6f4fa76772 | ||
|
|
9621a40f29 | ||
|
|
df95dffa74 | ||
|
|
a59b59192a | ||
|
|
4f7dc105b0 | ||
|
|
e918e049e2 | ||
|
|
1e8d28ff46 | ||
|
|
a128b3cf98 | ||
|
|
290a9fdeaa | ||
|
|
58b5ed86df | ||
|
|
fe1cee0159 | ||
|
|
3dfaa8cca1 | ||
|
|
0a5abfc1b1 | ||
|
|
c501bc6996 | ||
|
|
0c71842b12 | ||
|
|
e86dc03619 | ||
|
|
775626e037 | ||
|
|
91fab68578 | ||
|
|
0bdd3e6f8b | ||
|
|
465846c1bc | ||
|
|
cce11c5416 | ||
|
|
d021289279 | ||
|
|
aa7f55646d | ||
|
|
925bfafc1f | ||
|
|
e24f7984cc | ||
|
|
ac3e6ae6a5 | ||
|
|
b2019da999 | ||
|
|
871ee730cd | ||
|
|
c2657e0adb | ||
|
|
aff9c7120b | ||
|
|
94d2696c84 | ||
|
|
949bff993e | ||
|
|
b2ee5b5156 | ||
|
|
9dbe0c183e | ||
|
|
d9aa3529d7 | ||
|
|
77e47f1ea2 | ||
|
|
d75ebc5efd | ||
|
|
5ea14ba520 | ||
|
|
3e61b0426b | ||
|
|
d28a282de4 | ||
|
|
1eef2e554c | ||
|
|
6722af50e2 | ||
|
|
eeef98e2ca | ||
|
|
be83d68956 | ||
|
|
c8915ecd88 | ||
|
|
0da2352907 | ||
|
|
a30fa478ac | ||
|
|
9f0059e13f | ||
|
|
159aa28ec8 | ||
|
|
39febfac28 | ||
|
|
36d73eec0d | ||
|
|
e9a8d7ed66 | ||
|
|
c193bb2a09 | ||
|
|
72031d99ed | ||
|
|
9fcc996336 | ||
|
|
d5fa46e948 | ||
|
|
9f46204b63 | ||
|
|
a60bea70c9 | ||
|
|
a569f6788e | ||
|
|
00c83af170 | ||
|
|
089dbe9499 | ||
|
|
445880c006 | ||
|
|
3c1e5603d0 | ||
|
|
adef0ea1e7 | ||
|
|
b69a7652b9 | ||
|
|
d8e829ad18 | ||
|
|
5b73a4d5b7 | ||
|
|
1de84dbd0c | ||
|
|
e8a3495c70 | ||
|
|
1166a0fabf | ||
|
|
9e97d0a9d9 | ||
|
|
6730716d26 | ||
|
|
65961cce4b | ||
|
|
d041cb3249 | ||
|
|
f1f1fd2007 | ||
|
|
66eaac2762 | ||
|
|
c583ff57a3 | ||
|
|
9b3d3d15a1 | ||
|
|
d4f869152b | ||
|
|
ee34433cc5 | ||
|
|
a3d1a9dbe5 | ||
|
|
82f490d066 |
@@ -4,10 +4,10 @@
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||
"VARIANT": "1.24",
|
||||
"VARIANT": "1.25",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v20"
|
||||
"NODE_VERSION": "v24"
|
||||
}
|
||||
},
|
||||
"workspaceMount": "",
|
||||
|
||||
53
.github/copilot-instructions.md
vendored
53
.github/copilot-instructions.md
vendored
@@ -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
|
||||
44
.github/workflows/pipeline.yml
vendored
44
.github/workflows/pipeline.yml
vendored
@@ -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@v5
|
||||
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@v5
|
||||
|
||||
- name: Download TagLib
|
||||
uses: ./.github/actions/download-taglib
|
||||
@@ -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@v5
|
||||
|
||||
- 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@v5
|
||||
- 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@v5
|
||||
- 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@v5
|
||||
|
||||
- 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@v5
|
||||
|
||||
- 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@v5
|
||||
|
||||
- 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@v5
|
||||
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 }}
|
||||
|
||||
2
.github/workflows/update-translations.yml
vendored
2
.github/workflows/update-translations.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'navidrome' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Get updated translations
|
||||
id: poeditor
|
||||
env:
|
||||
|
||||
236
.github/workflows/validate-translations.sh
vendored
Executable file
236
.github/workflows/validate-translations.sh
vendored
Executable file
@@ -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
|
||||
@@ -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
|
||||
@@ -31,7 +31,9 @@ ARG TARGETPLATFORM
|
||||
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 <<EOT
|
||||
apk add --no-cache wget
|
||||
PLATFORM=$(echo ${TARGETPLATFORM} | tr '/' '-')
|
||||
FILE=taglib-${PLATFORM}.tar.gz
|
||||
|
||||
@@ -61,7 +63,7 @@ COPY --from=ui /build /build
|
||||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.24-bookworm AS base
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-bookworm AS base
|
||||
RUN apt-get update && apt-get install -y clang lld
|
||||
COPY --from=xx / /
|
||||
WORKDIR /workspace
|
||||
|
||||
47
Makefile
47
Makefile
@@ -16,6 +16,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||
CROSS_TAGLIB_VERSION ?= 2.1.1-1
|
||||
GOLANGCI_LINT_VERSION ?= v2.5.0
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
@@ -32,25 +33,55 @@ server: check_go_env buildjs ##@Development Start the backend in development mod
|
||||
@ND_ENABLEINSIGHTSCOLLECTOR="false" go tool reflex -d none -c reflex.conf
|
||||
.PHONY: server
|
||||
|
||||
stop: ##@Development Stop development servers (UI and backend)
|
||||
@echo "Stopping development servers..."
|
||||
@-pkill -f "vite"
|
||||
@-pkill -f "go tool reflex.*reflex.conf"
|
||||
@-pkill -f "go run.*netgo"
|
||||
@echo "Development servers stopped."
|
||||
.PHONY: stop
|
||||
|
||||
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||
go tool ginkgo watch -tags=netgo -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
PKG ?= ./...
|
||||
test: ##@Development Run Go tests
|
||||
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
|
||||
go test -tags netgo $(PKG)
|
||||
.PHONY: test
|
||||
|
||||
testrace: ##@Development Run Go tests with race detector
|
||||
go test -tags netgo -race -shuffle=on ./...
|
||||
.PHONY: test
|
||||
|
||||
testall: testrace ##@Development Run Go and JS tests
|
||||
@(cd ./ui && npm run test)
|
||||
testall: test-race test-i18n test-js ##@Development Run Go and JS tests
|
||||
.PHONY: testall
|
||||
|
||||
test-race: ##@Development Run Go tests with race detector
|
||||
go test -tags netgo -race -shuffle=on ./...
|
||||
.PHONY: test-race
|
||||
|
||||
test-js: ##@Development Run JS tests
|
||||
@(cd ./ui && npm run test)
|
||||
.PHONY: test-js
|
||||
|
||||
test-i18n: ##@Development Validate all translations files
|
||||
./.github/workflows/validate-translations.sh
|
||||
.PHONY: test-i18n
|
||||
|
||||
install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||
@PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6)
|
||||
@INSTALL=false; \
|
||||
if PATH=$$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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <typeinfo>
|
||||
|
||||
#define TAGLIB_STATIC
|
||||
#include <apeproperties.h>
|
||||
@@ -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<const TagLib::APE::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", apeProperties->bitsPerSample());
|
||||
if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", asfProperties->bitsPerSample());
|
||||
bitsPerSample = apeProperties->bitsPerSample();
|
||||
else if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) })
|
||||
bitsPerSample = asfProperties->bitsPerSample();
|
||||
else if (const auto* flacProperties{ dynamic_cast<const TagLib::FLAC::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", flacProperties->bitsPerSample());
|
||||
bitsPerSample = flacProperties->bitsPerSample();
|
||||
else if (const auto* mp4Properties{ dynamic_cast<const TagLib::MP4::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", mp4Properties->bitsPerSample());
|
||||
bitsPerSample = mp4Properties->bitsPerSample();
|
||||
else if (const auto* wavePackProperties{ dynamic_cast<const TagLib::WavPack::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", wavePackProperties->bitsPerSample());
|
||||
bitsPerSample = wavePackProperties->bitsPerSample();
|
||||
else if (const auto* aiffProperties{ dynamic_cast<const TagLib::RIFF::AIFF::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", aiffProperties->bitsPerSample());
|
||||
bitsPerSample = aiffProperties->bitsPerSample();
|
||||
else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", wavProperties->bitsPerSample());
|
||||
bitsPerSample = wavProperties->bitsPerSample();
|
||||
else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(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<char*>(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<char*>(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<char*>(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<char*>(item.first.toCString(true));
|
||||
for (const auto value: item.second.toStringList()) {
|
||||
char *val = (char *)value.toCString(true);
|
||||
char *val = const_cast<char*>(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<char*>(item.first.toCString(true));
|
||||
|
||||
for (auto j = item.second.begin();
|
||||
j != item.second.end(); ++j) {
|
||||
|
||||
char *val = const_cast<char*>(j->toString().toCString(true));
|
||||
goPutStr(id, key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send all collected tags to the Go map
|
||||
for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end();
|
||||
++i) {
|
||||
char *key = (char *)i->first.toCString(true);
|
||||
char *key = const_cast<char*>(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<char*>((*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<TagLib::ASF::File *>(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<TagLib::DSF::File *>(f.file())}) {
|
||||
const TagLib::ID3v2::Tag *tag { dsffile->tag() };
|
||||
hasCover = tag && !tag->frameListMap()["APIC"].isEmpty();
|
||||
}
|
||||
// ----- WAVPAK (APE tag)
|
||||
else if (TagLib::WavPack::File * wvFile{dynamic_cast<TagLib::WavPack::File *>(f.file())}) {
|
||||
if (wvFile->hasAPETag()) {
|
||||
// This is the particular string that Picard uses
|
||||
hasCover = !wvFile->APETag()->itemListMap()["COVER ART (FRONT)"].isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
return hasCover;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -47,18 +47,32 @@ 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()
|
||||
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, scannerScanner)
|
||||
library := core.NewLibrary(dataStore, scannerScanner, watcher, broker)
|
||||
router := nativeapi.New(dataStore, share, playlists, insights, library)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -122,7 +136,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
|
||||
}
|
||||
|
||||
@@ -164,7 +180,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
watcher := scanner.NewWatcher(dataStore, scannerScanner)
|
||||
watcher := scanner.GetWatcher(dataStore, scannerScanner)
|
||||
return watcher
|
||||
}
|
||||
|
||||
@@ -175,7 +191,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 +201,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.Scanner), new(scanner.Scanner)), 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
|
||||
|
||||
@@ -38,12 +38,15 @@ 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.Scanner), new(scanner.Scanner)),
|
||||
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
|
||||
)
|
||||
|
||||
func CreateDataStore() model.DataStore {
|
||||
@@ -58,7 +61,7 @@ func CreateServer() *server.Server {
|
||||
))
|
||||
}
|
||||
|
||||
func CreateNativeAPIRouter() *nativeapi.Router {
|
||||
func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
@@ -118,13 +121,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
|
||||
|
||||
@@ -127,6 +127,7 @@ type configOptions struct {
|
||||
DevScannerThreads uint
|
||||
DevInsightsInitialDelay time.Duration
|
||||
DevEnablePlayerInsights bool
|
||||
DevEnablePluginsInsights bool
|
||||
DevPluginCompilationTimeout time.Duration
|
||||
DevExternalArtistFetchMultiplier float64
|
||||
}
|
||||
@@ -264,13 +265,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()
|
||||
@@ -599,6 +602,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devscannerthreads", 5)
|
||||
viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay)
|
||||
viper.SetDefault("devenableplayerinsights", true)
|
||||
viper.SetDefault("devenablepluginsinsights", true)
|
||||
viper.SetDefault("devplugincompilationtimeout", time.Minute)
|
||||
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
@@ -214,3 +215,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
412
core/library.go
Normal file
412
core/library.go
Normal file
@@ -0,0 +1,412 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// Scanner interface for triggering scans
|
||||
type Scanner interface {
|
||||
ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
|
||||
}
|
||||
|
||||
// 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 Scanner
|
||||
watcher Watcher
|
||||
broker events.Broker
|
||||
}
|
||||
|
||||
// NewLibrary creates a new Library service
|
||||
func NewLibrary(ds model.DataStore, scanner 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 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{}, cols ...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))
|
||||
}
|
||||
}
|
||||
980
core/library_test.go
Normal file
980
core/library_test.go
Normal file
@@ -0,0 +1,980 @@
|
||||
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 *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 = &mockScanner{}
|
||||
// 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.len()
|
||||
}, "1s", "10ms").Should(Equal(1))
|
||||
|
||||
// Verify scan was called with correct parameters
|
||||
Expect(scanner.ScanCalls[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.len()
|
||||
}, "1s", "10ms").Should(Equal(1))
|
||||
|
||||
// Verify scan was called with correct parameters
|
||||
Expect(scanner.ScanCalls[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.len()
|
||||
}, "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.len()
|
||||
}, "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.len()
|
||||
}, "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.len()
|
||||
}, "1s", "10ms").Should(Equal(1))
|
||||
|
||||
// Verify scan was called with correct parameters
|
||||
Expect(scanner.ScanCalls[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.len()
|
||||
}, "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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// mockScanner provides a simple mock implementation of core.Scanner for testing
|
||||
type mockScanner struct {
|
||||
ScanCalls []ScanCall
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type ScanCall struct {
|
||||
FullScan bool
|
||||
}
|
||||
|
||||
func (m *mockScanner) ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.ScanCalls = append(m.ScanCalls, ScanCall{
|
||||
FullScan: fullScan,
|
||||
})
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
func (m *mockScanner) len() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.ScanCalls)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 <basePath-without-ext><suffix>, 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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/metrics/insights"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
@@ -34,12 +35,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,7 +58,7 @@ func GetInstance(ds model.DataStore) Insights {
|
||||
}
|
||||
}
|
||||
insightsID = id
|
||||
return &insightsCollector{ds: ds}
|
||||
return &insightsCollector{ds: ds, pluginLoader: pluginLoader}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -180,10 +187,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 +207,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.ReverseProxyWhitelist != ""
|
||||
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
|
||||
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
|
||||
|
||||
return data
|
||||
})
|
||||
@@ -233,12 +244,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 +292,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
|
||||
}
|
||||
|
||||
@@ -36,6 +36,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 +56,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 +71,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 {
|
||||
|
||||
46
core/mock_library_service.go
Normal file
46
core/mock_library_service.go
Normal file
@@ -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)
|
||||
@@ -372,7 +372,7 @@ goto loop
|
||||
`
|
||||
} else {
|
||||
scriptExt = ".sh"
|
||||
scriptContent = `#!/bin/bash
|
||||
scriptContent = `#!/bin/sh
|
||||
echo "$0"
|
||||
for arg in "$@"; do
|
||||
echo "$arg"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -40,7 +40,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)
|
||||
}
|
||||
|
||||
@@ -74,8 +74,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
|
||||
}
|
||||
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()})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,23 +137,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,8 +194,7 @@ 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 {
|
||||
|
||||
@@ -429,6 +429,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()
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
428
core/storage/local/local_test.go
Normal file
428
core/storage/local/local_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ var Set = wire.NewSet(
|
||||
NewPlayers,
|
||||
NewShare,
|
||||
NewPlaylists,
|
||||
NewLibrary,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||
|
||||
119
db/migrations/20250701010108_add_multi_library_support.go
Normal file
119
db/migrations/20250701010108_add_multi_library_support.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
90
go.mod
90
go.mod
@@ -1,15 +1,19 @@
|
||||
module github.com/navidrome/navidrome
|
||||
|
||||
go 1.24.4
|
||||
go 1.25.4
|
||||
|
||||
// Fork to fix https://github.com/navidrome/navidrome/pull/3254
|
||||
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
replace (
|
||||
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
// Using version from main that fixes https://github.com/navidrome/navidrome/issues/4396
|
||||
github.com/tetratelabs/wazero v1.9.0 => github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684
|
||||
)
|
||||
|
||||
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 +26,58 @@ 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/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/spf13/cobra v1.10.1
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tetratelabs/wazero v1.9.0
|
||||
github.com/unrolled/secure v1.17.0
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||
go.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-20251023183803-a4bb9ffd2546
|
||||
golang.org/x/image v0.32.0
|
||||
golang.org/x/net v0.46.0
|
||||
golang.org/x/sync v0.17.0
|
||||
golang.org/x/sys v0.37.0
|
||||
golang.org/x/text v0.30.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 +89,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-20251007162407-5df77e3f7d1d // 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 +113,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.43.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
)
|
||||
|
||||
211
go.sum
211
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-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
|
||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/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.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
|
||||
github.com/maruel/natural v1.1.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 v0.0.0-20251106165119-514cdb337684 h1:ugT1JTRsK1Jhn95BWilCugyZ1Svsyxm9xSiflOa2e7E=
|
||||
github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684/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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
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.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
||||
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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.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-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
|
||||
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
|
||||
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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -32,7 +32,6 @@ var fieldMap = map[string]*mappedField{
|
||||
"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"},
|
||||
@@ -53,6 +52,10 @@ var fieldMap = map[string]*mappedField{
|
||||
"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
|
||||
@@ -153,13 +156,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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,11 +14,15 @@ 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 {
|
||||
func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string {
|
||||
releaseDate := legacyReleaseDate(md)
|
||||
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", legacyMapAlbumArtistName(md), legacyMapAlbumName(md)))
|
||||
if !conf.Server.Scanner.GroupAlbumReleases {
|
||||
@@ -26,6 +30,9 @@ func legacyAlbumID(md Metadata) string {
|
||||
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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
12
model/tag.go
12
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
83
model/user_test.go
Normal file
83
model/user_test.go
Normal file
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -123,6 +123,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() {
|
||||
@@ -184,9 +185,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) {
|
||||
@@ -216,8 +218,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) {
|
||||
@@ -291,7 +295,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"),
|
||||
@@ -346,15 +349,15 @@ 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
|
||||
if uuid.Validate(q) == nil {
|
||||
err := r.searchByMBID(r.selectAlbum(), q, []string{"mbz_album_id", "mbz_release_group_id"}, includeMissing, &res)
|
||||
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(), q, offset, size, includeMissing, &res, "name")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -27,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 {
|
||||
@@ -38,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
|
||||
@@ -113,11 +131,12 @@ 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, "mbz_artist_id"),
|
||||
"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",
|
||||
@@ -127,9 +146,9 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
"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
|
||||
}
|
||||
@@ -137,26 +156,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 {
|
||||
@@ -213,8 +266,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 {
|
||||
@@ -229,10 +289,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})
|
||||
@@ -299,6 +368,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 {
|
||||
@@ -327,27 +397,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,
|
||||
@@ -355,40 +421,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
|
||||
@@ -429,19 +498,30 @@ 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) {
|
||||
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(), q, []string{"mbz_artist_id"}, includeMissing, &res)
|
||||
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 {
|
||||
err := r.doSearch(r.selectArtist(), q, offset, size, includeMissing, &res, "json_extract(stats, '$.total.m') desc", "name")
|
||||
// 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)
|
||||
}
|
||||
@@ -464,9 +544,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...))
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,8 +61,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,8 +86,9 @@ 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) {
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
329
persistence/genre_repository_test.go
Normal file
329
persistence/genre_repository_test.go
Normal file
@@ -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{}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -150,6 +190,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 +221,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 +237,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 +272,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)
|
||||
|
||||
@@ -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,6 @@ 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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -96,6 +96,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
|
||||
"genre_id": tagIDFilter,
|
||||
"missing": booleanFilter,
|
||||
"artists_id": artistFilter,
|
||||
"library_id": libraryIdFilter,
|
||||
}
|
||||
// Add all album tags as filters
|
||||
for tag := range model.TagMappings() {
|
||||
@@ -116,6 +117,7 @@ func mediaFileRecentlyAddedSort() string {
|
||||
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...)
|
||||
}
|
||||
|
||||
@@ -134,10 +136,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) {
|
||||
@@ -273,7 +276,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{
|
||||
@@ -294,15 +297,57 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Search(q string, offset int, size int, includeMissing bool) (model.MediaFiles, error) {
|
||||
// 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 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(), q, []string{"mbz_recording_id", "mbz_release_track_id"}, includeMissing, &res)
|
||||
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(), q, offset, size, includeMissing, &res, "title")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -38,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() {
|
||||
@@ -314,7 +314,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
Describe("Search", func() {
|
||||
Context("text search", func() {
|
||||
It("finds media files by title", func() {
|
||||
results, err := mr.Search("Antenna", 0, 10, false)
|
||||
results, err := mr.Search("Antenna", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2
|
||||
for _, result := range results {
|
||||
@@ -323,7 +323,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("finds media files case insensitively", func() {
|
||||
results, err := mr.Search("antenna", 0, 10, false)
|
||||
results, err := mr.Search("antenna", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(3))
|
||||
for _, result := range results {
|
||||
@@ -332,7 +332,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("returns empty result when no matches found", func() {
|
||||
results, err := mr.Search("nonexistent", 0, 10, false)
|
||||
results, err := mr.Search("nonexistent", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(BeEmpty())
|
||||
})
|
||||
@@ -365,7 +365,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("finds media file by mbz_recording_id", func() {
|
||||
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", 0, 10, false)
|
||||
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"))
|
||||
@@ -373,7 +373,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("finds media file by mbz_release_track_id", func() {
|
||||
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", 0, 10, false)
|
||||
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"))
|
||||
@@ -381,12 +381,12 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("returns empty result when MBID is not found", func() {
|
||||
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10, false)
|
||||
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("handles includeMissing parameter for MBID search", func() {
|
||||
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",
|
||||
@@ -400,17 +400,11 @@ var _ = Describe("MediaRepository", func() {
|
||||
err := mr.Put(&missingMediaFile)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Should not find missing media file when includeMissing is false
|
||||
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10, false)
|
||||
// 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())
|
||||
|
||||
// Should find missing media file when includeMissing is true
|
||||
results, err = mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].ID).To(Equal("test-missing-mbid-mediafile"))
|
||||
|
||||
// Clean up
|
||||
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID}))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 @@ 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 + "')")
|
||||
sq = r.addCriteria(sq, rules)
|
||||
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
|
||||
_, err = r.executeSQL(insSql)
|
||||
@@ -379,6 +379,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",
|
||||
@@ -389,11 +391,12 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
|
||||
"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})
|
||||
|
||||
@@ -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,37 @@ 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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,11 +85,12 @@ 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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
133
persistence/share_repository_test.go
Normal file
133
persistence/share_repository_test.go
Normal file
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,15 +15,14 @@ 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",
|
||||
@@ -42,8 +41,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 +56,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)
|
||||
@@ -86,8 +87,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ 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 {
|
||||
@@ -27,23 +31,18 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, in
|
||||
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, includeMissing bool, results any) error {
|
||||
func (r sqlRepository) searchByMBID(sq SelectBuilder, mbid string, mbidFields []string, results any) error {
|
||||
sq = sq.Where(mbidExpr(r.tableName, mbid, mbidFields...))
|
||||
|
||||
if !includeMissing {
|
||||
sq = sq.Where(Eq{r.tableName + ".missing": false})
|
||||
}
|
||||
sq = sq.Where(Eq{r.tableName + ".missing": false})
|
||||
|
||||
return r.queryAll(sq, results)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
259
persistence/tag_library_filtering_test.go
Normal file
259
persistence/tag_library_filtering_test.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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())
|
||||
|
||||
// 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
|
||||
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
|
||||
Bind(dbx.Params{"id": libraryID2, "name": "Library 2", "path": "/music/lib2"}).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", "path": "/music/lib3"}).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})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,6 +78,11 @@ 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')
|
||||
`)
|
||||
@@ -87,30 +96,4 @@ func (r *tagRepository) purgeUnused() error {
|
||||
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{}
|
||||
|
||||
311
persistence/tag_repository_test.go
Normal file
311
persistence/tag_repository_test.go
Normal file
@@ -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{}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user