Merge branch 'master' into msi-insights-detection

This commit is contained in:
Rob Emery 2025-11-07 07:36:13 +00:00 committed by GitHub
commit 70bc45c944
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
228 changed files with 19402 additions and 3310 deletions

View File

@ -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": "",

View File

@ -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

View File

@ -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
@ -191,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
@ -217,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
@ -248,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 }}
@ -264,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-*
@ -318,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*
@ -339,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
@ -352,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-*
@ -383,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*
@ -406,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 }}

View File

@ -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:

2
.nvmrc
View File

@ -1 +1 @@
v20
v24

View File

@ -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

View File

@ -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,12 +33,20 @@ 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
@ -57,7 +66,22 @@ test-i18n: ##@Development Validate all translations files
.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

View File

@ -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".

View File

@ -45,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)

View File

@ -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 {

View File

@ -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
}
@ -185,7 +201,7 @@ 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 {
manager := getPluginManager()

View File

@ -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(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,
))

View File

@ -127,6 +127,7 @@ type configOptions struct {
DevScannerThreads uint
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
}
@ -601,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)
}

View File

@ -17,7 +17,7 @@ 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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
View 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
View 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)
}

View File

@ -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

View File

@ -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"))
})
})
})

View File

@ -22,6 +22,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"
)
@ -35,12 +36,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 {
@ -52,7 +59,7 @@ func GetInstance(ds model.DataStore) Insights {
}
}
insightsID = id
return &insightsCollector{ds: ds}
return &insightsCollector{ds: ds, pluginLoader: pluginLoader}
})
}
@ -188,10 +195,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
@ -207,6 +215,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
})
@ -241,12 +252,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)},
@ -272,3 +300,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
}

View File

@ -37,6 +37,7 @@ type Data struct {
Playlists int64 `json:"playlists"`
Shares int64 `json:"shares"`
Radios int64 `json:"radios"`
Libraries int64 `json:"libraries"`
ActiveUsers int64 `json:"activeUsers"`
ActivePlayers map[string]int64 `json:"activePlayers,omitempty"`
} `json:"library"`
@ -56,6 +57,7 @@ type Data struct {
EnableStarRating bool `json:"enableStarRating,omitempty"`
EnableLastFM bool `json:"enableLastFM,omitempty"`
EnableListenBrainz bool `json:"enableListenBrainz,omitempty"`
EnableDeezer bool `json:"enableDeezer,omitempty"`
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
EnableSpotify bool `json:"enableSpotify,omitempty"`
EnableJukebox bool `json:"enableJukebox,omitempty"`
@ -70,7 +72,17 @@ type Data struct {
BackupCount int `json:"backupCount,omitempty"`
DevActivityPanel bool `json:"devActivityPanel,omitempty"`
DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"`
HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"`
ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"`
HasCustomPID bool `json:"hasCustomPID,omitempty"`
HasCustomTags bool `json:"hasCustomTags,omitempty"`
} `json:"config"`
Plugins map[string]PluginInfo `json:"plugins,omitempty"`
}
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
type FSInfo struct {

View 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)

View File

@ -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)

View File

@ -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() {

View File

@ -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()})
})
}
@ -195,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 {

View File

@ -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()

View File

@ -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 ""

View File

@ -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() {

View File

@ -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")
}

View 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
}

View File

@ -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)

View File

@ -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&amp/file.mp3"),
Entry("multiple special chars", "/tmp/Song #1 & More?.mp3"),
)
})
})

View File

@ -17,6 +17,7 @@ var Set = wire.NewSet(
NewPlayers,
NewShare,
NewPlaylists,
NewLibrary,
agents.GetAgents,
external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)),

View 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
}

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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

View File

@ -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)

View File

@ -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 {

View File

@ -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"

View File

@ -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
}

View File

@ -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() {

View File

@ -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")
)

View File

@ -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"`

View File

@ -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

View File

@ -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

View File

@ -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)))
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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))
})
})
})
})

View File

@ -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 {

View File

@ -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)
}

View File

@ -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
}

View File

@ -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

View File

@ -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
View 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())
})
})
})
})

View File

@ -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)
}

View File

@ -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 {

View File

@ -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

View File

@ -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) {

View File

@ -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{}
}

View 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{}))
})
})
})

View File

@ -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)

View File

@ -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))
})
})

View File

@ -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)
}

View File

@ -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}))
})

View File

@ -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

View File

@ -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})

View File

@ -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
})
})
})

View File

@ -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",

View File

@ -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
}

View 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())
})
})
})

View File

@ -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

View File

@ -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)
}

View File

@ -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"))
})
})
})
})

View File

@ -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},
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)

View 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(&regularUser)
// 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(&regularUser, 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(&regularUser, 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(&regularUser, 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})
})
})
})
})

View File

@ -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{}

View 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{}))
})
})
})

View File

@ -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})

View File

@ -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)

View File

@ -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(&regularUser)
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(&regularUser)
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(&regularUser)
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}"))
})
})
})

View File

@ -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` |

View File

@ -26,6 +26,12 @@ var _ = Describe("Adapter Media Agent", func() {
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() {

View File

@ -903,7 +903,6 @@ type InitRequest struct {
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Empty for now
Config map[string]string `protobuf:"bytes,1,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` // Configuration specific to this plugin
}

View File

@ -194,7 +194,6 @@ service LifecycleManagement {
}
message InitRequest {
// Empty for now
map<string, string> config = 1; // Configuration specific to this plugin
}

View File

@ -88,3 +88,7 @@ func (n *namedSchedulerService) CancelSchedule(ctx context.Context, request *sch
request.ScheduleId = key
return n.svc.CancelSchedule(ctx, request)
}
func (n *namedSchedulerService) TimeNow(ctx context.Context, request *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) {
return n.svc.TimeNow(ctx, request)
}

View File

@ -59,7 +59,7 @@ clientID, users, err := d.getConfig(ctx)
Add the following to `navidrome.toml` and adjust for your tokens:
```toml
[PluginSettings.discord-rich-presence]
[PluginConfig.discord-rich-presence]
ClientID = "123456789012345678"
Users = "alice:token123,bob:token456"
```

View File

@ -154,6 +154,51 @@ func (x *CancelResponse) GetError() string {
return ""
}
type TimeNowRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
func (x *TimeNowRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
type TimeNowResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Rfc3339Nano string `protobuf:"bytes,1,opt,name=rfc3339_nano,json=rfc3339Nano,proto3" json:"rfc3339_nano,omitempty"` // Current time in RFC3339Nano format
UnixMilli int64 `protobuf:"varint,2,opt,name=unix_milli,json=unixMilli,proto3" json:"unix_milli,omitempty"` // Current time as Unix milliseconds timestamp
LocalTimeZone string `protobuf:"bytes,3,opt,name=local_time_zone,json=localTimeZone,proto3" json:"local_time_zone,omitempty"` // Local timezone name (e.g., "America/New_York", "UTC")
}
func (x *TimeNowResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *TimeNowResponse) GetRfc3339Nano() string {
if x != nil {
return x.Rfc3339Nano
}
return ""
}
func (x *TimeNowResponse) GetUnixMilli() int64 {
if x != nil {
return x.UnixMilli
}
return 0
}
func (x *TimeNowResponse) GetLocalTimeZone() string {
if x != nil {
return x.LocalTimeZone
}
return ""
}
// go:plugin type=host version=1
type SchedulerService interface {
// One-time event scheduling
@ -162,4 +207,6 @@ type SchedulerService interface {
ScheduleRecurring(context.Context, *ScheduleRecurringRequest) (*ScheduleResponse, error)
// Cancel any scheduled job
CancelSchedule(context.Context, *CancelRequest) (*CancelResponse, error)
// Get current time in multiple formats
TimeNow(context.Context, *TimeNowRequest) (*TimeNowResponse, error)
}

View File

@ -14,6 +14,9 @@ service SchedulerService {
// Cancel any scheduled job
rpc CancelSchedule(CancelRequest) returns (CancelResponse);
// Get current time in multiple formats
rpc TimeNow(TimeNowRequest) returns (TimeNowResponse);
}
message ScheduleOneTimeRequest {
@ -39,4 +42,14 @@ message CancelRequest {
message CancelResponse {
bool success = 1; // Whether cancellation was successful
string error = 2; // Error message if cancellation failed
}
message TimeNowRequest {
// Empty request - no parameters needed
}
message TimeNowResponse {
string rfc3339_nano = 1; // Current time in RFC3339Nano format
int64 unix_milli = 2; // Current time as Unix milliseconds timestamp
string local_time_zone = 3; // Local timezone name (e.g., "America/New_York", "UTC")
}

View File

@ -44,6 +44,11 @@ func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions SchedulerS
WithParameterNames("offset", "size").
Export("cancel_schedule")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._TimeNow), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("time_now")
_, err := envBuilder.Instantiate(ctx)
return err
}
@ -134,3 +139,32 @@ func (h _schedulerService) _CancelSchedule(ctx context.Context, m api.Module, st
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Get current time in multiple formats
func (h _schedulerService) _TimeNow(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(TimeNowRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.TimeNow(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}

View File

@ -88,3 +88,26 @@ func (h schedulerService) CancelSchedule(ctx context.Context, request *CancelReq
}
return response, nil
}
//go:wasmimport env time_now
func _time_now(ptr uint32, size uint32) uint64
func (h schedulerService) TimeNow(ctx context.Context, request *TimeNowRequest) (*TimeNowResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _time_now(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(TimeNowResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}

View File

@ -256,6 +256,91 @@ func (m *CancelResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
return len(dAtA) - i, nil
}
func (m *TimeNowRequest) MarshalVT() (dAtA []byte, err error) {
if m == nil {
return nil, nil
}
size := m.SizeVT()
dAtA = make([]byte, size)
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
if err != nil {
return nil, err
}
return dAtA[:n], nil
}
func (m *TimeNowRequest) MarshalToVT(dAtA []byte) (int, error) {
size := m.SizeVT()
return m.MarshalToSizedBufferVT(dAtA[:size])
}
func (m *TimeNowRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
if m == nil {
return 0, nil
}
i := len(dAtA)
_ = i
var l int
_ = l
if m.unknownFields != nil {
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
return len(dAtA) - i, nil
}
func (m *TimeNowResponse) MarshalVT() (dAtA []byte, err error) {
if m == nil {
return nil, nil
}
size := m.SizeVT()
dAtA = make([]byte, size)
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
if err != nil {
return nil, err
}
return dAtA[:n], nil
}
func (m *TimeNowResponse) MarshalToVT(dAtA []byte) (int, error) {
size := m.SizeVT()
return m.MarshalToSizedBufferVT(dAtA[:size])
}
func (m *TimeNowResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
if m == nil {
return 0, nil
}
i := len(dAtA)
_ = i
var l int
_ = l
if m.unknownFields != nil {
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
if len(m.LocalTimeZone) > 0 {
i -= len(m.LocalTimeZone)
copy(dAtA[i:], m.LocalTimeZone)
i = encodeVarint(dAtA, i, uint64(len(m.LocalTimeZone)))
i--
dAtA[i] = 0x1a
}
if m.UnixMilli != 0 {
i = encodeVarint(dAtA, i, uint64(m.UnixMilli))
i--
dAtA[i] = 0x10
}
if len(m.Rfc3339Nano) > 0 {
i -= len(m.Rfc3339Nano)
copy(dAtA[i:], m.Rfc3339Nano)
i = encodeVarint(dAtA, i, uint64(len(m.Rfc3339Nano)))
i--
dAtA[i] = 0xa
}
return len(dAtA) - i, nil
}
func encodeVarint(dAtA []byte, offset int, v uint64) int {
offset -= sov(v)
base := offset
@ -355,6 +440,37 @@ func (m *CancelResponse) SizeVT() (n int) {
return n
}
func (m *TimeNowRequest) SizeVT() (n int) {
if m == nil {
return 0
}
var l int
_ = l
n += len(m.unknownFields)
return n
}
func (m *TimeNowResponse) SizeVT() (n int) {
if m == nil {
return 0
}
var l int
_ = l
l = len(m.Rfc3339Nano)
if l > 0 {
n += 1 + l + sov(uint64(l))
}
if m.UnixMilli != 0 {
n += 1 + sov(uint64(m.UnixMilli))
}
l = len(m.LocalTimeZone)
if l > 0 {
n += 1 + l + sov(uint64(l))
}
n += len(m.unknownFields)
return n
}
func sov(x uint64) (n int) {
return (bits.Len64(x|1) + 6) / 7
}
@ -915,6 +1031,191 @@ func (m *CancelResponse) UnmarshalVT(dAtA []byte) error {
}
return nil
}
func (m *TimeNowRequest) UnmarshalVT(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
preIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
wireType := int(wire & 0x7)
if wireType == 4 {
return fmt.Errorf("proto: TimeNowRequest: wiretype end group for non-group")
}
if fieldNum <= 0 {
return fmt.Errorf("proto: TimeNowRequest: illegal tag %d (wire type %d)", fieldNum, wire)
}
switch fieldNum {
default:
iNdEx = preIndex
skippy, err := skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLength
}
if (iNdEx + skippy) > l {
return io.ErrUnexpectedEOF
}
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
iNdEx += skippy
}
}
if iNdEx > l {
return io.ErrUnexpectedEOF
}
return nil
}
func (m *TimeNowResponse) UnmarshalVT(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
preIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
wireType := int(wire & 0x7)
if wireType == 4 {
return fmt.Errorf("proto: TimeNowResponse: wiretype end group for non-group")
}
if fieldNum <= 0 {
return fmt.Errorf("proto: TimeNowResponse: illegal tag %d (wire type %d)", fieldNum, wire)
}
switch fieldNum {
case 1:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field Rfc3339Nano", wireType)
}
var stringLen uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLen |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLen := int(stringLen)
if intStringLen < 0 {
return ErrInvalidLength
}
postIndex := iNdEx + intStringLen
if postIndex < 0 {
return ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
m.Rfc3339Nano = string(dAtA[iNdEx:postIndex])
iNdEx = postIndex
case 2:
if wireType != 0 {
return fmt.Errorf("proto: wrong wireType = %d for field UnixMilli", wireType)
}
m.UnixMilli = 0
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
m.UnixMilli |= int64(b&0x7F) << shift
if b < 0x80 {
break
}
}
case 3:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field LocalTimeZone", wireType)
}
var stringLen uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLen |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLen := int(stringLen)
if intStringLen < 0 {
return ErrInvalidLength
}
postIndex := iNdEx + intStringLen
if postIndex < 0 {
return ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
m.LocalTimeZone = string(dAtA[iNdEx:postIndex])
iNdEx = postIndex
default:
iNdEx = preIndex
skippy, err := skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLength
}
if (iNdEx + skippy) > l {
return io.ErrUnexpectedEOF
}
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
iNdEx += skippy
}
}
if iNdEx > l {
return io.ErrUnexpectedEOF
}
return nil
}
func skip(dAtA []byte) (n int, err error) {
l := len(dAtA)

Some files were not shown because too many files have changed in this diff Show More