mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Merge branch 'master' into fork/SoraKasvgano/master
This commit is contained in:
commit
e6220d8d0d
@ -23,5 +23,7 @@ RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
|
|||||||
&& rmdir /usr/include/taglib \
|
&& rmdir /usr/include/taglib \
|
||||||
&& rm /tmp/cross-taglib.tar.gz /usr/provenance.json
|
&& rm /tmp/cross-taglib.tar.gz /usr/provenance.json
|
||||||
|
|
||||||
|
ENV CGO_CFLAGS_ALLOW="--define-prefix"
|
||||||
|
|
||||||
# [Optional] Uncomment this line to install global node packages.
|
# [Optional] Uncomment this line to install global node packages.
|
||||||
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||||
|
|||||||
38
.github/workflows/pipeline.yml
vendored
38
.github/workflows/pipeline.yml
vendored
@ -15,6 +15,7 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CROSS_TAGLIB_VERSION: "2.1.1-1"
|
CROSS_TAGLIB_VERSION: "2.1.1-1"
|
||||||
|
CGO_CFLAGS_ALLOW: "--define-prefix"
|
||||||
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@ -88,6 +89,16 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Run go generate
|
||||||
|
run: go generate ./...
|
||||||
|
- name: Verify no changes from go generate
|
||||||
|
run: |
|
||||||
|
git status --porcelain
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo 'Generated code is out of date. Run "make gen" and commit the changes'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
go:
|
go:
|
||||||
name: Test Go code
|
name: Test Go code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -108,6 +119,13 @@ jobs:
|
|||||||
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
||||||
go test -shuffle=on -tags netgo -race ./... -v
|
go test -shuffle=on -tags netgo -race ./... -v
|
||||||
|
|
||||||
|
- name: Test ndpgen
|
||||||
|
run: |
|
||||||
|
cd plugins/cmd/ndpgen
|
||||||
|
go test -shuffle=on -v
|
||||||
|
go build -o ndpgen .
|
||||||
|
./ndpgen --help
|
||||||
|
|
||||||
js:
|
js:
|
||||||
name: Test JS code
|
name: Test JS code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -217,7 +235,7 @@ jobs:
|
|||||||
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||||
|
|
||||||
- name: Upload Binaries
|
- name: Upload Binaries
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: navidrome-${{ env.PLATFORM }}
|
name: navidrome-${{ env.PLATFORM }}
|
||||||
path: ./output
|
path: ./output
|
||||||
@ -248,7 +266,7 @@ jobs:
|
|||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v6
|
||||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM }}
|
name: digests-${{ env.PLATFORM }}
|
||||||
@ -270,7 +288,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
@ -304,7 +322,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
@ -356,7 +374,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/download-artifact@v6
|
- uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
path: ./binaries
|
path: ./binaries
|
||||||
pattern: navidrome-windows*
|
pattern: navidrome-windows*
|
||||||
@ -375,7 +393,7 @@ jobs:
|
|||||||
du -h binaries/msi/*.msi
|
du -h binaries/msi/*.msi
|
||||||
|
|
||||||
- name: Upload MSI files
|
- name: Upload MSI files
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: navidrome-windows-installers
|
name: navidrome-windows-installers
|
||||||
path: binaries/msi/*.msi
|
path: binaries/msi/*.msi
|
||||||
@ -393,7 +411,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
|
|
||||||
- uses: actions/download-artifact@v6
|
- uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
path: ./binaries
|
path: ./binaries
|
||||||
pattern: navidrome-*
|
pattern: navidrome-*
|
||||||
@ -419,7 +437,7 @@ jobs:
|
|||||||
rm ./dist/*.tar.gz ./dist/*.zip
|
rm ./dist/*.tar.gz ./dist/*.zip
|
||||||
|
|
||||||
- name: Upload all-packages artifact
|
- name: Upload all-packages artifact
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: packages
|
name: packages
|
||||||
path: dist/navidrome_0*
|
path: dist/navidrome_0*
|
||||||
@ -442,13 +460,13 @@ jobs:
|
|||||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download all-packages artifact
|
- name: Download all-packages artifact
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: packages
|
name: packages
|
||||||
path: ./dist
|
path: ./dist
|
||||||
|
|
||||||
- name: Upload all-packages artifact
|
- name: Upload all-packages artifact
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: navidrome_linux_${{ matrix.item }}
|
name: navidrome_linux_${{ matrix.item }}
|
||||||
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
||||||
|
|||||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -12,7 +12,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/lock-threads@v5
|
- uses: dessant/lock-threads@v6
|
||||||
with:
|
with:
|
||||||
process-only: 'issues, prs'
|
process-only: 'issues, prs'
|
||||||
issue-inactive-days: 120
|
issue-inactive-days: 120
|
||||||
|
|||||||
2
.github/workflows/update-translations.yml
vendored
2
.github/workflows/update-translations.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
|||||||
git status --porcelain
|
git status --porcelain
|
||||||
git diff
|
git diff
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v7
|
uses: peter-evans/create-pull-request@v8
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PAT }}
|
token: ${{ secrets.PAT }}
|
||||||
author: "navidrome-bot <navidrome-bot@navidrome.org>"
|
author: "navidrome-bot <navidrome-bot@navidrome.org>"
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -17,6 +17,7 @@ master.zip
|
|||||||
testDB
|
testDB
|
||||||
cache/*
|
cache/*
|
||||||
*.swp
|
*.swp
|
||||||
|
coverage.out
|
||||||
dist
|
dist
|
||||||
music
|
music
|
||||||
*.db*
|
*.db*
|
||||||
@ -25,6 +26,7 @@ docker-compose.yml
|
|||||||
!contrib/docker-compose.yml
|
!contrib/docker-compose.yml
|
||||||
binaries
|
binaries
|
||||||
navidrome-*
|
navidrome-*
|
||||||
|
/ndpgen
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
.github/prompts
|
.github/prompts
|
||||||
.github/instructions
|
.github/instructions
|
||||||
@ -32,4 +34,6 @@ AGENTS.md
|
|||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.wasm
|
*.wasm
|
||||||
openspec/
|
*.ndp
|
||||||
|
openspec/
|
||||||
|
go.work*
|
||||||
@ -94,6 +94,7 @@ RUN --mount=type=bind,source=. \
|
|||||||
# Setup CGO cross-compilation environment
|
# Setup CGO cross-compilation environment
|
||||||
xx-go --wrap
|
xx-go --wrap
|
||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
|
export CGO_CFLAGS_ALLOW="--define-prefix"
|
||||||
export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
|
export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
|
||||||
cat $(go env GOENV)
|
cat $(go env GOENV)
|
||||||
|
|
||||||
|
|||||||
45
Makefile
45
Makefile
@ -1,6 +1,10 @@
|
|||||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||||
NODE_VERSION=$(shell cat .nvmrc)
|
NODE_VERSION=$(shell cat .nvmrc)
|
||||||
|
|
||||||
|
# Set global environment variables, required for most targets
|
||||||
|
export CGO_CFLAGS_ALLOW=--define-prefix
|
||||||
|
export ND_ENABLEINSIGHTSCOLLECTOR=false
|
||||||
|
|
||||||
ifneq ("$(wildcard .git/HEAD)","")
|
ifneq ("$(wildcard .git/HEAD)","")
|
||||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||||
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
|
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
|
||||||
@ -16,7 +20,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
|
|||||||
|
|
||||||
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||||
CROSS_TAGLIB_VERSION ?= 2.1.1-1
|
CROSS_TAGLIB_VERSION ?= 2.1.1-1
|
||||||
GOLANGCI_LINT_VERSION ?= v2.6.2
|
GOLANGCI_LINT_VERSION ?= v2.8.0
|
||||||
|
|
||||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||||
|
|
||||||
@ -26,11 +30,11 @@ setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First In
|
|||||||
.PHONY: setup
|
.PHONY: setup
|
||||||
|
|
||||||
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
|
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
|
||||||
ND_ENABLEINSIGHTSCOLLECTOR="false" npx foreman -j Procfile.dev -p 4533 start
|
npx foreman -j Procfile.dev -p 4533 start
|
||||||
.PHONY: dev
|
.PHONY: dev
|
||||||
|
|
||||||
server: check_go_env buildjs ##@Development Start the backend in development mode
|
server: check_go_env buildjs ##@Development Start the backend in development mode
|
||||||
@ND_ENABLEINSIGHTSCOLLECTOR="false" go tool reflex -d none -c reflex.conf
|
go tool reflex -d none -c reflex.conf
|
||||||
.PHONY: server
|
.PHONY: server
|
||||||
|
|
||||||
stop: ##@Development Stop development servers (UI and backend)
|
stop: ##@Development Stop development servers (UI and backend)
|
||||||
@ -50,7 +54,11 @@ test: ##@Development Run Go tests. Use PKG variable to specify packages to test,
|
|||||||
go test -tags netgo $(PKG)
|
go test -tags netgo $(PKG)
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
||||||
testall: test-race test-i18n test-js ##@Development Run Go and JS tests
|
test-ndpgen: ##@Development Run tests for ndpgen plugin
|
||||||
|
cd plugins/cmd/ndpgen && go test ./......
|
||||||
|
.PHONY: test-ndpgen
|
||||||
|
|
||||||
|
testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests
|
||||||
.PHONY: testall
|
.PHONY: testall
|
||||||
|
|
||||||
test-race: ##@Development Run Go tests with race detector
|
test-race: ##@Development Run Go tests with race detector
|
||||||
@ -85,7 +93,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
|||||||
.PHONY: install-golangci-lint
|
.PHONY: install-golangci-lint
|
||||||
|
|
||||||
lint: install-golangci-lint ##@Development Lint Go code
|
lint: install-golangci-lint ##@Development Lint Go code
|
||||||
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
|
PATH=$$PATH:./bin golangci-lint run --timeout 5m
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
||||||
lintall: lint ##@Development Lint Go and JS code
|
lintall: lint ##@Development Lint Go and JS code
|
||||||
@ -103,6 +111,15 @@ wire: check_go_env ##@Development Update Dependency Injection
|
|||||||
go tool wire gen -tags=netgo ./...
|
go tool wire gen -tags=netgo ./...
|
||||||
.PHONY: wire
|
.PHONY: wire
|
||||||
|
|
||||||
|
gen: check_go_env ##@Development Run go generate for code generation
|
||||||
|
go generate ./...
|
||||||
|
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host
|
||||||
|
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -python -rust
|
||||||
|
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust
|
||||||
|
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities
|
||||||
|
go mod tidy -C plugins/pdk/go
|
||||||
|
.PHONY: gen
|
||||||
|
|
||||||
snapshots: ##@Development Update (GoLang) Snapshot tests
|
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||||
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
|
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
|
||||||
.PHONY: snapshots
|
.PHONY: snapshots
|
||||||
@ -266,24 +283,6 @@ deprecated:
|
|||||||
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
|
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
|
||||||
.PHONY: deprecated
|
.PHONY: deprecated
|
||||||
|
|
||||||
# Generate Go code from plugins/api/api.proto
|
|
||||||
plugin-gen: check_go_env ##@Development Generate Go code from plugins protobuf files
|
|
||||||
go generate ./plugins/...
|
|
||||||
.PHONY: plugin-gen
|
|
||||||
|
|
||||||
plugin-examples: check_go_env ##@Development Build all example plugins
|
|
||||||
$(MAKE) -C plugins/examples clean all
|
|
||||||
.PHONY: plugin-examples
|
|
||||||
|
|
||||||
plugin-clean: check_go_env ##@Development Clean all plugins
|
|
||||||
$(MAKE) -C plugins/examples clean
|
|
||||||
$(MAKE) -C plugins/testdata clean
|
|
||||||
.PHONY: plugin-clean
|
|
||||||
|
|
||||||
plugin-tests: check_go_env ##@Development Build all test plugins
|
|
||||||
$(MAKE) -C plugins/testdata clean all
|
|
||||||
.PHONY: plugin-tests
|
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
HELP_FUN = \
|
HELP_FUN = \
|
||||||
|
|||||||
274
adapters/gotaglib/end_to_end_test.go
Normal file
274
adapters/gotaglib/end_to_end_test.go
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
package gotaglib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/djherbis/times"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
|
"github.com/navidrome/navidrome/utils/gg"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testFileInfo struct {
|
||||||
|
fs.FileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t testFileInfo) BirthTime() time.Time {
|
||||||
|
if ts := times.Get(t.FileInfo); ts.HasBirthTime() {
|
||||||
|
return ts.BirthTime()
|
||||||
|
}
|
||||||
|
return t.FileInfo.ModTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = Describe("Extractor", func() {
|
||||||
|
toP := func(name, sortName, mbid string) model.Participant {
|
||||||
|
return model.Participant{
|
||||||
|
Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roles := []struct {
|
||||||
|
model.Role
|
||||||
|
model.ParticipantList
|
||||||
|
}{
|
||||||
|
{model.RoleComposer, model.ParticipantList{
|
||||||
|
toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"),
|
||||||
|
toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"),
|
||||||
|
}},
|
||||||
|
{model.RoleLyricist, model.ParticipantList{
|
||||||
|
toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"),
|
||||||
|
toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"),
|
||||||
|
}},
|
||||||
|
{model.RoleArranger, model.ParticipantList{
|
||||||
|
toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"),
|
||||||
|
toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"),
|
||||||
|
}},
|
||||||
|
{model.RoleConductor, model.ParticipantList{
|
||||||
|
toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"),
|
||||||
|
toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"),
|
||||||
|
}},
|
||||||
|
{model.RoleDirector, model.ParticipantList{
|
||||||
|
toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"),
|
||||||
|
toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"),
|
||||||
|
}},
|
||||||
|
{model.RoleEngineer, model.ParticipantList{
|
||||||
|
toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"),
|
||||||
|
toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"),
|
||||||
|
}},
|
||||||
|
{model.RoleProducer, model.ParticipantList{
|
||||||
|
toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"),
|
||||||
|
toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"),
|
||||||
|
}},
|
||||||
|
{model.RoleRemixer, model.ParticipantList{
|
||||||
|
toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"),
|
||||||
|
toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"),
|
||||||
|
}},
|
||||||
|
{model.RoleDJMixer, model.ParticipantList{
|
||||||
|
toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"),
|
||||||
|
toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"),
|
||||||
|
}},
|
||||||
|
{model.RoleMixer, model.ParticipantList{
|
||||||
|
toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"),
|
||||||
|
toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var e *extractor
|
||||||
|
|
||||||
|
parseTestFile := func(path string) *model.MediaFile {
|
||||||
|
mds, err := e.Parse(path)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
info, ok := mds[path]
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
|
||||||
|
fileInfo, err := os.Stat(path)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||||
|
|
||||||
|
metadata := metadata.New(path, info)
|
||||||
|
mf := metadata.ToMediaFile(1, "folderID")
|
||||||
|
return &mf
|
||||||
|
}
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
e = &extractor{fs: os.DirFS(".")}
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("ReplayGain", func() {
|
||||||
|
DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) {
|
||||||
|
mf := parseTestFile("tests/fixtures/" + file)
|
||||||
|
|
||||||
|
Expect(mf.RGTrackGain).To(Equal(trackGain))
|
||||||
|
Expect(mf.RGTrackPeak).To(Equal(trackPeak))
|
||||||
|
Expect(mf.RGAlbumGain).To(Equal(albumGain))
|
||||||
|
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
|
||||||
|
},
|
||||||
|
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
|
||||||
|
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("lyrics", func() {
|
||||||
|
makeLyrics := func(code, secondLine string) model.Lyrics {
|
||||||
|
return model.Lyrics{
|
||||||
|
DisplayArtist: "",
|
||||||
|
DisplayTitle: "",
|
||||||
|
Lang: code,
|
||||||
|
Line: []model.Line{
|
||||||
|
{Start: gg.P(int64(0)), Value: "This is"},
|
||||||
|
{Start: gg.P(int64(2500)), Value: secondLine},
|
||||||
|
},
|
||||||
|
Offset: nil,
|
||||||
|
Synced: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
It("should fetch both synced and unsynced lyrics in mixed flac", func() {
|
||||||
|
mf := parseTestFile("tests/fixtures/mixed-lyrics.flac")
|
||||||
|
|
||||||
|
lyrics, err := mf.StructuredLyrics()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(lyrics).To(HaveLen(2))
|
||||||
|
|
||||||
|
Expect(lyrics[0].Synced).To(BeTrue())
|
||||||
|
Expect(lyrics[1].Synced).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should handle mp3 with uslt and sylt", func() {
|
||||||
|
mf := parseTestFile("tests/fixtures/test.mp3")
|
||||||
|
|
||||||
|
lyrics, err := mf.StructuredLyrics()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(lyrics).To(HaveLen(4))
|
||||||
|
|
||||||
|
engSylt := makeLyrics("eng", "English SYLT")
|
||||||
|
engUslt := makeLyrics("eng", "English")
|
||||||
|
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
||||||
|
unsUslt := makeLyrics("xxx", "unspecified")
|
||||||
|
|
||||||
|
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
|
||||||
|
mf := parseTestFile("tests/fixtures/" + file)
|
||||||
|
|
||||||
|
lyrics, err := mf.StructuredLyrics()
|
||||||
|
Expect(err).To(Not(HaveOccurred()))
|
||||||
|
Expect(lyrics).To(HaveLen(2))
|
||||||
|
|
||||||
|
unspec := makeLyrics("xxx", "unspecified")
|
||||||
|
eng := makeLyrics("xxx", "English")
|
||||||
|
|
||||||
|
if isId3 {
|
||||||
|
eng.Lang = "eng"
|
||||||
|
}
|
||||||
|
|
||||||
|
Expect(lyrics).To(Or(
|
||||||
|
Equal(model.LyricList{unspec, eng}),
|
||||||
|
Equal(model.LyricList{eng, unspec})))
|
||||||
|
},
|
||||||
|
Entry("flac", "test.flac", false),
|
||||||
|
Entry("m4a", "test.m4a", false),
|
||||||
|
Entry("ogg", "test.ogg", false),
|
||||||
|
Entry("wma", "test.wma", false),
|
||||||
|
Entry("wv", "test.wv", false),
|
||||||
|
Entry("wav", "test.wav", true),
|
||||||
|
Entry("aiff", "test.aiff", true),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Participants", func() {
|
||||||
|
DescribeTable("test tags consistent across formats", func(format string) {
|
||||||
|
mf := parseTestFile("tests/fixtures/test." + format)
|
||||||
|
|
||||||
|
for _, data := range roles {
|
||||||
|
role := data.Role
|
||||||
|
artists := data.ParticipantList
|
||||||
|
|
||||||
|
actual := mf.Participants[role]
|
||||||
|
Expect(actual).To(HaveLen(len(artists)))
|
||||||
|
|
||||||
|
for i := range artists {
|
||||||
|
actualArtist := actual[i]
|
||||||
|
expectedArtist := artists[i]
|
||||||
|
|
||||||
|
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
|
||||||
|
Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName))
|
||||||
|
Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if format != "m4a" {
|
||||||
|
performers := mf.Participants[model.RolePerformer]
|
||||||
|
Expect(performers).To(HaveLen(8))
|
||||||
|
|
||||||
|
rules := map[string][]string{
|
||||||
|
"pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"},
|
||||||
|
"pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""},
|
||||||
|
"pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"},
|
||||||
|
"pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"},
|
||||||
|
"pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, rule := range rules {
|
||||||
|
mbid := rule[0]
|
||||||
|
for i := 1; i < len(rule); i++ {
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for _, mapped := range performers {
|
||||||
|
if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Expect(found).To(BeTrue(), "Could not find matching artist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Entry("FLAC format", "flac"),
|
||||||
|
Entry("M4a format", "m4a"),
|
||||||
|
Entry("OGG format", "ogg"),
|
||||||
|
Entry("WV format", "wv"),
|
||||||
|
|
||||||
|
Entry("MP3 format", "mp3"),
|
||||||
|
Entry("WAV format", "wav"),
|
||||||
|
Entry("AIFF format", "aiff"),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("should parse wma", func() {
|
||||||
|
mf := parseTestFile("tests/fixtures/test.wma")
|
||||||
|
|
||||||
|
for _, data := range roles {
|
||||||
|
role := data.Role
|
||||||
|
artists := data.ParticipantList
|
||||||
|
actual := mf.Participants[role]
|
||||||
|
|
||||||
|
// WMA has no Arranger role
|
||||||
|
if role == model.RoleArranger {
|
||||||
|
Expect(actual).To(HaveLen(0))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Expect(actual).To(HaveLen(len(artists)), role.String())
|
||||||
|
|
||||||
|
// For some bizarre reason, the order is inverted. We also don't get
|
||||||
|
// sort names or MBIDs
|
||||||
|
for i := range artists {
|
||||||
|
idx := len(artists) - 1 - i
|
||||||
|
|
||||||
|
actualArtist := actual[i]
|
||||||
|
expectedArtist := artists[idx]
|
||||||
|
|
||||||
|
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
263
adapters/gotaglib/gotaglib.go
Normal file
263
adapters/gotaglib/gotaglib.go
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
// Package gotaglib provides an alternative metadata extractor using go-taglib,
|
||||||
|
// a pure Go (WASM-based) implementation of TagLib.
|
||||||
|
//
|
||||||
|
// This extractor aims for parity with the CGO-based taglib extractor. It uses
|
||||||
|
// TagLib's PropertyMap interface for standard tags. The File handle API provides
|
||||||
|
// efficient access to format-specific tags (ID3v2 frames, MP4 atoms, ASF attributes)
|
||||||
|
// through a single file open operation.
|
||||||
|
//
|
||||||
|
// This extractor is registered under the name "gotaglib". It only works with a filesystem
|
||||||
|
// (fs.FS) and does not support direct local file paths. Files returned by the filesystem
|
||||||
|
// must implement io.ReadSeeker for go-taglib to read them.
|
||||||
|
package gotaglib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/core/storage/local"
|
||||||
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
|
"go.senan.xyz/taglib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type extractor struct {
|
||||||
|
fs fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
|
||||||
|
results := make(map[string]metadata.Info)
|
||||||
|
for _, path := range files {
|
||||||
|
props, err := e.extractMetadata(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[path] = *props
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e extractor) Version() string {
|
||||||
|
return "go-taglib (TagLib 2.1.1 WASM)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||||
|
f, close, err := e.openFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer close()
|
||||||
|
|
||||||
|
// Get all tags and properties in one go
|
||||||
|
allTags := f.AllTags()
|
||||||
|
props := f.Properties()
|
||||||
|
|
||||||
|
// Map properties to AudioProperties
|
||||||
|
ap := metadata.AudioProperties{
|
||||||
|
Duration: props.Length.Round(time.Millisecond * 10),
|
||||||
|
BitRate: int(props.Bitrate),
|
||||||
|
Channels: int(props.Channels),
|
||||||
|
SampleRate: int(props.SampleRate),
|
||||||
|
BitDepth: int(props.BitsPerSample),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
|
||||||
|
normalizedTags := make(map[string][]string, len(allTags.Tags))
|
||||||
|
for key, values := range allTags.Tags {
|
||||||
|
lowerKey := strings.ToLower(key)
|
||||||
|
normalizedTags[lowerKey] = values
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process format-specific raw tags
|
||||||
|
processRawTags(allTags, normalizedTags)
|
||||||
|
|
||||||
|
// Parse track/disc totals from "N/Total" format
|
||||||
|
parseTuple(normalizedTags, "track")
|
||||||
|
parseTuple(normalizedTags, "disc")
|
||||||
|
|
||||||
|
// Adjust some ID3 tags
|
||||||
|
parseLyrics(normalizedTags)
|
||||||
|
parseTIPL(normalizedTags)
|
||||||
|
delete(normalizedTags, "tmcl") // TMCL is already parsed by TagLib
|
||||||
|
|
||||||
|
// Determine if file has embedded picture
|
||||||
|
hasPicture := len(props.Images) > 0
|
||||||
|
|
||||||
|
return &metadata.Info{
|
||||||
|
Tags: normalizedTags,
|
||||||
|
AudioProperties: ap,
|
||||||
|
HasPicture: hasPicture,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openFile opens the file at filePath using the extractor's filesystem.
|
||||||
|
// It returns a TagLib File handle and a cleanup function to close resources.
|
||||||
|
func (e extractor) openFile(filePath string) (*taglib.File, func(), error) {
|
||||||
|
// Open the file from the filesystem
|
||||||
|
file, err := e.fs.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
rs, isSeekable := file.(io.ReadSeeker)
|
||||||
|
if !isSeekable {
|
||||||
|
file.Close()
|
||||||
|
return nil, nil, errors.New("file is not seekable")
|
||||||
|
}
|
||||||
|
f, err := taglib.OpenStream(rs, taglib.WithReadStyle(taglib.ReadStyleFast))
|
||||||
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
closeFunc := func() {
|
||||||
|
f.Close()
|
||||||
|
file.Close()
|
||||||
|
}
|
||||||
|
return f, closeFunc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTuple parses track/disc numbers in "N/Total" format and separates them.
|
||||||
|
// For example, tracknumber="2/10" becomes tracknumber="2" and tracktotal="10".
|
||||||
|
func parseTuple(tags map[string][]string, prop string) {
|
||||||
|
tagName := prop + "number"
|
||||||
|
tagTotal := prop + "total"
|
||||||
|
if value, ok := tags[tagName]; ok && len(value) > 0 {
|
||||||
|
parts := strings.Split(value[0], "/")
|
||||||
|
tags[tagName] = []string{parts[0]}
|
||||||
|
if len(parts) == 2 {
|
||||||
|
tags[tagTotal] = []string{parts[1]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLyrics ensures lyrics tags have a language code.
|
||||||
|
// If lyrics exist without a language code, they are moved to "lyrics:xxx".
|
||||||
|
func parseLyrics(tags map[string][]string) {
|
||||||
|
lyrics := tags["lyrics"]
|
||||||
|
if len(lyrics) > 0 {
|
||||||
|
tags["lyrics:xxx"] = lyrics
|
||||||
|
delete(tags, "lyrics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processRawTags processes format-specific raw tags based on the detected file format.
|
||||||
|
// This handles ID3v2 frames (MP3/WAV/AIFF), MP4 atoms, and ASF attributes.
|
||||||
|
func processRawTags(allTags taglib.AllTags, normalizedTags map[string][]string) {
|
||||||
|
switch allTags.Format {
|
||||||
|
case taglib.FormatMPEG, taglib.FormatWAV, taglib.FormatAIFF:
|
||||||
|
parseID3v2Frames(allTags.Raw, normalizedTags)
|
||||||
|
case taglib.FormatMP4:
|
||||||
|
parseMP4Atoms(allTags.Raw, normalizedTags)
|
||||||
|
case taglib.FormatASF:
|
||||||
|
parseASFAttributes(allTags.Raw, normalizedTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseID3v2Frames processes ID3v2 raw frames to extract USLT/SYLT with language codes.
|
||||||
|
// This extracts language-specific lyrics that the standard Tags() doesn't provide.
|
||||||
|
func parseID3v2Frames(rawFrames map[string][]string, tags map[string][]string) {
|
||||||
|
// Process frames that have language-specific data
|
||||||
|
for key, values := range rawFrames {
|
||||||
|
lowerKey := strings.ToLower(key)
|
||||||
|
|
||||||
|
// Handle USLT:xxx and SYLT:xxx (lyrics with language codes)
|
||||||
|
if strings.HasPrefix(lowerKey, "uslt:") || strings.HasPrefix(lowerKey, "sylt:") {
|
||||||
|
parts := strings.SplitN(lowerKey, ":", 2)
|
||||||
|
if len(parts) == 2 && parts[1] != "" {
|
||||||
|
lang := parts[1]
|
||||||
|
lyricsKey := "lyrics:" + lang
|
||||||
|
tags[lyricsKey] = append(tags[lyricsKey], values...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found any language-specific lyrics from ID3v2 frames, remove the generic lyrics
|
||||||
|
for key := range tags {
|
||||||
|
if strings.HasPrefix(key, "lyrics:") && key != "lyrics" {
|
||||||
|
delete(tags, "lyrics")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const iTunesKeyPrefix = "----:com.apple.iTunes:"
|
||||||
|
|
||||||
|
// parseMP4Atoms processes MP4 raw atoms to get iTunes-specific tags.
|
||||||
|
func parseMP4Atoms(rawAtoms map[string][]string, tags map[string][]string) {
|
||||||
|
// Process all atoms and add them to tags
|
||||||
|
for key, values := range rawAtoms {
|
||||||
|
// Strip iTunes prefix and convert to lowercase
|
||||||
|
normalizedKey := strings.TrimPrefix(key, iTunesKeyPrefix)
|
||||||
|
normalizedKey = strings.ToLower(normalizedKey)
|
||||||
|
|
||||||
|
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
|
||||||
|
if _, exists := tags[normalizedKey]; !exists {
|
||||||
|
tags[normalizedKey] = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseASFAttributes processes ASF raw attributes to get WMA-specific tags.
|
||||||
|
func parseASFAttributes(rawAttrs map[string][]string, tags map[string][]string) {
|
||||||
|
// Process all attributes and add them to tags
|
||||||
|
for key, values := range rawAttrs {
|
||||||
|
normalizedKey := strings.ToLower(key)
|
||||||
|
|
||||||
|
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
|
||||||
|
if _, exists := tags[normalizedKey]; !exists {
|
||||||
|
tags[normalizedKey] = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These are the only roles we support, based on Picard's tag map:
|
||||||
|
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
|
||||||
|
var tiplMapping = map[string]string{
|
||||||
|
"arranger": "arranger",
|
||||||
|
"engineer": "engineer",
|
||||||
|
"producer": "producer",
|
||||||
|
"mix": "mixer",
|
||||||
|
"DJ-mix": "djmixer",
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
|
||||||
|
//
|
||||||
|
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
|
||||||
|
//
|
||||||
|
// and breaks it down into a map of roles and names, e.g.:
|
||||||
|
//
|
||||||
|
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
|
||||||
|
func parseTIPL(tags map[string][]string) {
|
||||||
|
tipl := tags["tipl"]
|
||||||
|
if len(tipl) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addRole := func(currentRole string, currentValue []string) {
|
||||||
|
if currentRole != "" && len(currentValue) > 0 {
|
||||||
|
role := tiplMapping[currentRole]
|
||||||
|
tags[role] = append(tags[role], strings.Join(currentValue, " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var currentRole string
|
||||||
|
var currentValue []string
|
||||||
|
for _, part := range strings.Split(tipl[0], " ") {
|
||||||
|
if _, ok := tiplMapping[part]; ok {
|
||||||
|
addRole(currentRole, currentValue)
|
||||||
|
currentRole = part
|
||||||
|
currentValue = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
currentValue = append(currentValue, part)
|
||||||
|
}
|
||||||
|
addRole(currentRole, currentValue)
|
||||||
|
delete(tags, "tipl")
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ local.Extractor = (*extractor)(nil)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
|
||||||
|
return &extractor{fsys}
|
||||||
|
})
|
||||||
|
}
|
||||||
17
adapters/gotaglib/gotaglib_suite_test.go
Normal file
17
adapters/gotaglib/gotaglib_suite_test.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package gotaglib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGoTagLib(t *testing.T) {
|
||||||
|
tests.Init(t, true)
|
||||||
|
log.SetLevel(log.LevelFatal)
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "GoTagLib Suite")
|
||||||
|
}
|
||||||
302
adapters/gotaglib/gotaglib_test.go
Normal file
302
adapters/gotaglib/gotaglib_test.go
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
package gotaglib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/utils"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Extractor", func() {
|
||||||
|
var e *extractor
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
e = &extractor{fs: os.DirFS(".")}
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Parse", func() {
|
||||||
|
It("correctly parses metadata from all files in folder", func() {
|
||||||
|
mds, err := e.Parse(
|
||||||
|
"tests/fixtures/test.mp3",
|
||||||
|
"tests/fixtures/test.ogg",
|
||||||
|
)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(mds).To(HaveLen(2))
|
||||||
|
|
||||||
|
// Test MP3
|
||||||
|
m := mds["tests/fixtures/test.mp3"]
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
||||||
|
|
||||||
|
Expect(m.HasPicture).To(BeTrue())
|
||||||
|
Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s"))
|
||||||
|
Expect(m.AudioProperties.BitRate).To(Equal(192))
|
||||||
|
Expect(m.AudioProperties.Channels).To(Equal(2))
|
||||||
|
Expect(m.AudioProperties.SampleRate).To(Equal(44100))
|
||||||
|
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("compilation", []string{"1"}),
|
||||||
|
HaveKeyWithValue("tcmp", []string{"1"})),
|
||||||
|
)
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}))
|
||||||
|
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
|
||||||
|
|
||||||
|
Expect(m.Tags).ToNot(HaveKey("lyrics"))
|
||||||
|
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{
|
||||||
|
"[00:00.00]This is\n[00:02.50]English SYLT\n",
|
||||||
|
"[00:00.00]This is\n[00:02.50]English",
|
||||||
|
}), HaveKeyWithValue("lyrics:eng", []string{
|
||||||
|
"[00:00.00]This is\n[00:02.50]English",
|
||||||
|
"[00:00.00]This is\n[00:02.50]English SYLT\n",
|
||||||
|
})))
|
||||||
|
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{
|
||||||
|
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
|
||||||
|
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||||
|
}), HaveKeyWithValue("lyrics:xxx", []string{
|
||||||
|
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||||
|
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
|
||||||
|
})))
|
||||||
|
|
||||||
|
// Test OGG
|
||||||
|
m = mds["tests/fixtures/test.ogg"]
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
||||||
|
|
||||||
|
// TagLib 1.12 returns 18, previous versions return 39.
|
||||||
|
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
||||||
|
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
||||||
|
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
||||||
|
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||||
|
Expect(m.HasPicture).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("Format-Specific tests",
|
||||||
|
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
|
||||||
|
file = "tests/fixtures/" + file
|
||||||
|
mds, err := e.Parse(file)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(mds).To(HaveLen(1))
|
||||||
|
|
||||||
|
m := mds[file]
|
||||||
|
|
||||||
|
Expect(m.HasPicture).To(Equal(image))
|
||||||
|
Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
|
||||||
|
Expect(m.AudioProperties.Channels).To(Equal(channels))
|
||||||
|
Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
|
||||||
|
Expect(m.AudioProperties.BitDepth).To(Equal(bitdepth))
|
||||||
|
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
|
||||||
|
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
|
||||||
|
))
|
||||||
|
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}),
|
||||||
|
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}),
|
||||||
|
))
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("replaygain_track_gain", []string{trackGain}),
|
||||||
|
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}),
|
||||||
|
))
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}),
|
||||||
|
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}),
|
||||||
|
))
|
||||||
|
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
|
||||||
|
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("tracknumber", []string{"3"}),
|
||||||
|
HaveKeyWithValue("tracknumber", []string{"3/10"}),
|
||||||
|
))
|
||||||
|
if !strings.HasSuffix(file, "test.wma") {
|
||||||
|
// TODO Not sure why this is not working for WMA
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
|
||||||
|
}
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("discnumber", []string{"1"}),
|
||||||
|
HaveKeyWithValue("discnumber", []string{"1/2"}),
|
||||||
|
))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
|
||||||
|
|
||||||
|
// WMA does not have a "compilation" tag, but "wm/iscompilation"
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("compilation", []string{"1"}),
|
||||||
|
HaveKeyWithValue("wm/iscompilation", []string{"1"})),
|
||||||
|
)
|
||||||
|
|
||||||
|
if id3Lyrics {
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{
|
||||||
|
"[00:00.00]This is\n[00:02.50]English",
|
||||||
|
}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
|
||||||
|
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
|
||||||
|
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||||
|
"[00:00.00]This is\n[00:02.50]English",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
|
||||||
|
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true),
|
||||||
|
|
||||||
|
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||||
|
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||||
|
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
|
||||||
|
|
||||||
|
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
|
||||||
|
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
|
||||||
|
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
|
||||||
|
|
||||||
|
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
|
||||||
|
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true),
|
||||||
|
|
||||||
|
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
|
||||||
|
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
|
||||||
|
|
||||||
|
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
|
||||||
|
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Skip these tests when running as root
|
||||||
|
Context("Access Forbidden", func() {
|
||||||
|
var accessForbiddenFile string
|
||||||
|
var RegularUserContext = XContext
|
||||||
|
var isRegularUser = os.Getuid() != 0
|
||||||
|
if isRegularUser {
|
||||||
|
RegularUserContext = Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only run permission tests if we are not root
|
||||||
|
RegularUserContext("when run without root privileges", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
// Use root fs for absolute paths in temp directory
|
||||||
|
e = &extractor{fs: os.DirFS("/")}
|
||||||
|
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||||
|
|
||||||
|
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
DeferCleanup(func() {
|
||||||
|
Expect(f.Close()).To(Succeed())
|
||||||
|
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
It("correctly handle unreadable file due to insufficient read permission", func() {
|
||||||
|
// Strip leading slash for DirFS rooted at "/"
|
||||||
|
_, err := e.extractMetadata(accessForbiddenFile[1:])
|
||||||
|
Expect(err).To(MatchError(os.ErrPermission))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("skips the file if it cannot be read", func() {
|
||||||
|
// Get current working directory to construct paths relative to root
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
// Strip leading slash for DirFS rooted at "/"
|
||||||
|
files := []string{
|
||||||
|
cwd[1:] + "/tests/fixtures/test.mp3",
|
||||||
|
cwd[1:] + "/tests/fixtures/test.ogg",
|
||||||
|
accessForbiddenFile[1:],
|
||||||
|
}
|
||||||
|
mds, err := e.Parse(files...)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(mds).To(HaveLen(2))
|
||||||
|
Expect(mds).ToNot(HaveKey(accessForbiddenFile[1:]))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Error Checking", func() {
|
||||||
|
It("returns a generic ErrPath if file does not exist", func() {
|
||||||
|
testFilePath := "tests/fixtures/NON_EXISTENT.ogg"
|
||||||
|
_, err := e.extractMetadata(testFilePath)
|
||||||
|
Expect(err).To(MatchError(fs.ErrNotExist))
|
||||||
|
})
|
||||||
|
It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() {
|
||||||
|
// File has an empty TDAT frame
|
||||||
|
md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("parseTIPL", func() {
|
||||||
|
var tags map[string][]string
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
tags = make(map[string][]string)
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("when the TIPL string is populated", func() {
|
||||||
|
It("correctly parses roles and names", func() {
|
||||||
|
tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"}
|
||||||
|
parseTIPL(tags)
|
||||||
|
Expect(tags["arranger"]).To(ConsistOf("Andrew Powell"))
|
||||||
|
Expect(tags["engineer"]).To(ConsistOf("Chris Blair"))
|
||||||
|
Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles multiple names for a single role", func() {
|
||||||
|
tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"}
|
||||||
|
parseTIPL(tags)
|
||||||
|
Expect(tags["producer"]).To(ConsistOf("Eric Woolfson"))
|
||||||
|
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("discards roles without names", func() {
|
||||||
|
tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"}
|
||||||
|
parseTIPL(tags)
|
||||||
|
Expect(tags).ToNot(HaveKey("producer"))
|
||||||
|
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("when the TIPL string is empty", func() {
|
||||||
|
It("does nothing", func() {
|
||||||
|
tags["tipl"] = []string{""}
|
||||||
|
parseTIPL(tags)
|
||||||
|
Expect(tags).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("when the TIPL is not present", func() {
|
||||||
|
It("does nothing", func() {
|
||||||
|
parseTIPL(tags)
|
||||||
|
Expect(tags).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
@ -151,11 +151,7 @@ var _ = Describe("Extractor", func() {
|
|||||||
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
||||||
unsUslt := makeLyrics("xxx", "unspecified")
|
unsUslt := makeLyrics("xxx", "unspecified")
|
||||||
|
|
||||||
// Why is the order inconsistent between runs? Nobody knows
|
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
|
||||||
Expect(lyrics).To(Or(
|
|
||||||
Equal(model.LyricList{engSylt, engUslt, unsSylt, unsUslt}),
|
|
||||||
Equal(model.LyricList{unsSylt, unsUslt, engSylt, engUslt}),
|
|
||||||
))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
|
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
|
||||||
|
|||||||
@ -168,7 +168,7 @@ func parseTIPL(tags map[string][]string) {
|
|||||||
var _ local.Extractor = (*extractor)(nil)
|
var _ local.Extractor = (*extractor)(nil)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
local.RegisterExtractor("taglib", func(_ fs.FS, baseDir string) local.Extractor {
|
local.RegisterExtractor("legacy-taglib", func(_ fs.FS, baseDir string) local.Extractor {
|
||||||
// ignores fs, as taglib extractor only works with local files
|
// ignores fs, as taglib extractor only works with local files
|
||||||
return &extractor{baseDir}
|
return &extractor{baseDir}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -80,12 +80,11 @@ var _ = Describe("Extractor", func() {
|
|||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
||||||
|
|
||||||
// TabLib 1.12 returns 18, previous versions return 39.
|
// TagLib 1.12 returns 18, previous versions return 39.
|
||||||
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
||||||
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
||||||
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
||||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
|
||||||
Expect(m.HasPicture).To(BeTrue())
|
Expect(m.HasPicture).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -106,7 +105,7 @@ var _ = Describe("Extractor", func() {
|
|||||||
|
|
||||||
Expect(m.Tags).To(Or(
|
Expect(m.Tags).To(Or(
|
||||||
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
|
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
|
||||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{albumGain}),
|
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
|
||||||
))
|
))
|
||||||
|
|
||||||
Expect(m.Tags).To(Or(
|
Expect(m.Tags).To(Or(
|
||||||
|
|||||||
716
cmd/plugin.go
716
cmd/plugin.go
@ -1,716 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"text/tabwriter"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/plugins"
|
|
||||||
"github.com/navidrome/navidrome/plugins/schema"
|
|
||||||
"github.com/navidrome/navidrome/utils"
|
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
pluginPackageExtension = ".ndp"
|
|
||||||
pluginDirPermissions = 0700
|
|
||||||
pluginFilePermissions = 0600
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
pluginCmd := &cobra.Command{
|
|
||||||
Use: "plugin",
|
|
||||||
Short: "Manage Navidrome plugins",
|
|
||||||
Long: "Commands for managing Navidrome plugins",
|
|
||||||
}
|
|
||||||
|
|
||||||
listCmd := &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List installed plugins",
|
|
||||||
Long: "List all installed plugins with their metadata",
|
|
||||||
Run: pluginList,
|
|
||||||
}
|
|
||||||
|
|
||||||
infoCmd := &cobra.Command{
|
|
||||||
Use: "info [pluginPackage|pluginName]",
|
|
||||||
Short: "Show details of a plugin",
|
|
||||||
Long: "Show detailed information about a plugin package (.ndp file) or an installed plugin",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Run: pluginInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
installCmd := &cobra.Command{
|
|
||||||
Use: "install [pluginPackage]",
|
|
||||||
Short: "Install a plugin from a .ndp file",
|
|
||||||
Long: "Install a Navidrome Plugin Package (.ndp) file",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Run: pluginInstall,
|
|
||||||
}
|
|
||||||
|
|
||||||
removeCmd := &cobra.Command{
|
|
||||||
Use: "remove [pluginName]",
|
|
||||||
Short: "Remove an installed plugin",
|
|
||||||
Long: "Remove a plugin by name",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Run: pluginRemove,
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCmd := &cobra.Command{
|
|
||||||
Use: "update [pluginPackage]",
|
|
||||||
Short: "Update an existing plugin",
|
|
||||||
Long: "Update an installed plugin with a new version from a .ndp file",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Run: pluginUpdate,
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshCmd := &cobra.Command{
|
|
||||||
Use: "refresh [pluginName]",
|
|
||||||
Short: "Reload a plugin without restarting Navidrome",
|
|
||||||
Long: "Reload and recompile a plugin without needing to restart Navidrome",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Run: pluginRefresh,
|
|
||||||
}
|
|
||||||
|
|
||||||
devCmd := &cobra.Command{
|
|
||||||
Use: "dev [folder_path]",
|
|
||||||
Short: "Create symlink to development folder",
|
|
||||||
Long: "Create a symlink from a plugin development folder to the plugins directory for easier development",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Run: pluginDev,
|
|
||||||
}
|
|
||||||
|
|
||||||
pluginCmd.AddCommand(listCmd, infoCmd, installCmd, removeCmd, updateCmd, refreshCmd, devCmd)
|
|
||||||
rootCmd.AddCommand(pluginCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validation helpers
|
|
||||||
|
|
||||||
func validatePluginPackageFile(path string) error {
|
|
||||||
if !utils.FileExists(path) {
|
|
||||||
return fmt.Errorf("plugin package not found: %s", path)
|
|
||||||
}
|
|
||||||
if filepath.Ext(path) != pluginPackageExtension {
|
|
||||||
return fmt.Errorf("not a valid plugin package: %s (expected %s extension)", path, pluginPackageExtension)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validatePluginDirectory(pluginsDir, pluginName string) (string, error) {
|
|
||||||
pluginDir := filepath.Join(pluginsDir, pluginName)
|
|
||||||
if !utils.FileExists(pluginDir) {
|
|
||||||
return "", fmt.Errorf("plugin not found: %s (path: %s)", pluginName, pluginDir)
|
|
||||||
}
|
|
||||||
return pluginDir, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolvePluginPath(pluginDir string) (resolvedPath string, isSymlink bool, err error) {
|
|
||||||
// Check if it's a directory or a symlink
|
|
||||||
lstat, err := os.Lstat(pluginDir)
|
|
||||||
if err != nil {
|
|
||||||
return "", false, fmt.Errorf("failed to stat plugin: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
isSymlink = lstat.Mode()&os.ModeSymlink != 0
|
|
||||||
|
|
||||||
if isSymlink {
|
|
||||||
// Resolve the symlink target
|
|
||||||
targetDir, err := os.Readlink(pluginDir)
|
|
||||||
if err != nil {
|
|
||||||
return "", true, fmt.Errorf("failed to resolve symlink: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If target is a relative path, make it absolute
|
|
||||||
if !filepath.IsAbs(targetDir) {
|
|
||||||
targetDir = filepath.Join(filepath.Dir(pluginDir), targetDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the target exists and is a directory
|
|
||||||
targetInfo, err := os.Stat(targetDir)
|
|
||||||
if err != nil {
|
|
||||||
return "", true, fmt.Errorf("failed to access symlink target %s: %w", targetDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !targetInfo.IsDir() {
|
|
||||||
return "", true, fmt.Errorf("symlink target is not a directory: %s", targetDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
return targetDir, true, nil
|
|
||||||
} else if !lstat.IsDir() {
|
|
||||||
return "", false, fmt.Errorf("not a valid plugin directory: %s", pluginDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
return pluginDir, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Package handling helpers
|
|
||||||
|
|
||||||
func loadAndValidatePackage(ndpPath string) (*plugins.PluginPackage, error) {
|
|
||||||
if err := validatePluginPackageFile(ndpPath); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := plugins.LoadPackage(ndpPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to load plugin package: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return pkg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractAndSetupPlugin(ndpPath, targetDir string) error {
|
|
||||||
if err := plugins.ExtractPackage(ndpPath, targetDir); err != nil {
|
|
||||||
return fmt.Errorf("failed to extract plugin package: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ensurePluginDirPermissions(targetDir)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display helpers
|
|
||||||
|
|
||||||
func displayPluginTableRow(w *tabwriter.Writer, discovery plugins.PluginDiscoveryEntry) {
|
|
||||||
if discovery.Error != nil {
|
|
||||||
// Handle global errors (like directory read failure)
|
|
||||||
if discovery.ID == "" {
|
|
||||||
log.Error("Failed to read plugins directory", "folder", conf.Server.Plugins.Folder, discovery.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Handle individual plugin errors - show them in the table
|
|
||||||
fmt.Fprintf(w, "%s\tERROR\tERROR\tERROR\tERROR\t%v\n", discovery.ID, discovery.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark symlinks with an indicator
|
|
||||||
nameDisplay := discovery.Manifest.Name
|
|
||||||
if discovery.IsSymlink {
|
|
||||||
nameDisplay = nameDisplay + " (dev)"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert capabilities to strings
|
|
||||||
capabilities := slice.Map(discovery.Manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string {
|
|
||||||
return string(cap)
|
|
||||||
})
|
|
||||||
|
|
||||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
||||||
discovery.ID,
|
|
||||||
nameDisplay,
|
|
||||||
cmp.Or(discovery.Manifest.Author, "-"),
|
|
||||||
cmp.Or(discovery.Manifest.Version, "-"),
|
|
||||||
strings.Join(capabilities, ", "),
|
|
||||||
cmp.Or(discovery.Manifest.Description, "-"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func displayTypedPermissions(permissions schema.PluginManifestPermissions, indent string) {
|
|
||||||
if permissions.Http != nil {
|
|
||||||
fmt.Printf("%shttp:\n", indent)
|
|
||||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Http.Reason)
|
|
||||||
fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Http.AllowLocalNetwork)
|
|
||||||
fmt.Printf("%s Allowed URLs:\n", indent)
|
|
||||||
for urlPattern, methodEnums := range permissions.Http.AllowedUrls {
|
|
||||||
methods := make([]string, len(methodEnums))
|
|
||||||
for i, methodEnum := range methodEnums {
|
|
||||||
methods[i] = string(methodEnum)
|
|
||||||
}
|
|
||||||
fmt.Printf("%s %s: [%s]\n", indent, urlPattern, strings.Join(methods, ", "))
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
if permissions.Config != nil {
|
|
||||||
fmt.Printf("%sconfig:\n", indent)
|
|
||||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Config.Reason)
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
if permissions.Scheduler != nil {
|
|
||||||
fmt.Printf("%sscheduler:\n", indent)
|
|
||||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Scheduler.Reason)
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
if permissions.Websocket != nil {
|
|
||||||
fmt.Printf("%swebsocket:\n", indent)
|
|
||||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Websocket.Reason)
|
|
||||||
fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Websocket.AllowLocalNetwork)
|
|
||||||
fmt.Printf("%s Allowed URLs: [%s]\n", indent, strings.Join(permissions.Websocket.AllowedUrls, ", "))
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
if permissions.Cache != nil {
|
|
||||||
fmt.Printf("%scache:\n", indent)
|
|
||||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Cache.Reason)
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
if permissions.Artwork != nil {
|
|
||||||
fmt.Printf("%sartwork:\n", indent)
|
|
||||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Artwork.Reason)
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
if permissions.Subsonicapi != nil {
|
|
||||||
allowedUsers := "All Users"
|
|
||||||
if len(permissions.Subsonicapi.AllowedUsernames) > 0 {
|
|
||||||
allowedUsers = strings.Join(permissions.Subsonicapi.AllowedUsernames, ", ")
|
|
||||||
}
|
|
||||||
fmt.Printf("%ssubsonicapi:\n", indent)
|
|
||||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Subsonicapi.Reason)
|
|
||||||
fmt.Printf("%s Allow Admins: %t\n", indent, permissions.Subsonicapi.AllowAdmins)
|
|
||||||
fmt.Printf("%s Allowed Usernames: [%s]\n", indent, allowedUsers)
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func displayPluginDetails(manifest *schema.PluginManifest, fileInfo *pluginFileInfo, permInfo *pluginPermissionInfo) {
|
|
||||||
fmt.Println("\nPlugin Information:")
|
|
||||||
fmt.Printf(" Name: %s\n", manifest.Name)
|
|
||||||
fmt.Printf(" Author: %s\n", manifest.Author)
|
|
||||||
fmt.Printf(" Version: %s\n", manifest.Version)
|
|
||||||
fmt.Printf(" Description: %s\n", manifest.Description)
|
|
||||||
|
|
||||||
fmt.Print(" Capabilities: ")
|
|
||||||
capabilities := make([]string, len(manifest.Capabilities))
|
|
||||||
for i, cap := range manifest.Capabilities {
|
|
||||||
capabilities[i] = string(cap)
|
|
||||||
}
|
|
||||||
fmt.Print(strings.Join(capabilities, ", "))
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Display manifest permissions using the typed permissions
|
|
||||||
fmt.Println(" Required Permissions:")
|
|
||||||
displayTypedPermissions(manifest.Permissions, " ")
|
|
||||||
|
|
||||||
// Print file information if available
|
|
||||||
if fileInfo != nil {
|
|
||||||
fmt.Println("Package Information:")
|
|
||||||
fmt.Printf(" File: %s\n", fileInfo.path)
|
|
||||||
fmt.Printf(" Size: %d bytes (%.2f KB)\n", fileInfo.size, float64(fileInfo.size)/1024)
|
|
||||||
fmt.Printf(" SHA-256: %s\n", fileInfo.hash)
|
|
||||||
fmt.Printf(" Modified: %s\n", fileInfo.modTime.Format(time.RFC3339))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print file permissions information if available
|
|
||||||
if permInfo != nil {
|
|
||||||
fmt.Println("File Permissions:")
|
|
||||||
fmt.Printf(" Plugin Directory: %s (%s)\n", permInfo.dirPath, permInfo.dirMode)
|
|
||||||
if permInfo.isSymlink {
|
|
||||||
fmt.Printf(" Symlink Target: %s (%s)\n", permInfo.targetPath, permInfo.targetMode)
|
|
||||||
}
|
|
||||||
fmt.Printf(" Manifest File: %s\n", permInfo.manifestMode)
|
|
||||||
if permInfo.wasmMode != "" {
|
|
||||||
fmt.Printf(" WASM File: %s\n", permInfo.wasmMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type pluginFileInfo struct {
|
|
||||||
path string
|
|
||||||
size int64
|
|
||||||
hash string
|
|
||||||
modTime time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type pluginPermissionInfo struct {
|
|
||||||
dirPath string
|
|
||||||
dirMode string
|
|
||||||
isSymlink bool
|
|
||||||
targetPath string
|
|
||||||
targetMode string
|
|
||||||
manifestMode string
|
|
||||||
wasmMode string
|
|
||||||
}
|
|
||||||
|
|
||||||
func getFileInfo(path string) *pluginFileInfo {
|
|
||||||
fileInfo, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to get file information", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pluginFileInfo{
|
|
||||||
path: path,
|
|
||||||
size: fileInfo.Size(),
|
|
||||||
hash: calculateSHA256(path),
|
|
||||||
modTime: fileInfo.ModTime(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPermissionInfo(pluginDir string) *pluginPermissionInfo {
|
|
||||||
// Get plugin directory permissions
|
|
||||||
dirInfo, err := os.Lstat(pluginDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to get plugin directory permissions", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
permInfo := &pluginPermissionInfo{
|
|
||||||
dirPath: pluginDir,
|
|
||||||
dirMode: dirInfo.Mode().String(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a symlink
|
|
||||||
if dirInfo.Mode()&os.ModeSymlink != 0 {
|
|
||||||
permInfo.isSymlink = true
|
|
||||||
|
|
||||||
// Get target path and permissions
|
|
||||||
targetPath, err := os.Readlink(pluginDir)
|
|
||||||
if err == nil {
|
|
||||||
if !filepath.IsAbs(targetPath) {
|
|
||||||
targetPath = filepath.Join(filepath.Dir(pluginDir), targetPath)
|
|
||||||
}
|
|
||||||
permInfo.targetPath = targetPath
|
|
||||||
|
|
||||||
if targetInfo, err := os.Stat(targetPath); err == nil {
|
|
||||||
permInfo.targetMode = targetInfo.Mode().String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get manifest file permissions
|
|
||||||
manifestPath := filepath.Join(pluginDir, "manifest.json")
|
|
||||||
if manifestInfo, err := os.Stat(manifestPath); err == nil {
|
|
||||||
permInfo.manifestMode = manifestInfo.Mode().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get WASM file permissions (look for .wasm files)
|
|
||||||
entries, err := os.ReadDir(pluginDir)
|
|
||||||
if err == nil {
|
|
||||||
for _, entry := range entries {
|
|
||||||
if filepath.Ext(entry.Name()) == ".wasm" {
|
|
||||||
wasmPath := filepath.Join(pluginDir, entry.Name())
|
|
||||||
if wasmInfo, err := os.Stat(wasmPath); err == nil {
|
|
||||||
permInfo.wasmMode = wasmInfo.Mode().String()
|
|
||||||
break // Just show the first WASM file found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return permInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
// Command implementations
|
|
||||||
|
|
||||||
func pluginList(cmd *cobra.Command, args []string) {
|
|
||||||
discoveries := plugins.DiscoverPlugins(conf.Server.Plugins.Folder)
|
|
||||||
|
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
||||||
fmt.Fprintln(w, "ID\tNAME\tAUTHOR\tVERSION\tCAPABILITIES\tDESCRIPTION")
|
|
||||||
|
|
||||||
for _, discovery := range discoveries {
|
|
||||||
displayPluginTableRow(w, discovery)
|
|
||||||
}
|
|
||||||
w.Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
func pluginInfo(cmd *cobra.Command, args []string) {
|
|
||||||
path := args[0]
|
|
||||||
pluginsDir := conf.Server.Plugins.Folder
|
|
||||||
|
|
||||||
var manifest *schema.PluginManifest
|
|
||||||
var fileInfo *pluginFileInfo
|
|
||||||
var permInfo *pluginPermissionInfo
|
|
||||||
|
|
||||||
if filepath.Ext(path) == pluginPackageExtension {
|
|
||||||
// It's a package file
|
|
||||||
pkg, err := loadAndValidatePackage(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Failed to load plugin package", err)
|
|
||||||
}
|
|
||||||
manifest = pkg.Manifest
|
|
||||||
fileInfo = getFileInfo(path)
|
|
||||||
// No permission info for package files
|
|
||||||
} else {
|
|
||||||
// It's a plugin name
|
|
||||||
pluginDir, err := validatePluginDirectory(pluginsDir, path)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Plugin validation failed", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest, err = plugins.LoadManifest(pluginDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Failed to load plugin manifest", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get permission info for installed plugins
|
|
||||||
permInfo = getPermissionInfo(pluginDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
displayPluginDetails(manifest, fileInfo, permInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pluginInstall(cmd *cobra.Command, args []string) {
|
|
||||||
ndpPath := args[0]
|
|
||||||
pluginsDir := conf.Server.Plugins.Folder
|
|
||||||
|
|
||||||
pkg, err := loadAndValidatePackage(ndpPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Package validation failed", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create target directory based on plugin name
|
|
||||||
targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name)
|
|
||||||
|
|
||||||
// Check if plugin already exists
|
|
||||||
if utils.FileExists(targetDir) {
|
|
||||||
log.Fatal("Plugin already installed", "name", pkg.Manifest.Name, "path", targetDir,
|
|
||||||
"use", "navidrome plugin update")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil {
|
|
||||||
log.Fatal("Plugin installation failed", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Plugin '%s' v%s installed successfully\n", pkg.Manifest.Name, pkg.Manifest.Version)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pluginRemove(cmd *cobra.Command, args []string) {
|
|
||||||
pluginName := args[0]
|
|
||||||
pluginsDir := conf.Server.Plugins.Folder
|
|
||||||
|
|
||||||
pluginDir, err := validatePluginDirectory(pluginsDir, pluginName)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Plugin validation failed", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, isSymlink, err := resolvePluginPath(pluginDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Failed to resolve plugin path", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSymlink {
|
|
||||||
// For symlinked plugins (dev mode), just remove the symlink
|
|
||||||
if err := os.Remove(pluginDir); err != nil {
|
|
||||||
log.Fatal("Failed to remove plugin symlink", "name", pluginName, err)
|
|
||||||
}
|
|
||||||
fmt.Printf("Development plugin symlink '%s' removed successfully (target directory preserved)\n", pluginName)
|
|
||||||
} else {
|
|
||||||
// For regular plugins, remove the entire directory
|
|
||||||
if err := os.RemoveAll(pluginDir); err != nil {
|
|
||||||
log.Fatal("Failed to remove plugin directory", "name", pluginName, err)
|
|
||||||
}
|
|
||||||
fmt.Printf("Plugin '%s' removed successfully\n", pluginName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func pluginUpdate(cmd *cobra.Command, args []string) {
|
|
||||||
ndpPath := args[0]
|
|
||||||
pluginsDir := conf.Server.Plugins.Folder
|
|
||||||
|
|
||||||
pkg, err := loadAndValidatePackage(ndpPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Package validation failed", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if plugin exists
|
|
||||||
targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name)
|
|
||||||
if !utils.FileExists(targetDir) {
|
|
||||||
log.Fatal("Plugin not found", "name", pkg.Manifest.Name, "path", targetDir,
|
|
||||||
"use", "navidrome plugin install")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a backup of the existing plugin
|
|
||||||
backupDir := targetDir + ".bak." + time.Now().Format("20060102150405")
|
|
||||||
if err := os.Rename(targetDir, backupDir); err != nil {
|
|
||||||
log.Fatal("Failed to backup existing plugin", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the new package
|
|
||||||
if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil {
|
|
||||||
// Restore backup if extraction failed
|
|
||||||
os.RemoveAll(targetDir)
|
|
||||||
_ = os.Rename(backupDir, targetDir) // Ignore error as we're already in a fatal path
|
|
||||||
log.Fatal("Plugin update failed", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the backup
|
|
||||||
os.RemoveAll(backupDir)
|
|
||||||
|
|
||||||
fmt.Printf("Plugin '%s' updated to v%s successfully\n", pkg.Manifest.Name, pkg.Manifest.Version)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pluginRefresh(cmd *cobra.Command, args []string) {
|
|
||||||
pluginName := args[0]
|
|
||||||
pluginsDir := conf.Server.Plugins.Folder
|
|
||||||
|
|
||||||
pluginDir, err := validatePluginDirectory(pluginsDir, pluginName)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Plugin validation failed", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedPath, isSymlink, err := resolvePluginPath(pluginDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Failed to resolve plugin path", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSymlink {
|
|
||||||
log.Debug("Processing symlinked plugin", "name", pluginName, "link", pluginDir, "target", resolvedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Refreshing plugin '%s'...\n", pluginName)
|
|
||||||
|
|
||||||
// Get the plugin manager and refresh
|
|
||||||
mgr := GetPluginManager(cmd.Context())
|
|
||||||
log.Debug("Scanning plugins directory", "path", pluginsDir)
|
|
||||||
mgr.ScanPlugins()
|
|
||||||
|
|
||||||
log.Info("Waiting for plugin compilation to complete", "name", pluginName)
|
|
||||||
|
|
||||||
// Wait for compilation to complete
|
|
||||||
if err := mgr.EnsureCompiled(pluginName); err != nil {
|
|
||||||
log.Fatal("Failed to compile refreshed plugin", "name", pluginName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("Plugin compilation completed successfully", "name", pluginName)
|
|
||||||
fmt.Printf("Plugin '%s' refreshed successfully\n", pluginName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pluginDev(cmd *cobra.Command, args []string) {
|
|
||||||
sourcePath, err := filepath.Abs(args[0])
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Invalid path", "path", args[0], err)
|
|
||||||
}
|
|
||||||
pluginsDir := conf.Server.Plugins.Folder
|
|
||||||
|
|
||||||
// Validate source directory and manifest
|
|
||||||
if err := validateDevSource(sourcePath); err != nil {
|
|
||||||
log.Fatal("Source validation failed", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load manifest to get plugin name
|
|
||||||
manifest, err := plugins.LoadManifest(sourcePath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Failed to load plugin manifest", "path", filepath.Join(sourcePath, "manifest.json"), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pluginName := cmp.Or(manifest.Name, filepath.Base(sourcePath))
|
|
||||||
targetPath := filepath.Join(pluginsDir, pluginName)
|
|
||||||
|
|
||||||
// Handle existing target
|
|
||||||
if err := handleExistingTarget(targetPath, sourcePath); err != nil {
|
|
||||||
log.Fatal("Failed to handle existing target", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create target directory if needed
|
|
||||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
|
||||||
log.Fatal("Failed to create plugins directory", "path", filepath.Dir(targetPath), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the symlink
|
|
||||||
if err := os.Symlink(sourcePath, targetPath); err != nil {
|
|
||||||
log.Fatal("Failed to create symlink", "source", sourcePath, "target", targetPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Development symlink created: '%s' -> '%s'\n", targetPath, sourcePath)
|
|
||||||
fmt.Println("Plugin can be refreshed with: navidrome plugin refresh", pluginName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility functions
|
|
||||||
|
|
||||||
func validateDevSource(sourcePath string) error {
|
|
||||||
sourceInfo, err := os.Stat(sourcePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("source folder not found: %s (%w)", sourcePath, err)
|
|
||||||
}
|
|
||||||
if !sourceInfo.IsDir() {
|
|
||||||
return fmt.Errorf("source path is not a directory: %s", sourcePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
manifestPath := filepath.Join(sourcePath, "manifest.json")
|
|
||||||
if !utils.FileExists(manifestPath) {
|
|
||||||
return fmt.Errorf("source folder missing manifest.json: %s", sourcePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleExistingTarget(targetPath, sourcePath string) error {
|
|
||||||
if !utils.FileExists(targetPath) {
|
|
||||||
return nil // Nothing to handle
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's already a symlink to our source
|
|
||||||
existingLink, err := os.Readlink(targetPath)
|
|
||||||
if err == nil && existingLink == sourcePath {
|
|
||||||
fmt.Printf("Symlink already exists and points to the correct source\n")
|
|
||||||
return fmt.Errorf("symlink already exists") // This will cause early return in caller
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle case where target exists but is not a symlink to our source
|
|
||||||
fmt.Printf("Target path '%s' already exists.\n", targetPath)
|
|
||||||
fmt.Print("Do you want to replace it? (y/N): ")
|
|
||||||
var response string
|
|
||||||
_, err = fmt.Scanln(&response)
|
|
||||||
if err != nil || strings.ToLower(response) != "y" {
|
|
||||||
if err != nil {
|
|
||||||
log.Debug("Error reading input, assuming 'no'", err)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("operation canceled")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove existing target
|
|
||||||
if err := os.RemoveAll(targetPath); err != nil {
|
|
||||||
return fmt.Errorf("failed to remove existing target %s: %w", targetPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensurePluginDirPermissions(dir string) {
|
|
||||||
if err := os.Chmod(dir, pluginDirPermissions); err != nil {
|
|
||||||
log.Error("Failed to set plugin directory permissions", "dir", dir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply permissions to all files in the directory
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to read plugin directory", "dir", dir, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
path := filepath.Join(dir, entry.Name())
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to stat file", "path", path, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
mode := os.FileMode(pluginFilePermissions) // Files
|
|
||||||
if info.IsDir() {
|
|
||||||
mode = os.FileMode(pluginDirPermissions) // Directories
|
|
||||||
ensurePluginDirPermissions(path) // Recursive
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Chmod(path, mode); err != nil {
|
|
||||||
log.Error("Failed to set file permissions", "path", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func calculateSHA256(filePath string) string {
|
|
||||||
file, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to open file for hashing", err)
|
|
||||||
return "N/A"
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
hasher := sha256.New()
|
|
||||||
if _, err := io.Copy(hasher, file); err != nil {
|
|
||||||
log.Error("Failed to calculate hash", err)
|
|
||||||
return "N/A"
|
|
||||||
}
|
|
||||||
|
|
||||||
return hex.EncodeToString(hasher.Sum(nil))
|
|
||||||
}
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("Plugin CLI Commands", func() {
|
|
||||||
var tempDir string
|
|
||||||
var cmd *cobra.Command
|
|
||||||
var stdOut *os.File
|
|
||||||
var origStdout *os.File
|
|
||||||
var outReader *os.File
|
|
||||||
|
|
||||||
// Helper to create a test plugin with the given name and details
|
|
||||||
createTestPlugin := func(name, author, version string, capabilities []string) string {
|
|
||||||
pluginDir := filepath.Join(tempDir, name)
|
|
||||||
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
|
|
||||||
|
|
||||||
// Create a properly formatted capabilities JSON array
|
|
||||||
capabilitiesJSON := `"` + strings.Join(capabilities, `", "`) + `"`
|
|
||||||
|
|
||||||
manifest := `{
|
|
||||||
"name": "` + name + `",
|
|
||||||
"author": "` + author + `",
|
|
||||||
"version": "` + version + `",
|
|
||||||
"description": "Plugin for testing",
|
|
||||||
"website": "https://test.navidrome.org/` + name + `",
|
|
||||||
"capabilities": [` + capabilitiesJSON + `],
|
|
||||||
"permissions": {}
|
|
||||||
}`
|
|
||||||
|
|
||||||
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
|
||||||
|
|
||||||
// Create a dummy WASM file
|
|
||||||
wasmContent := []byte("dummy wasm content for testing")
|
|
||||||
Expect(os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
|
|
||||||
|
|
||||||
return pluginDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to execute a command and return captured output
|
|
||||||
captureOutput := func(reader io.Reader) string {
|
|
||||||
stdOut.Close()
|
|
||||||
outputBytes, err := io.ReadAll(reader)
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
return string(outputBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
tempDir = GinkgoT().TempDir()
|
|
||||||
|
|
||||||
// Setup config
|
|
||||||
conf.Server.Plugins.Enabled = true
|
|
||||||
conf.Server.Plugins.Folder = tempDir
|
|
||||||
|
|
||||||
// Create a command for testing
|
|
||||||
cmd = &cobra.Command{Use: "test"}
|
|
||||||
|
|
||||||
// Setup stdout capture
|
|
||||||
origStdout = os.Stdout
|
|
||||||
var err error
|
|
||||||
outReader, stdOut, err = os.Pipe()
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
os.Stdout = stdOut
|
|
||||||
|
|
||||||
DeferCleanup(func() {
|
|
||||||
os.Stdout = origStdout
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
AfterEach(func() {
|
|
||||||
os.Stdout = origStdout
|
|
||||||
if stdOut != nil {
|
|
||||||
stdOut.Close()
|
|
||||||
}
|
|
||||||
if outReader != nil {
|
|
||||||
outReader.Close()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Plugin list command", func() {
|
|
||||||
It("should list installed plugins", func() {
|
|
||||||
// Create test plugins
|
|
||||||
createTestPlugin("plugin1", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
|
||||||
createTestPlugin("plugin2", "Another Author", "2.1.0", []string{"Scrobbler"})
|
|
||||||
|
|
||||||
// Execute command
|
|
||||||
pluginList(cmd, []string{})
|
|
||||||
|
|
||||||
// Verify output
|
|
||||||
output := captureOutput(outReader)
|
|
||||||
|
|
||||||
Expect(output).To(ContainSubstring("plugin1"))
|
|
||||||
Expect(output).To(ContainSubstring("Test Author"))
|
|
||||||
Expect(output).To(ContainSubstring("1.0.0"))
|
|
||||||
Expect(output).To(ContainSubstring("MetadataAgent"))
|
|
||||||
|
|
||||||
Expect(output).To(ContainSubstring("plugin2"))
|
|
||||||
Expect(output).To(ContainSubstring("Another Author"))
|
|
||||||
Expect(output).To(ContainSubstring("2.1.0"))
|
|
||||||
Expect(output).To(ContainSubstring("Scrobbler"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Plugin info command", func() {
|
|
||||||
It("should display information about an installed plugin", func() {
|
|
||||||
// Create test plugin with multiple capabilities
|
|
||||||
createTestPlugin("test-plugin", "Test Author", "1.0.0",
|
|
||||||
[]string{"MetadataAgent", "Scrobbler"})
|
|
||||||
|
|
||||||
// Execute command
|
|
||||||
pluginInfo(cmd, []string{"test-plugin"})
|
|
||||||
|
|
||||||
// Verify output
|
|
||||||
output := captureOutput(outReader)
|
|
||||||
|
|
||||||
Expect(output).To(ContainSubstring("Name: test-plugin"))
|
|
||||||
Expect(output).To(ContainSubstring("Author: Test Author"))
|
|
||||||
Expect(output).To(ContainSubstring("Version: 1.0.0"))
|
|
||||||
Expect(output).To(ContainSubstring("Description: Plugin for testing"))
|
|
||||||
Expect(output).To(ContainSubstring("Capabilities: MetadataAgent, Scrobbler"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Plugin remove command", func() {
|
|
||||||
It("should remove a regular plugin directory", func() {
|
|
||||||
// Create test plugin
|
|
||||||
pluginDir := createTestPlugin("regular-plugin", "Test Author", "1.0.0",
|
|
||||||
[]string{"MetadataAgent"})
|
|
||||||
|
|
||||||
// Execute command
|
|
||||||
pluginRemove(cmd, []string{"regular-plugin"})
|
|
||||||
|
|
||||||
// Verify output
|
|
||||||
output := captureOutput(outReader)
|
|
||||||
Expect(output).To(ContainSubstring("Plugin 'regular-plugin' removed successfully"))
|
|
||||||
|
|
||||||
// Verify directory is actually removed
|
|
||||||
_, err := os.Stat(pluginDir)
|
|
||||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("should remove only the symlink for a development plugin", func() {
|
|
||||||
// Create a real source directory
|
|
||||||
sourceDir := filepath.Join(GinkgoT().TempDir(), "dev-plugin-source")
|
|
||||||
Expect(os.MkdirAll(sourceDir, 0755)).To(Succeed())
|
|
||||||
|
|
||||||
manifest := `{
|
|
||||||
"name": "dev-plugin",
|
|
||||||
"author": "Dev Author",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Development plugin for testing",
|
|
||||||
"website": "https://test.navidrome.org/dev-plugin",
|
|
||||||
"capabilities": ["Scrobbler"],
|
|
||||||
"permissions": {}
|
|
||||||
}`
|
|
||||||
Expect(os.WriteFile(filepath.Join(sourceDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
|
||||||
|
|
||||||
// Create a dummy WASM file
|
|
||||||
wasmContent := []byte("dummy wasm content for testing")
|
|
||||||
Expect(os.WriteFile(filepath.Join(sourceDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
|
|
||||||
|
|
||||||
// Create a symlink in the plugins directory
|
|
||||||
symlinkPath := filepath.Join(tempDir, "dev-plugin")
|
|
||||||
Expect(os.Symlink(sourceDir, symlinkPath)).To(Succeed())
|
|
||||||
|
|
||||||
// Execute command
|
|
||||||
pluginRemove(cmd, []string{"dev-plugin"})
|
|
||||||
|
|
||||||
// Verify output
|
|
||||||
output := captureOutput(outReader)
|
|
||||||
Expect(output).To(ContainSubstring("Development plugin symlink 'dev-plugin' removed successfully"))
|
|
||||||
Expect(output).To(ContainSubstring("target directory preserved"))
|
|
||||||
|
|
||||||
// Verify the symlink is removed but source directory exists
|
|
||||||
_, err := os.Lstat(symlinkPath)
|
|
||||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
|
||||||
|
|
||||||
_, err = os.Stat(sourceDir)
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
18
cmd/root.go
18
cmd/root.go
@ -9,7 +9,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
_ "github.com/navidrome/navidrome/adapters/taglib"
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
@ -22,6 +21,14 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
|
// Import adapters to register them
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/deezer"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/taglib"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -330,16 +337,13 @@ func startPlaybackServer(ctx context.Context) func() error {
|
|||||||
// startPluginManager starts the plugin manager, if configured.
|
// startPluginManager starts the plugin manager, if configured.
|
||||||
func startPluginManager(ctx context.Context) func() error {
|
func startPluginManager(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
|
manager := GetPluginManager(ctx)
|
||||||
if !conf.Server.Plugins.Enabled {
|
if !conf.Server.Plugins.Enabled {
|
||||||
log.Debug("Plugins are DISABLED")
|
log.Debug("Plugin system is DISABLED")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log.Info(ctx, "Starting plugin manager")
|
log.Info(ctx, "Starting plugin manager")
|
||||||
// Get the manager instance and scan for plugins
|
return manager.Start(ctx)
|
||||||
manager := GetPluginManager(ctx)
|
|
||||||
manager.ScanPlugins()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,12 +8,11 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
|
"github.com/navidrome/navidrome/adapters/lastfm"
|
||||||
|
"github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
|
||||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/external"
|
"github.com/navidrome/navidrome/core/external"
|
||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
@ -30,7 +29,14 @@ import (
|
|||||||
"github.com/navidrome/navidrome/server/nativeapi"
|
"github.com/navidrome/navidrome/server/nativeapi"
|
||||||
"github.com/navidrome/navidrome/server/public"
|
"github.com/navidrome/navidrome/server/public"
|
||||||
"github.com/navidrome/navidrome/server/subsonic"
|
"github.com/navidrome/navidrome/server/subsonic"
|
||||||
|
)
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/deezer"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||||
_ "github.com/navidrome/navidrome/adapters/taglib"
|
_ "github.com/navidrome/navidrome/adapters/taglib"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -46,9 +52,7 @@ func CreateServer() *server.Server {
|
|||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
insights := metrics.GetInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
|
||||||
insights := metrics.GetInstance(dataStore, manager)
|
|
||||||
serverServer := server.New(dataStore, broker, insights)
|
serverServer := server.New(dataStore, broker, insights)
|
||||||
return serverServer
|
return serverServer
|
||||||
}
|
}
|
||||||
@ -58,21 +62,22 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
|||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
playlists := core.NewPlaylists(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
insights := metrics.GetInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
|
||||||
insights := metrics.GetInstance(dataStore, manager)
|
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
|
broker := events.GetBroker()
|
||||||
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
broker := events.GetBroker()
|
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker)
|
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||||
|
user := core.NewUser(dataStore, manager)
|
||||||
maintenance := core.NewMaintenance(dataStore)
|
maintenance := core.NewMaintenance(dataStore)
|
||||||
router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance)
|
router := nativeapi.New(dataStore, share, playlists, insights, library, user, maintenance, manager)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,8 +86,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
@ -92,7 +98,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||||
players := core.NewPlayers(dataStore)
|
players := core.NewPlayers(dataStore)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
broker := events.GetBroker()
|
|
||||||
playlists := core.NewPlaylists(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||||
@ -106,8 +111,9 @@ func CreatePublicRouter() *public.Router {
|
|||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
@ -136,9 +142,7 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
|||||||
func CreateInsights() metrics.Insights {
|
func CreateInsights() metrics.Insights {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
insights := metrics.GetInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
|
||||||
insights := metrics.GetInstance(dataStore, manager)
|
|
||||||
return insights
|
return insights
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,13 +158,13 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
|||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
broker := events.GetBroker()
|
|
||||||
playlists := core.NewPlaylists(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
return modelScanner
|
return modelScanner
|
||||||
@ -171,13 +175,13 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
|||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
broker := events.GetBroker()
|
|
||||||
playlists := core.NewPlaylists(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||||
@ -191,19 +195,20 @@ func GetPlaybackServer() playback.PlaybackServer {
|
|||||||
return playbackServer
|
return playbackServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPluginManager() plugins.Manager {
|
func getPluginManager() *plugins.Manager {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// wire_injectors.go:
|
// 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.GetWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||||
|
|
||||||
func GetPluginManager(ctx context.Context) plugins.Manager {
|
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||||
manager := getPluginManager()
|
manager := getPluginManager()
|
||||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||||
return manager
|
return manager
|
||||||
|
|||||||
@ -6,10 +6,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
|
"github.com/navidrome/navidrome/adapters/lastfm"
|
||||||
|
"github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
|
||||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
@ -39,12 +39,14 @@ var allProviders = wire.NewSet(
|
|||||||
events.GetBroker,
|
events.GetBroker,
|
||||||
scanner.New,
|
scanner.New,
|
||||||
scanner.GetWatcher,
|
scanner.GetWatcher,
|
||||||
plugins.GetManager,
|
|
||||||
metrics.GetPrometheusInstance,
|
metrics.GetPrometheusInstance,
|
||||||
db.Db,
|
db.Db,
|
||||||
wire.Bind(new(agents.PluginLoader), new(plugins.Manager)),
|
plugins.GetManager,
|
||||||
wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)),
|
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
||||||
wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)),
|
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
||||||
|
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
|
||||||
|
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
|
||||||
|
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
|
||||||
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
|
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -120,13 +122,13 @@ func GetPlaybackServer() playback.PlaybackServer {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPluginManager() plugins.Manager {
|
func getPluginManager() *plugins.Manager {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetPluginManager(ctx context.Context) plugins.Manager {
|
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||||
manager := getPluginManager()
|
manager := getPluginManager()
|
||||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||||
return manager
|
return manager
|
||||||
|
|||||||
@ -89,7 +89,6 @@ type configOptions struct {
|
|||||||
PasswordEncryptionKey string
|
PasswordEncryptionKey string
|
||||||
ExtAuth extAuthOptions
|
ExtAuth extAuthOptions
|
||||||
Plugins pluginsOptions
|
Plugins pluginsOptions
|
||||||
PluginConfig map[string]map[string]string
|
|
||||||
HTTPHeaders httpHeaderOptions `json:",omitzero"`
|
HTTPHeaders httpHeaderOptions `json:",omitzero"`
|
||||||
Prometheus prometheusOptions `json:",omitzero"`
|
Prometheus prometheusOptions `json:",omitzero"`
|
||||||
Scanner scannerOptions `json:",omitzero"`
|
Scanner scannerOptions `json:",omitzero"`
|
||||||
@ -153,7 +152,9 @@ type subsonicOptions struct {
|
|||||||
AppendSubtitle bool
|
AppendSubtitle bool
|
||||||
ArtistParticipations bool
|
ArtistParticipations bool
|
||||||
DefaultReportRealPath bool
|
DefaultReportRealPath bool
|
||||||
|
EnableAverageRating bool
|
||||||
LegacyClients string
|
LegacyClients string
|
||||||
|
MinimalClients string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TagConf struct {
|
type TagConf struct {
|
||||||
@ -226,9 +227,11 @@ type inspectOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type pluginsOptions struct {
|
type pluginsOptions struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Folder string
|
Folder string
|
||||||
CacheSize string
|
CacheSize string
|
||||||
|
AutoReload bool
|
||||||
|
LogLevel string
|
||||||
}
|
}
|
||||||
|
|
||||||
type extAuthOptions struct {
|
type extAuthOptions struct {
|
||||||
@ -364,10 +367,6 @@ func Load(noConfigDump bool) {
|
|||||||
disableExternalServices()
|
disableExternalServices()
|
||||||
}
|
}
|
||||||
|
|
||||||
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
|
|
||||||
log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
|
|
||||||
Server.Scanner.Extractor = consts.DefaultScannerExtractor
|
|
||||||
}
|
|
||||||
logDeprecatedOptions("Scanner.GenreSeparators", "")
|
logDeprecatedOptions("Scanner.GenreSeparators", "")
|
||||||
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
|
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
|
||||||
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||||
@ -607,6 +606,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||||
viper.SetDefault("subsonic.artistparticipations", false)
|
viper.SetDefault("subsonic.artistparticipations", false)
|
||||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||||
|
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||||
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
|
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
|
||||||
viper.SetDefault("agents", "lastfm,spotify,deezer")
|
viper.SetDefault("agents", "lastfm,spotify,deezer")
|
||||||
viper.SetDefault("lastfm.enabled", true)
|
viper.SetDefault("lastfm.enabled", true)
|
||||||
@ -633,7 +633,8 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||||
viper.SetDefault("plugins.folder", "")
|
viper.SetDefault("plugins.folder", "")
|
||||||
viper.SetDefault("plugins.enabled", false)
|
viper.SetDefault("plugins.enabled", false)
|
||||||
viper.SetDefault("plugins.cachesize", "100MB")
|
viper.SetDefault("plugins.cachesize", "200MB")
|
||||||
|
viper.SetDefault("plugins.autoreload", false)
|
||||||
|
|
||||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||||
viper.SetDefault("devlogsourceline", false)
|
viper.SetDefault("devlogsourceline", false)
|
||||||
|
|||||||
@ -150,6 +150,8 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var HTTPUserAgent = "Navidrome" + "/" + Version
|
||||||
|
|
||||||
var (
|
var (
|
||||||
VariousArtists = "Various Artists"
|
VariousArtists = "Various Artists"
|
||||||
// TODO This will be dynamic when using disambiguation
|
// TODO This will be dynamic when using disambiguation
|
||||||
|
|||||||
@ -64,6 +64,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
|||||||
if a.pluginLoader != nil {
|
if a.pluginLoader != nil {
|
||||||
availablePlugins = a.pluginLoader.PluginNames("MetadataAgent")
|
availablePlugins = a.pluginLoader.PluginNames("MetadataAgent")
|
||||||
}
|
}
|
||||||
|
log.Trace("Available MetadataAgent plugins", "plugins", availablePlugins)
|
||||||
|
|
||||||
configuredAgents := strings.Split(conf.Server.Agents, ",")
|
configuredAgents := strings.Split(conf.Server.Agents, ",")
|
||||||
|
|
||||||
@ -354,6 +355,9 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
|
images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
|
||||||
|
if err != nil {
|
||||||
|
log.Trace(ctx, "Agent GetAlbumImages failed", "agent", ag.AgentName(), "album", name, "artist", artist, "mbid", mbid, err)
|
||||||
|
}
|
||||||
if len(images) > 0 && err == nil {
|
if len(images) > 0 && err == nil {
|
||||||
log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist,
|
log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist,
|
||||||
"mbid", mbid, "elapsed", time.Since(start))
|
"mbid", mbid, "elapsed", time.Since(start))
|
||||||
|
|||||||
@ -22,6 +22,7 @@ type AlbumInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
|
ID string
|
||||||
Name string
|
Name string
|
||||||
MBID string
|
MBID string
|
||||||
}
|
}
|
||||||
@ -32,6 +33,7 @@ type ExternalImage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Song struct {
|
type Song struct {
|
||||||
|
ID string
|
||||||
Name string
|
Name string
|
||||||
MBID string
|
MBID string
|
||||||
}
|
}
|
||||||
|
|||||||
@ -182,6 +182,7 @@ func fromAlbumExternalSource(ctx context.Context, al model.Album, provider exter
|
|||||||
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
|
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
|
||||||
hc := http.Client{Timeout: 5 * time.Second}
|
hc := http.Client{Timeout: 5 * time.Second}
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
|
||||||
|
req.Header.Set("User-Agent", consts.HTTPUserAgent)
|
||||||
resp, err := hc.Do(req)
|
resp, err := hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
|
|||||||
201
core/external/provider.go
vendored
201
core/external/provider.go
vendored
@ -12,10 +12,6 @@ import (
|
|||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
_ "github.com/navidrome/navidrome/core/agents/deezer"
|
|
||||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
|
||||||
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
|
|
||||||
_ "github.com/navidrome/navidrome/core/agents/spotify"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
@ -426,17 +422,21 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
|
|||||||
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
|
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
idMatches, err := e.loadTracksByID(ctx, songs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
|
||||||
|
}
|
||||||
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
|
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||||
}
|
}
|
||||||
titleMatches, err := e.loadTracksByTitle(ctx, songs, artist, mbidMatches)
|
titleMatches, err := e.loadTracksByTitle(ctx, songs, artist, idMatches, mbidMatches)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
|
log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numIDMatches", len(idMatches), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
|
||||||
mfs := e.selectTopSongs(songs, mbidMatches, titleMatches, count)
|
mfs := e.selectTopSongs(songs, idMatches, mbidMatches, titleMatches, count)
|
||||||
|
|
||||||
if len(mfs) == 0 {
|
if len(mfs) == 0 {
|
||||||
log.Debug(ctx, "No matching top songs found", "name", artistName)
|
log.Debug(ctx, "No matching top songs found", "name", artistName)
|
||||||
@ -477,9 +477,41 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (m
|
|||||||
return matches, nil
|
return matches, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||||
|
var ids []string
|
||||||
|
for _, s := range songs {
|
||||||
|
if s.ID != "" {
|
||||||
|
ids = append(ids, s.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches := map[string]model.MediaFile{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||||
|
Filters: squirrel.And{
|
||||||
|
squirrel.Eq{"media_file.id": ids},
|
||||||
|
squirrel.Eq{"missing": false},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return matches, err
|
||||||
|
}
|
||||||
|
for _, mf := range res {
|
||||||
|
if _, ok := matches[mf.ID]; !ok {
|
||||||
|
matches[mf.ID] = mf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, idMatches, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||||
titleMap := map[string]string{}
|
titleMap := map[string]string{}
|
||||||
for _, s := range songs {
|
for _, s := range songs {
|
||||||
|
// Skip if already matched by ID or MBID
|
||||||
|
if s.ID != "" && idMatches[s.ID].ID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
|
if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -518,18 +550,27 @@ func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, a
|
|||||||
return matches, nil
|
return matches, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *provider) selectTopSongs(songs []agents.Song, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles {
|
func (e *provider) selectTopSongs(songs []agents.Song, byID, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles {
|
||||||
var mfs model.MediaFiles
|
var mfs model.MediaFiles
|
||||||
for _, t := range songs {
|
for _, t := range songs {
|
||||||
if len(mfs) == count {
|
if len(mfs) == count {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
// Try ID match first
|
||||||
|
if t.ID != "" {
|
||||||
|
if mf, ok := byID[t.ID]; ok {
|
||||||
|
mfs = append(mfs, mf)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try MBID match second
|
||||||
if t.MBID != "" {
|
if t.MBID != "" {
|
||||||
if mf, ok := byMBID[t.MBID]; ok {
|
if mf, ok := byMBID[t.MBID]; ok {
|
||||||
mfs = append(mfs, mf)
|
mfs = append(mfs, mf)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Fall back to title match
|
||||||
if mf, ok := byTitle[str.SanitizeFieldForSorting(t.Name)]; ok {
|
if mf, ok := byTitle[str.SanitizeFieldForSorting(t.Name)]; ok {
|
||||||
mfs = append(mfs, mf)
|
mfs = append(mfs, mf)
|
||||||
}
|
}
|
||||||
@ -593,36 +634,51 @@ func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artis
|
|||||||
var result model.Artists
|
var result model.Artists
|
||||||
var notPresent []string
|
var notPresent []string
|
||||||
|
|
||||||
artistNames := slice.Map(similar, func(artist agents.Artist) string { return artist.Name })
|
// Load artists by ID (highest priority)
|
||||||
|
idMatches, err := e.loadArtistsByID(ctx, similar)
|
||||||
// Query all artists at once
|
|
||||||
clauses := slice.Map(artistNames, func(name string) squirrel.Sqlizer {
|
|
||||||
return squirrel.Like{"artist.name": name}
|
|
||||||
})
|
|
||||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
|
||||||
Filters: squirrel.Or(clauses),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a map for quick lookup
|
// Load artists by MBID (second priority)
|
||||||
artistMap := make(map[string]model.Artist)
|
mbidMatches, err := e.loadArtistsByMBID(ctx, similar, idMatches)
|
||||||
for _, artist := range artists {
|
if err != nil {
|
||||||
artistMap[artist.Name] = artist
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load artists by name (lowest priority, fallback)
|
||||||
|
nameMatches, err := e.loadArtistsByName(ctx, similar, idMatches, mbidMatches)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
|
|
||||||
// Process the similar artists
|
// Process the similar artists using priority: ID → MBID → Name
|
||||||
for _, s := range similar {
|
for _, s := range similar {
|
||||||
if artist, found := artistMap[s.Name]; found {
|
if count >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Try ID match first
|
||||||
|
if s.ID != "" {
|
||||||
|
if artist, found := idMatches[s.ID]; found {
|
||||||
|
result = append(result, artist)
|
||||||
|
count++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try MBID match second
|
||||||
|
if s.MBID != "" {
|
||||||
|
if artist, found := mbidMatches[s.MBID]; found {
|
||||||
|
result = append(result, artist)
|
||||||
|
count++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to name match
|
||||||
|
if artist, found := nameMatches[s.Name]; found {
|
||||||
result = append(result, artist)
|
result = append(result, artist)
|
||||||
count++
|
count++
|
||||||
|
|
||||||
if count >= limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
notPresent = append(notPresent, s.Name)
|
notPresent = append(notPresent, s.Name)
|
||||||
}
|
}
|
||||||
@ -645,6 +701,95 @@ func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artis
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *provider) loadArtistsByID(ctx context.Context, similar []agents.Artist) (map[string]model.Artist, error) {
|
||||||
|
var ids []string
|
||||||
|
for _, s := range similar {
|
||||||
|
if s.ID != "" {
|
||||||
|
ids = append(ids, s.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches := map[string]model.Artist{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||||
|
Filters: squirrel.Eq{"artist.id": ids},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return matches, err
|
||||||
|
}
|
||||||
|
for _, a := range res {
|
||||||
|
if _, ok := matches[a.ID]; !ok {
|
||||||
|
matches[a.ID] = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *provider) loadArtistsByMBID(ctx context.Context, similar []agents.Artist, idMatches map[string]model.Artist) (map[string]model.Artist, error) {
|
||||||
|
var mbids []string
|
||||||
|
for _, s := range similar {
|
||||||
|
// Skip if already matched by ID
|
||||||
|
if s.ID != "" && idMatches[s.ID].ID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.MBID != "" {
|
||||||
|
mbids = append(mbids, s.MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches := map[string]model.Artist{}
|
||||||
|
if len(mbids) == 0 {
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||||
|
Filters: squirrel.Eq{"mbz_artist_id": mbids},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return matches, err
|
||||||
|
}
|
||||||
|
for _, a := range res {
|
||||||
|
if id := a.MbzArtistID; id != "" {
|
||||||
|
if _, ok := matches[id]; !ok {
|
||||||
|
matches[id] = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artist, idMatches map[string]model.Artist, mbidMatches map[string]model.Artist) (map[string]model.Artist, error) {
|
||||||
|
var names []string
|
||||||
|
for _, s := range similar {
|
||||||
|
// Skip if already matched by ID or MBID
|
||||||
|
if s.ID != "" && idMatches[s.ID].ID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, s.Name)
|
||||||
|
}
|
||||||
|
matches := map[string]model.Artist{}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
clauses := slice.Map(names, func(name string) squirrel.Sqlizer {
|
||||||
|
return squirrel.Like{"artist.name": name}
|
||||||
|
})
|
||||||
|
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||||
|
Filters: squirrel.Or(clauses),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return matches, err
|
||||||
|
}
|
||||||
|
for _, a := range res {
|
||||||
|
if _, ok := matches[a.Name]; !ok {
|
||||||
|
matches[a.Name] = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (e *provider) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
|
func (e *provider) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
|
||||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.Like{"artist.name": artistName},
|
Filters: squirrel.Like{"artist.name": artistName},
|
||||||
|
|||||||
11
core/external/provider_artistradio_test.go
vendored
11
core/external/provider_artistradio_test.go
vendored
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
. "github.com/navidrome/navidrome/core/external"
|
. "github.com/navidrome/navidrome/core/external"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
@ -67,8 +68,16 @@ var _ = Describe("Provider - ArtistRadio", func() {
|
|||||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||||
Return(similarAgentsResp, nil).Once()
|
Return(similarAgentsResp, nil).Once()
|
||||||
|
|
||||||
|
// Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name
|
||||||
|
// MBID lookup returns empty (no match)
|
||||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||||
return opt.Max == 0 && opt.Filters != nil
|
_, ok := opt.Filters.(squirrel.Eq)
|
||||||
|
return opt.Max == 0 && ok
|
||||||
|
})).Return(model.Artists{}, nil).Once()
|
||||||
|
// Name lookup returns the similar artist
|
||||||
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||||
|
_, ok := opt.Filters.(squirrel.Or)
|
||||||
|
return opt.Max == 0 && ok
|
||||||
})).Return(model.Artists{similarArtist}, nil).Once()
|
})).Return(model.Artists{similarArtist}, nil).Once()
|
||||||
|
|
||||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||||
|
|||||||
62
core/external/provider_topsongs_test.go
vendored
62
core/external/provider_topsongs_test.go
vendored
@ -4,10 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
|
||||||
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
|
|
||||||
_ "github.com/navidrome/navidrome/core/agents/spotify"
|
|
||||||
. "github.com/navidrome/navidrome/core/external"
|
. "github.com/navidrome/navidrome/core/external"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
@ -271,4 +271,60 @@ var _ = Describe("Provider - TopSongs", func() {
|
|||||||
ag.AssertExpectations(GinkgoT())
|
ag.AssertExpectations(GinkgoT())
|
||||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("matches songs by ID first when agent provides IDs", func() {
|
||||||
|
// Mock finding the artist
|
||||||
|
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||||
|
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||||
|
|
||||||
|
// Mock agent response with IDs provided (highest priority matching)
|
||||||
|
// Note: Songs have no MBID to ensure only ID matching is used
|
||||||
|
agentSongs := []agents.Song{
|
||||||
|
{ID: "song-1", Name: "Song One"},
|
||||||
|
{ID: "song-2", Name: "Song Two"},
|
||||||
|
}
|
||||||
|
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
|
||||||
|
|
||||||
|
// Mock ID lookup (first query - should match both songs directly)
|
||||||
|
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
|
||||||
|
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"}
|
||||||
|
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
||||||
|
|
||||||
|
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(songs).To(HaveLen(2))
|
||||||
|
Expect(songs[0].ID).To(Equal("song-1"))
|
||||||
|
Expect(songs[1].ID).To(Equal("song-2"))
|
||||||
|
artistRepo.AssertExpectations(GinkgoT())
|
||||||
|
ag.AssertExpectations(GinkgoT())
|
||||||
|
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to MBID when ID is not found", func() {
|
||||||
|
// Mock finding the artist
|
||||||
|
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||||
|
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||||
|
|
||||||
|
// Mock agent response with ID that won't be found, but MBID that will
|
||||||
|
agentSongs := []agents.Song{
|
||||||
|
{ID: "non-existent-id", Name: "Song One", MBID: "mbid-song-1"},
|
||||||
|
}
|
||||||
|
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
|
||||||
|
|
||||||
|
// Mock ID lookup - returns empty (ID not found)
|
||||||
|
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once()
|
||||||
|
// Mock MBID lookup - finds the song
|
||||||
|
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||||
|
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||||
|
|
||||||
|
songs, err := p.TopSongs(ctx, "Artist One", 1)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(songs).To(HaveLen(1))
|
||||||
|
Expect(songs[0].ID).To(Equal("song-1"))
|
||||||
|
artistRepo.AssertExpectations(GinkgoT())
|
||||||
|
ag.AssertExpectations(GinkgoT())
|
||||||
|
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
84
core/external/provider_updateartistinfo_test.go
vendored
84
core/external/provider_updateartistinfo_test.go
vendored
@ -226,4 +226,88 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
|||||||
Expect(updatedArtist.ID).To(Equal("ar-agent-fail"))
|
Expect(updatedArtist.ID).To(Equal("ar-agent-fail"))
|
||||||
ag.AssertExpectations(GinkgoT())
|
ag.AssertExpectations(GinkgoT())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("matches similar artists by ID first when agent provides IDs", func() {
|
||||||
|
originalArtist := &model.Artist{
|
||||||
|
ID: "ar-id-match",
|
||||||
|
Name: "ID Match Artist",
|
||||||
|
}
|
||||||
|
similarByID := model.Artist{ID: "ar-similar-by-id", Name: "Similar By ID", MbzArtistID: "mbid-similar"}
|
||||||
|
mockArtistRepo.SetData(model.Artists{*originalArtist, similarByID})
|
||||||
|
|
||||||
|
// Agent returns similar artist with ID (highest priority matching)
|
||||||
|
rawSimilar := []agents.Artist{
|
||||||
|
{ID: "ar-similar-by-id", Name: "Different Name", MBID: "different-mbid"},
|
||||||
|
}
|
||||||
|
|
||||||
|
ag.On("GetArtistMBID", ctx, "ar-id-match", "ID Match Artist").Return("", nil).Once()
|
||||||
|
ag.On("GetArtistImages", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return(nil, nil).Maybe()
|
||||||
|
ag.On("GetArtistBiography", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return("", nil).Maybe()
|
||||||
|
ag.On("GetArtistURL", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return("", nil).Maybe()
|
||||||
|
ag.On("GetSimilarArtists", ctx, "ar-id-match", "ID Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()
|
||||||
|
|
||||||
|
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-id-match", 10, false)
|
||||||
|
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||||
|
// Should match by ID, not by name or MBID
|
||||||
|
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-id"))
|
||||||
|
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By ID"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("matches similar artists by MBID when ID is empty", func() {
|
||||||
|
originalArtist := &model.Artist{
|
||||||
|
ID: "ar-mbid-match",
|
||||||
|
Name: "MBID Match Artist",
|
||||||
|
}
|
||||||
|
similarByMBID := model.Artist{ID: "ar-similar-by-mbid", Name: "Similar By MBID", MbzArtistID: "mbid-similar"}
|
||||||
|
mockArtistRepo.SetData(model.Artists{*originalArtist, similarByMBID})
|
||||||
|
|
||||||
|
// Agent returns similar artist with only MBID (no ID)
|
||||||
|
rawSimilar := []agents.Artist{
|
||||||
|
{Name: "Different Name", MBID: "mbid-similar"},
|
||||||
|
}
|
||||||
|
|
||||||
|
ag.On("GetArtistMBID", ctx, "ar-mbid-match", "MBID Match Artist").Return("", nil).Once()
|
||||||
|
ag.On("GetArtistImages", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return(nil, nil).Maybe()
|
||||||
|
ag.On("GetArtistBiography", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return("", nil).Maybe()
|
||||||
|
ag.On("GetArtistURL", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return("", nil).Maybe()
|
||||||
|
ag.On("GetSimilarArtists", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()
|
||||||
|
|
||||||
|
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-mbid-match", 10, false)
|
||||||
|
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||||
|
// Should match by MBID since ID was empty
|
||||||
|
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-mbid"))
|
||||||
|
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By MBID"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to name matching when ID and MBID don't match", func() {
|
||||||
|
originalArtist := &model.Artist{
|
||||||
|
ID: "ar-name-match",
|
||||||
|
Name: "Name Match Artist",
|
||||||
|
}
|
||||||
|
similarByName := model.Artist{ID: "ar-similar-by-name", Name: "Similar By Name"}
|
||||||
|
mockArtistRepo.SetData(model.Artists{*originalArtist, similarByName})
|
||||||
|
|
||||||
|
// Agent returns similar artist with non-matching ID and MBID
|
||||||
|
rawSimilar := []agents.Artist{
|
||||||
|
{ID: "non-existent-id", Name: "Similar By Name", MBID: "non-existent-mbid"},
|
||||||
|
}
|
||||||
|
|
||||||
|
ag.On("GetArtistMBID", ctx, "ar-name-match", "Name Match Artist").Return("", nil).Once()
|
||||||
|
ag.On("GetArtistImages", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return(nil, nil).Maybe()
|
||||||
|
ag.On("GetArtistBiography", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return("", nil).Maybe()
|
||||||
|
ag.On("GetArtistURL", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return("", nil).Maybe()
|
||||||
|
ag.On("GetSimilarArtists", ctx, "ar-name-match", "Name Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()
|
||||||
|
|
||||||
|
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-name-match", 10, false)
|
||||||
|
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||||
|
// Should fall back to name matching since ID and MBID didn't match
|
||||||
|
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-name"))
|
||||||
|
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By Name"))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -37,19 +37,21 @@ type Library interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type libraryService struct {
|
type libraryService struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
scanner model.Scanner
|
scanner model.Scanner
|
||||||
watcher Watcher
|
watcher Watcher
|
||||||
broker events.Broker
|
broker events.Broker
|
||||||
|
pluginManager PluginUnloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLibrary creates a new Library service
|
// NewLibrary creates a new Library service
|
||||||
func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker) Library {
|
func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker, pluginManager PluginUnloader) Library {
|
||||||
return &libraryService{
|
return &libraryService{
|
||||||
ds: ds,
|
ds: ds,
|
||||||
scanner: scanner,
|
scanner: scanner,
|
||||||
watcher: watcher,
|
watcher: watcher,
|
||||||
broker: broker,
|
broker: broker,
|
||||||
|
pluginManager: pluginManager,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,6 +143,7 @@ func (s *libraryService) NewRepository(ctx context.Context) rest.Repository {
|
|||||||
scanner: s.scanner,
|
scanner: s.scanner,
|
||||||
watcher: s.watcher,
|
watcher: s.watcher,
|
||||||
broker: s.broker,
|
broker: s.broker,
|
||||||
|
pluginManager: s.pluginManager,
|
||||||
}
|
}
|
||||||
return wrapper
|
return wrapper
|
||||||
}
|
}
|
||||||
@ -148,11 +151,12 @@ func (s *libraryService) NewRepository(ctx context.Context) rest.Repository {
|
|||||||
type libraryRepositoryWrapper struct {
|
type libraryRepositoryWrapper struct {
|
||||||
rest.Repository
|
rest.Repository
|
||||||
model.LibraryRepository
|
model.LibraryRepository
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
scanner model.Scanner
|
scanner model.Scanner
|
||||||
watcher Watcher
|
watcher Watcher
|
||||||
broker events.Broker
|
broker events.Broker
|
||||||
|
pluginManager PluginUnloader
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
|
func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||||
@ -272,6 +276,10 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
|
|||||||
log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name)
|
log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After successful deletion, check if any plugins were auto-disabled
|
||||||
|
// and need to be unloaded from memory
|
||||||
|
r.pluginManager.UnloadDisabledPlugins(r.ctx)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
_ "github.com/navidrome/navidrome/adapters/taglib" // Register taglib extractor
|
_ "github.com/navidrome/navidrome/adapters/gotaglib" // Register taglib extractor
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
_ "github.com/navidrome/navidrome/core/storage/local" // Register local storage
|
_ "github.com/navidrome/navidrome/core/storage/local" // Register local storage
|
||||||
@ -32,6 +32,7 @@ var _ = Describe("Library Service", func() {
|
|||||||
var scanner *tests.MockScanner
|
var scanner *tests.MockScanner
|
||||||
var watcherManager *mockWatcherManager
|
var watcherManager *mockWatcherManager
|
||||||
var broker *mockEventBroker
|
var broker *mockEventBroker
|
||||||
|
var pluginManager *mockPluginUnloader
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
@ -50,7 +51,9 @@ var _ = Describe("Library Service", func() {
|
|||||||
}
|
}
|
||||||
// Create a mock event broker
|
// Create a mock event broker
|
||||||
broker = &mockEventBroker{}
|
broker = &mockEventBroker{}
|
||||||
service = core.NewLibrary(ds, scanner, watcherManager, broker)
|
// Create a mock plugin unloader
|
||||||
|
pluginManager = &mockPluginUnloader{}
|
||||||
|
service = core.NewLibrary(ds, scanner, watcherManager, broker, pluginManager)
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
|
|
||||||
// Create a temporary directory for testing valid paths
|
// Create a temporary directory for testing valid paths
|
||||||
@ -869,8 +872,45 @@ var _ = Describe("Library Service", func() {
|
|||||||
Expect(broker.Events).To(HaveLen(1))
|
Expect(broker.Events).To(HaveLen(1))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("Plugin Manager Integration", func() {
|
||||||
|
var repo rest.Persistable
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
// Reset the call count for each test
|
||||||
|
pluginManager.unloadCalls = 0
|
||||||
|
r := service.NewRepository(ctx)
|
||||||
|
repo = r.(rest.Persistable)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("calls UnloadDisabledPlugins after successful library deletion", func() {
|
||||||
|
libraryRepo.SetData(model.Libraries{
|
||||||
|
{ID: 2, Name: "Library to Delete", Path: tempDir},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := repo.Delete("2")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(pluginManager.unloadCalls).To(Equal(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not call UnloadDisabledPlugins when library deletion fails", func() {
|
||||||
|
// Try to delete non-existent library
|
||||||
|
err := repo.Delete("999")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(pluginManager.unloadCalls).To(Equal(0))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// mockPluginUnloader is a simple mock for testing UnloadDisabledPlugins calls
|
||||||
|
type mockPluginUnloader struct {
|
||||||
|
unloadCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPluginUnloader) UnloadDisabledPlugins(ctx context.Context) {
|
||||||
|
m.unloadCalls++
|
||||||
|
}
|
||||||
|
|
||||||
// mockWatcherManager provides a simple mock implementation of core.Watcher for testing
|
// mockWatcherManager provides a simple mock implementation of core.Watcher for testing
|
||||||
type mockWatcherManager struct {
|
type mockWatcherManager struct {
|
||||||
StartedWatchers []model.Library
|
StartedWatchers []model.Library
|
||||||
|
|||||||
@ -23,7 +23,8 @@ import (
|
|||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/plugins/schema"
|
"github.com/navidrome/navidrome/plugins"
|
||||||
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/utils/singleton"
|
"github.com/navidrome/navidrome/utils/singleton"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -37,18 +38,12 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type insightsCollector struct {
|
type insightsCollector struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
pluginLoader PluginLoader
|
lastRun atomic.Int64
|
||||||
lastRun atomic.Int64
|
lastStatus atomic.Bool
|
||||||
lastStatus atomic.Bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PluginLoader defines an interface for loading plugins
|
func GetInstance(ds model.DataStore) Insights {
|
||||||
type PluginLoader interface {
|
|
||||||
PluginList() map[string]schema.PluginManifest
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
|
|
||||||
return singleton.GetInstance(func() *insightsCollector {
|
return singleton.GetInstance(func() *insightsCollector {
|
||||||
id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
|
id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -60,7 +55,7 @@ func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
insightsID = id
|
insightsID = id
|
||||||
return &insightsCollector{ds: ds, pluginLoader: pluginLoader}
|
return &insightsCollector{ds: ds}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -270,6 +265,10 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace(ctx, "Error reading active users count", err)
|
log.Trace(ctx, "Error reading active users count", err)
|
||||||
}
|
}
|
||||||
|
data.Library.FileSuffixes, err = c.ds.MediaFile(ctx).CountBySuffix()
|
||||||
|
if err != nil {
|
||||||
|
log.Trace(ctx, "Error reading file suffixes count", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Check for smart playlists
|
// Check for smart playlists
|
||||||
data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx)
|
data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx)
|
||||||
@ -319,12 +318,16 @@ func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error)
|
|||||||
|
|
||||||
// collectPlugins collects information about installed plugins
|
// collectPlugins collects information about installed plugins
|
||||||
func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo {
|
func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo {
|
||||||
plugins := make(map[string]insights.PluginInfo)
|
// TODO Fix import/inject cycles
|
||||||
for id, manifest := range c.pluginLoader.PluginList() {
|
manager := plugins.GetManager(c.ds, events.GetBroker(), nil)
|
||||||
plugins[id] = insights.PluginInfo{
|
info := manager.GetPluginInfo()
|
||||||
Name: manifest.Name,
|
|
||||||
Version: manifest.Version,
|
result := make(map[string]insights.PluginInfo, len(info))
|
||||||
|
for name, p := range info {
|
||||||
|
result[name] = insights.PluginInfo{
|
||||||
|
Name: p.Name,
|
||||||
|
Version: p.Version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return plugins
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ type Data struct {
|
|||||||
Libraries int64 `json:"libraries"`
|
Libraries int64 `json:"libraries"`
|
||||||
ActiveUsers int64 `json:"activeUsers"`
|
ActiveUsers int64 `json:"activeUsers"`
|
||||||
ActivePlayers map[string]int64 `json:"activePlayers,omitempty"`
|
ActivePlayers map[string]int64 `json:"activePlayers,omitempty"`
|
||||||
|
FileSuffixes map[string]int64 `json:"fileSuffixes,omitempty"`
|
||||||
} `json:"library"`
|
} `json:"library"`
|
||||||
Config struct {
|
Config struct {
|
||||||
LogLevel string `json:"logLevel,omitempty"`
|
LogLevel string `json:"logLevel,omitempty"`
|
||||||
|
|||||||
@ -168,6 +168,11 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R
|
|||||||
if nsp.Comment != "" {
|
if nsp.Comment != "" {
|
||||||
pls.Comment = nsp.Comment
|
pls.Comment = nsp.Comment
|
||||||
}
|
}
|
||||||
|
if nsp.Public != nil {
|
||||||
|
pls.Public = *nsp.Public
|
||||||
|
} else {
|
||||||
|
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -409,7 +414,10 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
|
|||||||
} else {
|
} else {
|
||||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||||
newPls.OwnerID = owner.ID
|
newPls.OwnerID = owner.ID
|
||||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
// For NSP files, Public may already be set from the file; for M3U, use server default
|
||||||
|
if !newPls.IsSmartPlaylist() {
|
||||||
|
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return s.ds.Playlist(ctx).Put(newPls)
|
return s.ds.Playlist(ctx).Put(newPls)
|
||||||
}
|
}
|
||||||
@ -473,6 +481,7 @@ type nspFile struct {
|
|||||||
criteria.Criteria
|
criteria.Criteria
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
|
Public *bool `json:"public"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||||
@ -483,5 +492,8 @@ func (i *nspFile) UnmarshalJSON(data []byte) error {
|
|||||||
}
|
}
|
||||||
i.Name, _ = m["name"].(string)
|
i.Name, _ = m["name"].(string)
|
||||||
i.Comment, _ = m["comment"].(string)
|
i.Comment, _ = m["comment"].(string)
|
||||||
|
if public, ok := m["public"].(bool); ok {
|
||||||
|
i.Public = &public
|
||||||
|
}
|
||||||
return json.Unmarshal(data, &i.Criteria)
|
return json.Unmarshal(data, &i.Criteria)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -112,6 +112,27 @@ var _ = Describe("Playlists", func() {
|
|||||||
_, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
|
_, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
|
||||||
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
|
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
|
||||||
})
|
})
|
||||||
|
It("parses NSP with public: true and creates public playlist", func() {
|
||||||
|
pls, err := ps.ImportFile(ctx, folder, "public_playlist.nsp")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(pls.Name).To(Equal("Public Playlist"))
|
||||||
|
Expect(pls.Public).To(BeTrue())
|
||||||
|
})
|
||||||
|
It("parses NSP with public: false and creates private playlist", func() {
|
||||||
|
pls, err := ps.ImportFile(ctx, folder, "private_playlist.nsp")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(pls.Name).To(Equal("Private Playlist"))
|
||||||
|
Expect(pls.Public).To(BeFalse())
|
||||||
|
})
|
||||||
|
It("uses server default when public field is absent", func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.DefaultPlaylistPublicVisibility = true
|
||||||
|
|
||||||
|
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(pls.Name).To(Equal("Recently Played"))
|
||||||
|
Expect(pls.Public).To(BeTrue()) // Should be true since server default is true
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Cross-library relative paths", func() {
|
Describe("Cross-library relative paths", func() {
|
||||||
|
|||||||
81
core/publicurl/publicurl.go
Normal file
81
core/publicurl/publicurl.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package publicurl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImageURL generates a public URL for artwork images.
|
||||||
|
// It creates a signed token for the artwork ID and builds a complete public URL.
|
||||||
|
func ImageURL(req *http.Request, artID model.ArtworkID, size int) string {
|
||||||
|
token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()})
|
||||||
|
uri := path.Join(consts.URLPathPublicImages, token)
|
||||||
|
params := url.Values{}
|
||||||
|
if size > 0 {
|
||||||
|
params.Add("size", strconv.Itoa(size))
|
||||||
|
}
|
||||||
|
return PublicURL(req, uri, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicURL builds a full URL for public-facing resources.
|
||||||
|
// It uses ShareURL from config if available, otherwise falls back to extracting
|
||||||
|
// the scheme and host from the provided http.Request.
|
||||||
|
// If req is nil and ShareURL is not set, it defaults to http://localhost.
|
||||||
|
func PublicURL(req *http.Request, u string, params url.Values) string {
|
||||||
|
if conf.Server.ShareURL == "" {
|
||||||
|
return AbsoluteURL(req, u, params)
|
||||||
|
}
|
||||||
|
shareUrl, err := url.Parse(conf.Server.ShareURL)
|
||||||
|
if err != nil {
|
||||||
|
return AbsoluteURL(req, u, params)
|
||||||
|
}
|
||||||
|
buildUrl, err := url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
return AbsoluteURL(req, u, params)
|
||||||
|
}
|
||||||
|
buildUrl.Scheme = shareUrl.Scheme
|
||||||
|
buildUrl.Host = shareUrl.Host
|
||||||
|
if len(params) > 0 {
|
||||||
|
buildUrl.RawQuery = params.Encode()
|
||||||
|
}
|
||||||
|
return buildUrl.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AbsoluteURL builds an absolute URL from a relative path.
|
||||||
|
// It uses BaseHost/BaseScheme from config if available, otherwise extracts
|
||||||
|
// the scheme and host from the http.Request.
|
||||||
|
// If req is nil and BaseHost is not set, it defaults to http://localhost.
|
||||||
|
func AbsoluteURL(req *http.Request, u string, params url.Values) string {
|
||||||
|
buildUrl, err := url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(req.Context(), "Failed to parse URL path", "url", u, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(u, "/") {
|
||||||
|
buildUrl.Path = path.Join(conf.Server.BasePath, buildUrl.Path)
|
||||||
|
if conf.Server.BaseHost != "" {
|
||||||
|
buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http")
|
||||||
|
buildUrl.Host = conf.Server.BaseHost
|
||||||
|
} else if req != nil {
|
||||||
|
buildUrl.Scheme = req.URL.Scheme
|
||||||
|
buildUrl.Host = req.Host
|
||||||
|
} else {
|
||||||
|
buildUrl.Scheme = "http"
|
||||||
|
buildUrl.Host = "localhost"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(params) > 0 {
|
||||||
|
buildUrl.RawQuery = params.Encode()
|
||||||
|
}
|
||||||
|
return buildUrl.String()
|
||||||
|
}
|
||||||
174
core/publicurl/publicurl_test.go
Normal file
174
core/publicurl/publicurl_test.go
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
package publicurl_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/jwtauth/v5"
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
|
"github.com/navidrome/navidrome/core/publicurl"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicURL(t *testing.T) {
|
||||||
|
tests.Init(t, false)
|
||||||
|
log.SetLevel(log.LevelFatal)
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "Public URL Suite")
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = Describe("Public URL Utilities", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("PublicURL", func() {
|
||||||
|
When("ShareURL is set", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
conf.Server.ShareURL = "https://share.example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
It("uses ShareURL as the base", func() {
|
||||||
|
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||||
|
result := publicurl.PublicURL(r, "/path/to/resource", nil)
|
||||||
|
Expect(result).To(Equal("https://share.example.com/path/to/resource"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("includes query parameters", func() {
|
||||||
|
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||||
|
params := url.Values{"size": []string{"300"}, "format": []string{"png"}}
|
||||||
|
result := publicurl.PublicURL(r, "/image/123", params)
|
||||||
|
Expect(result).To(ContainSubstring("https://share.example.com/image/123"))
|
||||||
|
Expect(result).To(ContainSubstring("size=300"))
|
||||||
|
Expect(result).To(ContainSubstring("format=png"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("works without a request", func() {
|
||||||
|
result := publicurl.PublicURL(nil, "/path/to/resource", nil)
|
||||||
|
Expect(result).To(Equal("https://share.example.com/path/to/resource"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("ShareURL is not set", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
conf.Server.ShareURL = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to AbsoluteURL with request", func() {
|
||||||
|
r, _ := http.NewRequest("GET", "https://myserver.com/test", nil)
|
||||||
|
r.Host = "myserver.com"
|
||||||
|
result := publicurl.PublicURL(r, "/path/to/resource", nil)
|
||||||
|
Expect(result).To(Equal("https://myserver.com/path/to/resource"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to localhost without request", func() {
|
||||||
|
result := publicurl.PublicURL(nil, "/path/to/resource", nil)
|
||||||
|
Expect(result).To(Equal("http://localhost/path/to/resource"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("AbsoluteURL", func() {
|
||||||
|
When("BaseHost is set", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
conf.Server.BaseHost = "configured.example.com"
|
||||||
|
conf.Server.BaseScheme = "https"
|
||||||
|
conf.Server.BasePath = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
It("uses BaseHost and BaseScheme", func() {
|
||||||
|
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||||
|
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
|
||||||
|
Expect(result).To(Equal("https://configured.example.com/path/to/resource"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("defaults to http scheme if BaseScheme is empty", func() {
|
||||||
|
conf.Server.BaseScheme = ""
|
||||||
|
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||||
|
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
|
||||||
|
Expect(result).To(Equal("http://configured.example.com/path/to/resource"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("BaseHost is not set", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
conf.Server.BaseHost = ""
|
||||||
|
conf.Server.BasePath = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
It("extracts host from request", func() {
|
||||||
|
r, _ := http.NewRequest("GET", "https://request.example.com/test", nil)
|
||||||
|
r.Host = "request.example.com"
|
||||||
|
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
|
||||||
|
Expect(result).To(Equal("https://request.example.com/path/to/resource"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to localhost without request", func() {
|
||||||
|
result := publicurl.AbsoluteURL(nil, "/path/to/resource", nil)
|
||||||
|
Expect(result).To(Equal("http://localhost/path/to/resource"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("BasePath is set", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
conf.Server.BasePath = "/navidrome"
|
||||||
|
conf.Server.BaseHost = "example.com"
|
||||||
|
conf.Server.BaseScheme = "https"
|
||||||
|
})
|
||||||
|
|
||||||
|
It("prepends BasePath to the URL", func() {
|
||||||
|
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||||
|
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
|
||||||
|
Expect(result).To(Equal("https://example.com/navidrome/path/to/resource"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
It("passes through absolute URLs unchanged", func() {
|
||||||
|
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||||
|
result := publicurl.AbsoluteURL(r, "https://other.example.com/path", nil)
|
||||||
|
Expect(result).To(Equal("https://other.example.com/path"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("includes query parameters", func() {
|
||||||
|
conf.Server.BaseHost = "example.com"
|
||||||
|
conf.Server.BaseScheme = "https"
|
||||||
|
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||||
|
params := url.Values{"key": []string{"value"}}
|
||||||
|
result := publicurl.AbsoluteURL(r, "/path", params)
|
||||||
|
Expect(result).To(Equal("https://example.com/path?key=value"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("ImageURL", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
conf.Server.ShareURL = "https://share.example.com"
|
||||||
|
// Initialize JWT auth for token generation
|
||||||
|
auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("generates a URL with the artwork token", func() {
|
||||||
|
artID := model.NewArtworkID(model.KindAlbumArtwork, "album-123", nil)
|
||||||
|
result := publicurl.ImageURL(nil, artID, 0)
|
||||||
|
Expect(result).To(HavePrefix("https://share.example.com/share/img/"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("includes size parameter when provided", func() {
|
||||||
|
artID := model.NewArtworkID(model.KindArtistArtwork, "artist-1", nil)
|
||||||
|
result := publicurl.ImageURL(nil, artID, 300)
|
||||||
|
Expect(result).To(ContainSubstring("size=300"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("omits size parameter when zero", func() {
|
||||||
|
artID := model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil)
|
||||||
|
result := publicurl.ImageURL(nil, artID, 0)
|
||||||
|
Expect(result).ToNot(ContainSubstring("size="))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -9,11 +9,27 @@ import (
|
|||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Loader is a function that loads a scrobbler by name.
|
||||||
|
// It returns the scrobbler and true if found, or nil and false if not available.
|
||||||
|
// This allows the buffered scrobbler to always get the current plugin instance.
|
||||||
|
type Loader func() (Scrobbler, bool)
|
||||||
|
|
||||||
|
// newBufferedScrobbler creates a buffered scrobbler that wraps a static scrobbler instance.
|
||||||
|
// Use this for builtin scrobblers that don't change.
|
||||||
func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler {
|
func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler {
|
||||||
|
return newBufferedScrobblerWithLoader(ds, service, func() (Scrobbler, bool) {
|
||||||
|
return s, true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBufferedScrobblerWithLoader creates a buffered scrobbler that dynamically loads
|
||||||
|
// the underlying scrobbler on each call. Use this for plugin scrobblers that may be
|
||||||
|
// reloaded (e.g., after configuration changes).
|
||||||
|
func newBufferedScrobblerWithLoader(ds model.DataStore, service string, loader Loader) *bufferedScrobbler {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
b := &bufferedScrobbler{
|
b := &bufferedScrobbler{
|
||||||
ds: ds,
|
ds: ds,
|
||||||
wrapped: s,
|
loader: loader,
|
||||||
service: service,
|
service: service,
|
||||||
wakeSignal: make(chan struct{}, 1),
|
wakeSignal: make(chan struct{}, 1),
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
@ -25,7 +41,7 @@ func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *buff
|
|||||||
|
|
||||||
type bufferedScrobbler struct {
|
type bufferedScrobbler struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
wrapped Scrobbler
|
loader Loader
|
||||||
service string
|
service string
|
||||||
wakeSignal chan struct{}
|
wakeSignal chan struct{}
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@ -39,11 +55,19 @@ func (b *bufferedScrobbler) Stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
|
func (b *bufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
|
||||||
return b.wrapped.IsAuthorized(ctx, userId)
|
s, ok := b.loader()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.IsAuthorized(ctx, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||||
return b.wrapped.NowPlaying(ctx, userId, track, position)
|
s, ok := b.loader()
|
||||||
|
if !ok {
|
||||||
|
return errors.New("scrobbler not available")
|
||||||
|
}
|
||||||
|
return s.NowPlaying(ctx, userId, track, position)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
|
func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
|
||||||
@ -107,8 +131,13 @@ func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string)
|
|||||||
if entry == nil {
|
if entry == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
s, ok := b.loader()
|
||||||
|
if !ok {
|
||||||
|
log.Warn(ctx, "Scrobbler not available, will retry later", "scrobbler", b.service)
|
||||||
|
return false
|
||||||
|
}
|
||||||
log.Debug(ctx, "Sending scrobble", "scrobbler", b.service, "track", entry.Title, "artist", entry.Artist)
|
log.Debug(ctx, "Sending scrobble", "scrobbler", b.service, "track", entry.Title, "artist", entry.Artist)
|
||||||
err = b.wrapped.Scrobble(ctx, entry.UserID, Scrobble{
|
err = s.Scrobble(ctx, entry.UserID, Scrobble{
|
||||||
MediaFile: entry.MediaFile,
|
MediaFile: entry.MediaFile,
|
||||||
TimeStamp: entry.PlayTime,
|
TimeStamp: entry.PlayTime,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -116,7 +116,7 @@ func (p *playTracker) stopNowPlayingWorker() {
|
|||||||
<-p.workerDone // Wait for worker to finish
|
<-p.workerDone // Wait for worker to finish
|
||||||
}
|
}
|
||||||
|
|
||||||
// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers
|
// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers.
|
||||||
func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool {
|
func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool {
|
||||||
if len(pluginNames) != len(scrobblers) {
|
if len(pluginNames) != len(scrobblers) {
|
||||||
return false
|
return false
|
||||||
@ -129,7 +129,9 @@ func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scro
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshPluginScrobblers updates the pluginScrobblers map to match the current set of plugin scrobblers
|
// refreshPluginScrobblers updates the pluginScrobblers map to match the current set of plugin scrobblers.
|
||||||
|
// The buffered scrobblers use a loader function to dynamically get the current plugin instance,
|
||||||
|
// so we only need to add/remove scrobblers when plugins are added/removed (not when reloaded).
|
||||||
func (p *playTracker) refreshPluginScrobblers() {
|
func (p *playTracker) refreshPluginScrobblers() {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
@ -148,15 +150,16 @@ func (p *playTracker) refreshPluginScrobblers() {
|
|||||||
// Build a set of current plugins for faster lookups
|
// Build a set of current plugins for faster lookups
|
||||||
current := make(map[string]struct{}, len(pluginNames))
|
current := make(map[string]struct{}, len(pluginNames))
|
||||||
|
|
||||||
// Process additions - add new plugins
|
// Process additions - add new plugins with a loader that dynamically fetches the current instance
|
||||||
for _, name := range pluginNames {
|
for _, name := range pluginNames {
|
||||||
current[name] = struct{}{}
|
current[name] = struct{}{}
|
||||||
// Only create a new scrobbler if it doesn't exist
|
|
||||||
if _, exists := p.pluginScrobblers[name]; !exists {
|
if _, exists := p.pluginScrobblers[name]; !exists {
|
||||||
s, ok := p.pluginLoader.LoadScrobbler(name)
|
// Capture the name for the closure
|
||||||
if ok && s != nil {
|
pluginName := name
|
||||||
p.pluginScrobblers[name] = newBufferedScrobbler(p.ds, s, name)
|
loader := p.pluginLoader
|
||||||
}
|
p.pluginScrobblers[name] = newBufferedScrobblerWithLoader(p.ds, name, func() (Scrobbler, bool) {
|
||||||
|
return loader.LoadScrobbler(pluginName)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -432,6 +432,122 @@ var _ = Describe("PlayTracker", func() {
|
|||||||
Expect(pTracker.pluginScrobblers).NotTo(HaveKey("plugin1"))
|
Expect(pTracker.pluginScrobblers).NotTo(HaveKey("plugin1"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("Plugin reload (config update) behavior", func() {
|
||||||
|
var mockPlugin *mockPluginLoader
|
||||||
|
var pTracker *playTracker
|
||||||
|
var originalScrobbler *fakeScrobbler
|
||||||
|
var reloadedScrobbler *fakeScrobbler
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
ctx = GinkgoT().Context()
|
||||||
|
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
|
||||||
|
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
||||||
|
ds = &tests.MockDataStore{}
|
||||||
|
|
||||||
|
// Setup initial plugin scrobbler
|
||||||
|
originalScrobbler = &fakeScrobbler{Authorized: true}
|
||||||
|
reloadedScrobbler = &fakeScrobbler{Authorized: true}
|
||||||
|
|
||||||
|
mockPlugin = &mockPluginLoader{
|
||||||
|
names: []string{"plugin1"},
|
||||||
|
scrobblers: map[string]Scrobbler{"plugin1": originalScrobbler},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tracker - this will create buffered scrobblers with loaders
|
||||||
|
pTracker = newPlayTracker(ds, events.GetBroker(), mockPlugin)
|
||||||
|
|
||||||
|
// Trigger initial plugin registration
|
||||||
|
pTracker.refreshPluginScrobblers()
|
||||||
|
})
|
||||||
|
|
||||||
|
AfterEach(func() {
|
||||||
|
pTracker.stopNowPlayingWorker()
|
||||||
|
})
|
||||||
|
|
||||||
|
It("uses the new plugin instance after reload (simulating config update)", func() {
|
||||||
|
// First call should use the original scrobbler
|
||||||
|
scrobblers := pTracker.getActiveScrobblers()
|
||||||
|
pluginScr := scrobblers["plugin1"]
|
||||||
|
Expect(pluginScr).ToNot(BeNil())
|
||||||
|
|
||||||
|
err := pluginScr.NowPlaying(ctx, "u-1", &track, 0)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(originalScrobbler.GetNowPlayingCalled()).To(BeTrue())
|
||||||
|
Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeFalse())
|
||||||
|
|
||||||
|
// Simulate plugin reload (config update): replace the scrobbler in the loader
|
||||||
|
// This is what happens when UpdatePluginConfig is called - the plugin manager
|
||||||
|
// unloads the old plugin and loads a new instance
|
||||||
|
mockPlugin.mu.Lock()
|
||||||
|
mockPlugin.scrobblers["plugin1"] = reloadedScrobbler
|
||||||
|
mockPlugin.mu.Unlock()
|
||||||
|
|
||||||
|
// Reset call tracking
|
||||||
|
originalScrobbler.nowPlayingCalled.Store(false)
|
||||||
|
|
||||||
|
// Get scrobblers again - should still return the same buffered scrobbler
|
||||||
|
// but subsequent calls should use the new plugin instance via the loader
|
||||||
|
scrobblers = pTracker.getActiveScrobblers()
|
||||||
|
pluginScr = scrobblers["plugin1"]
|
||||||
|
|
||||||
|
err = pluginScr.NowPlaying(ctx, "u-1", &track, 0)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// The new scrobbler should be called, not the old one
|
||||||
|
Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeTrue())
|
||||||
|
Expect(originalScrobbler.GetNowPlayingCalled()).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles plugin becoming unavailable temporarily", func() {
|
||||||
|
// First verify plugin works
|
||||||
|
scrobblers := pTracker.getActiveScrobblers()
|
||||||
|
pluginScr := scrobblers["plugin1"]
|
||||||
|
|
||||||
|
err := pluginScr.NowPlaying(ctx, "u-1", &track, 0)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(originalScrobbler.GetNowPlayingCalled()).To(BeTrue())
|
||||||
|
|
||||||
|
// Simulate plugin becoming unavailable (e.g., during reload)
|
||||||
|
mockPlugin.mu.Lock()
|
||||||
|
delete(mockPlugin.scrobblers, "plugin1")
|
||||||
|
mockPlugin.mu.Unlock()
|
||||||
|
|
||||||
|
originalScrobbler.nowPlayingCalled.Store(false)
|
||||||
|
|
||||||
|
// NowPlaying should return error when plugin unavailable
|
||||||
|
err = pluginScr.NowPlaying(ctx, "u-1", &track, 0)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(originalScrobbler.GetNowPlayingCalled()).To(BeFalse())
|
||||||
|
|
||||||
|
// Simulate plugin becoming available again
|
||||||
|
mockPlugin.mu.Lock()
|
||||||
|
mockPlugin.scrobblers["plugin1"] = reloadedScrobbler
|
||||||
|
mockPlugin.mu.Unlock()
|
||||||
|
|
||||||
|
// Should work again with new instance
|
||||||
|
err = pluginScr.NowPlaying(ctx, "u-1", &track, 0)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("IsAuthorized uses the current plugin instance", func() {
|
||||||
|
scrobblers := pTracker.getActiveScrobblers()
|
||||||
|
pluginScr := scrobblers["plugin1"]
|
||||||
|
|
||||||
|
// Original is authorized
|
||||||
|
Expect(pluginScr.IsAuthorized(ctx, "u-1")).To(BeTrue())
|
||||||
|
|
||||||
|
// Replace with unauthorized scrobbler
|
||||||
|
unauthorizedScrobbler := &fakeScrobbler{Authorized: false}
|
||||||
|
mockPlugin.mu.Lock()
|
||||||
|
mockPlugin.scrobblers["plugin1"] = unauthorizedScrobbler
|
||||||
|
mockPlugin.mu.Unlock()
|
||||||
|
|
||||||
|
// Should reflect the new scrobbler's authorization status
|
||||||
|
Expect(pluginScr.IsAuthorized(ctx, "u-1")).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
type fakeScrobbler struct {
|
type fakeScrobbler struct {
|
||||||
|
|||||||
76
core/user.go
Normal file
76
core/user.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/deluan/rest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluginUnloader defines the interface for unloading disabled plugins.
|
||||||
|
// This is satisfied by plugins.Manager but defined here to avoid import cycles.
|
||||||
|
type PluginUnloader interface {
|
||||||
|
UnloadDisabledPlugins(ctx context.Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User provides business logic for user management with plugin coordination.
|
||||||
|
type User interface {
|
||||||
|
NewRepository(ctx context.Context) rest.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
type userService struct {
|
||||||
|
ds model.DataStore
|
||||||
|
pluginManager PluginUnloader
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUser creates a new User service
|
||||||
|
func NewUser(ds model.DataStore, pluginManager PluginUnloader) User {
|
||||||
|
return &userService{
|
||||||
|
ds: ds,
|
||||||
|
pluginManager: pluginManager,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRepository returns a REST repository wrapper for user operations.
|
||||||
|
// The wrapper intercepts Delete operations to coordinate plugin unloading.
|
||||||
|
func (s *userService) NewRepository(ctx context.Context) rest.Repository {
|
||||||
|
repo := s.ds.User(ctx)
|
||||||
|
wrapper := &userRepositoryWrapper{
|
||||||
|
ctx: ctx,
|
||||||
|
UserRepository: repo,
|
||||||
|
pluginManager: s.pluginManager,
|
||||||
|
}
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
type userRepositoryWrapper struct {
|
||||||
|
model.UserRepository
|
||||||
|
ctx context.Context
|
||||||
|
pluginManager PluginUnloader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save implements rest.Persistable by delegating to the underlying repository.
|
||||||
|
func (r *userRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||||
|
return r.UserRepository.(rest.Persistable).Save(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update implements rest.Persistable by delegating to the underlying repository.
|
||||||
|
func (r *userRepositoryWrapper) Update(id string, entity interface{}, cols ...string) error {
|
||||||
|
return r.UserRepository.(rest.Persistable).Update(id, entity, cols...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete implements rest.Persistable and coordinates plugin unloading.
|
||||||
|
func (r *userRepositoryWrapper) Delete(id string) error {
|
||||||
|
// The underlying repository Delete handles the database cleanup
|
||||||
|
// including calling cleanupPluginUserReferences
|
||||||
|
err := r.UserRepository.(rest.Persistable).Delete(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// After successful deletion, check if any plugins were auto-disabled
|
||||||
|
// and need to be unloaded from memory
|
||||||
|
r.pluginManager.UnloadDisabledPlugins(r.ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
86
core/user_test.go
Normal file
86
core/user_test.go
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/deluan/rest"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("User Service", func() {
|
||||||
|
var service core.User
|
||||||
|
var ds *tests.MockDataStore
|
||||||
|
var userRepo *tests.MockedUserRepo
|
||||||
|
var pluginManager *mockPluginUnloader
|
||||||
|
var ctx context.Context
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
ds = &tests.MockDataStore{}
|
||||||
|
userRepo = tests.CreateMockUserRepo()
|
||||||
|
ds.MockedUser = userRepo
|
||||||
|
pluginManager = &mockPluginUnloader{}
|
||||||
|
service = core.NewUser(ds, pluginManager)
|
||||||
|
ctx = GinkgoT().Context()
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("NewRepository", func() {
|
||||||
|
It("returns a rest.Persistable", func() {
|
||||||
|
repo := service.NewRepository(ctx)
|
||||||
|
_, ok := repo.(rest.Persistable)
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Delete", func() {
|
||||||
|
var repo rest.Persistable
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
r := service.NewRepository(ctx)
|
||||||
|
repo = r.(rest.Persistable)
|
||||||
|
|
||||||
|
// Add a test user
|
||||||
|
user := &model.User{
|
||||||
|
ID: "user-123",
|
||||||
|
UserName: "testuser",
|
||||||
|
IsAdmin: false,
|
||||||
|
}
|
||||||
|
user.NewPassword = "password"
|
||||||
|
Expect(userRepo.Put(user)).To(Succeed())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("deletes the user successfully", func() {
|
||||||
|
err := repo.Delete("user-123")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
// Verify user is deleted
|
||||||
|
_, err = userRepo.Get("user-123")
|
||||||
|
Expect(err).To(Equal(model.ErrNotFound))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("calls UnloadDisabledPlugins after successful deletion", func() {
|
||||||
|
err := repo.Delete("user-123")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(pluginManager.unloadCalls).To(Equal(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not call UnloadDisabledPlugins when deletion fails", func() {
|
||||||
|
// Try to delete non-existent user
|
||||||
|
err := repo.Delete("non-existent")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(pluginManager.unloadCalls).To(Equal(0))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error when repository fails", func() {
|
||||||
|
userRepo.Error = errors.New("database error")
|
||||||
|
err := repo.Delete("user-123")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("database error"))
|
||||||
|
Expect(pluginManager.unloadCalls).To(Equal(0))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -18,6 +18,7 @@ var Set = wire.NewSet(
|
|||||||
NewShare,
|
NewShare,
|
||||||
NewPlaylists,
|
NewPlaylists,
|
||||||
NewLibrary,
|
NewLibrary,
|
||||||
|
NewUser,
|
||||||
NewMaintenance,
|
NewMaintenance,
|
||||||
agents.GetAgents,
|
agents.GetAgents,
|
||||||
external.NewProvider,
|
external.NewProvider,
|
||||||
|
|||||||
@ -0,0 +1,99 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Fix case-insensitive sorting for playlist names
|
||||||
|
create table playlist_dg_tmp
|
||||||
|
(
|
||||||
|
id varchar(255) not null
|
||||||
|
primary key,
|
||||||
|
name varchar(255) collate NOCASE default '' not null,
|
||||||
|
comment varchar(255) default '' not null,
|
||||||
|
duration real default 0 not null,
|
||||||
|
song_count integer default 0 not null,
|
||||||
|
public bool default FALSE not null,
|
||||||
|
created_at datetime,
|
||||||
|
updated_at datetime,
|
||||||
|
path string default '' not null,
|
||||||
|
sync bool default false not null,
|
||||||
|
size integer default 0 not null,
|
||||||
|
rules varchar,
|
||||||
|
evaluated_at datetime,
|
||||||
|
owner_id varchar(255) not null
|
||||||
|
constraint playlist_user_user_id_fk
|
||||||
|
references user
|
||||||
|
on update cascade on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into playlist_dg_tmp(id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size,
|
||||||
|
rules, evaluated_at, owner_id)
|
||||||
|
select id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at,
|
||||||
|
owner_id
|
||||||
|
from playlist;
|
||||||
|
|
||||||
|
drop table playlist;
|
||||||
|
|
||||||
|
alter table playlist_dg_tmp
|
||||||
|
rename to playlist;
|
||||||
|
|
||||||
|
create index playlist_name
|
||||||
|
on playlist (name);
|
||||||
|
|
||||||
|
create index playlist_created_at
|
||||||
|
on playlist (created_at);
|
||||||
|
|
||||||
|
create index playlist_updated_at
|
||||||
|
on playlist (updated_at);
|
||||||
|
|
||||||
|
create index playlist_evaluated_at
|
||||||
|
on playlist (evaluated_at);
|
||||||
|
|
||||||
|
create index playlist_size
|
||||||
|
on playlist (size);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- Note: Downgrade loses the collation but preserves data
|
||||||
|
create table playlist_dg_tmp
|
||||||
|
(
|
||||||
|
id varchar(255) not null
|
||||||
|
primary key,
|
||||||
|
name varchar(255) default '' not null,
|
||||||
|
comment varchar(255) default '' not null,
|
||||||
|
duration real default 0 not null,
|
||||||
|
song_count integer default 0 not null,
|
||||||
|
public bool default FALSE not null,
|
||||||
|
created_at datetime,
|
||||||
|
updated_at datetime,
|
||||||
|
path string default '' not null,
|
||||||
|
sync bool default false not null,
|
||||||
|
size integer default 0 not null,
|
||||||
|
rules varchar,
|
||||||
|
evaluated_at datetime,
|
||||||
|
owner_id varchar(255) not null
|
||||||
|
constraint playlist_user_user_id_fk
|
||||||
|
references user
|
||||||
|
on update cascade on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into playlist_dg_tmp(id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size,
|
||||||
|
rules, evaluated_at, owner_id)
|
||||||
|
select id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at,
|
||||||
|
owner_id
|
||||||
|
from playlist;
|
||||||
|
|
||||||
|
drop table playlist;
|
||||||
|
|
||||||
|
alter table playlist_dg_tmp
|
||||||
|
rename to playlist;
|
||||||
|
|
||||||
|
create index playlist_name
|
||||||
|
on playlist (name);
|
||||||
|
|
||||||
|
create index playlist_created_at
|
||||||
|
on playlist (created_at);
|
||||||
|
|
||||||
|
create index playlist_updated_at
|
||||||
|
on playlist (updated_at);
|
||||||
|
|
||||||
|
create index playlist_evaluated_at
|
||||||
|
on playlist (evaluated_at);
|
||||||
|
|
||||||
|
create index playlist_size
|
||||||
|
on playlist (size);
|
||||||
19
db/migrations/20260106000620_create_plugin_table.sql
Normal file
19
db/migrations/20260106000620_create_plugin_table.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE IF NOT EXISTS plugin (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
manifest JSONB NOT NULL,
|
||||||
|
config JSONB,
|
||||||
|
users JSONB,
|
||||||
|
all_users BOOL NOT NULL DEFAULT false,
|
||||||
|
libraries JSONB,
|
||||||
|
all_libraries BOOL NOT NULL DEFAULT false,
|
||||||
|
enabled BOOL NOT NULL DEFAULT false,
|
||||||
|
last_error TEXT,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS plugin;
|
||||||
23
db/migrations/20260117201522_add_avg_rating_column.sql
Normal file
23
db/migrations/20260117201522_add_avg_rating_column.sql
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE album ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE media_file ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE artist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Populate average_rating from existing ratings
|
||||||
|
UPDATE album SET average_rating = coalesce(
|
||||||
|
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = album.id AND item_type = 'album' AND rating > 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
UPDATE media_file SET average_rating = coalesce(
|
||||||
|
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = media_file.id AND item_type = 'media_file' AND rating > 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
UPDATE artist SET average_rating = coalesce(
|
||||||
|
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = artist.id AND item_type = 'artist' AND rating > 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE artist DROP COLUMN average_rating;
|
||||||
|
ALTER TABLE media_file DROP COLUMN average_rating;
|
||||||
|
ALTER TABLE album DROP COLUMN average_rating;
|
||||||
71
go.mod
71
go.mod
@ -2,14 +2,19 @@ module github.com/navidrome/navidrome
|
|||||||
|
|
||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
replace (
|
||||||
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
||||||
|
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||||
|
|
||||||
|
// Fork to implement raw tags support
|
||||||
|
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Masterminds/squirrel v1.5.4
|
github.com/Masterminds/squirrel v1.5.4
|
||||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
|
||||||
github.com/andybalholm/cascadia v1.3.3
|
github.com/andybalholm/cascadia v1.3.3
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1
|
github.com/bmatcuk/doublestar/v4 v4.9.2
|
||||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
|
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
|
||||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
|
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
|
||||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
|
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
|
||||||
@ -21,8 +26,9 @@ require (
|
|||||||
github.com/djherbis/stream v1.4.0
|
github.com/djherbis/stream v1.4.0
|
||||||
github.com/djherbis/times v1.6.0
|
github.com/djherbis/times v1.6.0
|
||||||
github.com/dustin/go-humanize v1.0.1
|
github.com/dustin/go-humanize v1.0.1
|
||||||
|
github.com/extism/go-sdk v1.7.1
|
||||||
github.com/fatih/structs v1.1.0
|
github.com/fatih/structs v1.1.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.4
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/go-chi/httprate v0.15.0
|
github.com/go-chi/httprate v0.15.0
|
||||||
github.com/go-chi/jwtauth/v5 v5.3.3
|
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||||
@ -36,16 +42,15 @@ require (
|
|||||||
github.com/jellydator/ttlcache/v3 v3.4.0
|
github.com/jellydator/ttlcache/v3 v3.4.0
|
||||||
github.com/kardianos/service v1.2.4
|
github.com/kardianos/service v1.2.4
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
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/kr/pretty v0.3.1
|
||||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||||
github.com/maruel/natural v1.2.1
|
github.com/maruel/natural v1.3.0
|
||||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.32
|
github.com/mattn/go-sqlite3 v1.14.33
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/mileusna/useragent v1.3.5
|
github.com/mileusna/useragent v1.3.5
|
||||||
github.com/onsi/ginkgo/v2 v2.27.3
|
github.com/onsi/ginkgo/v2 v2.27.5
|
||||||
github.com/onsi/gomega v1.38.3
|
github.com/onsi/gomega v1.39.0
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pressly/goose/v3 v3.26.0
|
github.com/pressly/goose/v3 v3.26.0
|
||||||
@ -57,19 +62,18 @@ require (
|
|||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tetratelabs/wazero v1.10.1
|
github.com/tetratelabs/wazero v1.11.0
|
||||||
github.com/unrolled/secure v1.17.0
|
github.com/unrolled/secure v1.17.0
|
||||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||||
|
go.senan.xyz/taglib v0.0.0-00010101000000-000000000000
|
||||||
go.uber.org/goleak v1.3.0
|
go.uber.org/goleak v1.3.0
|
||||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9
|
golang.org/x/image v0.35.0
|
||||||
golang.org/x/image v0.34.0
|
golang.org/x/net v0.49.0
|
||||||
golang.org/x/net v0.48.0
|
|
||||||
golang.org/x/sync v0.19.0
|
golang.org/x/sync v0.19.0
|
||||||
golang.org/x/sys v0.39.0
|
golang.org/x/sys v0.40.0
|
||||||
golang.org/x/term v0.38.0
|
golang.org/x/term v0.39.0
|
||||||
golang.org/x/text v0.32.0
|
golang.org/x/text v0.33.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
google.golang.org/protobuf v1.36.11
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -81,20 +85,23 @@ require (
|
|||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/reflex v0.3.1 // indirect
|
github.com/cespare/reflex v0.3.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/creack/pty v1.1.11 // indirect
|
github.com/creack/pty v1.1.24 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
|
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
github.com/gobwas/glob v0.2.3 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f // indirect
|
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440 // indirect
|
||||||
github.com/google/subcommands v1.2.0 // indirect
|
github.com/google/subcommands v1.2.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
@ -112,8 +119,8 @@ require (
|
|||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.66.1 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
github.com/sanity-io/litter v1.5.8 // indirect
|
github.com/sanity-io/litter v1.5.8 // indirect
|
||||||
@ -123,17 +130,21 @@ require (
|
|||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
github.com/stretchr/objx v0.5.3 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
|
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.46.0 // indirect
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
golang.org/x/mod v0.31.0 // indirect
|
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||||
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect
|
golang.org/x/mod v0.32.0 // indirect
|
||||||
golang.org/x/tools v0.40.0 // indirect
|
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
golang.org/x/tools v0.41.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.1 // indirect
|
||||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
122
go.sum
122
go.sum
@ -16,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/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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
|
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
|
||||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
|
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
|
||||||
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
|
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
|
||||||
@ -26,8 +26,9 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
|
|
||||||
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@ -35,6 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
|||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
|
github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798 h1:q4fvcIK/LxElpyQILCejG6WPYjVb2F/4P93+k017ANk=
|
||||||
|
github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
|
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
|
||||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
|
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
|
||||||
@ -55,6 +58,10 @@ github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
|||||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
|
||||||
|
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
|
||||||
|
github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
|
||||||
|
github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
|
||||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
@ -68,8 +75,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ
|
|||||||
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
|
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 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
|
||||||
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
|
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.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
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 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||||
@ -85,12 +92,14 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
|
|||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
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 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
|
||||||
github.com/go-viper/encoding/ini v0.1.1/go.mod h1:Pfi4M2V1eAGJVZ5q6FrkHPhtHED2YgLlXhvgMVrB+YQ=
|
github.com/go-viper/encoding/ini v0.1.1/go.mod h1:Pfi4M2V1eAGJVZ5q6FrkHPhtHED2YgLlXhvgMVrB+YQ=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||||
|
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
|
github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
|
||||||
github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ=
|
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/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
@ -99,8 +108,8 @@ 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-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 h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
||||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
||||||
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f h1:HU1RgM6NALf/KW9HEY6zry3ADbDKcmpQ+hJedoNGQYQ=
|
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440 h1:oKBqR+eQXiIM7X8K1JEg9aoTEePLq/c6Awe484abOuA=
|
||||||
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f/go.mod h1:67FPmZWbr+KDT/VlpWtw6sO9XSjpJmLuHpoLmWiTGgY=
|
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@ -118,6 +127,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
|
|||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f h1:Fnl4pzx8SR7k7JuzyW8lEtSFH6EQ8xgcypgIn8pcGIE=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
|
||||||
@ -134,8 +145,6 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
|
|||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
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/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=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@ -162,14 +171,14 @@ 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/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 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||||
github.com/maruel/natural v1.2.1 h1:G/y4pwtTA07lbQsMefvsmEO0VN0NfqpxprxXDM4R/4o=
|
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
|
||||||
github.com/maruel/natural v1.2.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
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 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||||
@ -186,10 +195,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/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 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||||
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
|
github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
|
||||||
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||||
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
|
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
|
||||||
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
@ -207,10 +216,10 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
|||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
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.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
|
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
|
||||||
@ -253,20 +262,27 @@ 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/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.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.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||||
|
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||||
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.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.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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
|
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=
|
||||||
github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
|
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk=
|
||||||
|
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
|
||||||
|
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
|
||||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
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 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
@ -284,12 +300,14 @@ 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/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 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
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.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
@ -298,20 +316,20 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
|
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.15.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.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-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-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
@ -323,8 +341,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
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.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.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@ -350,11 +368,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.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.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA=
|
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
|
||||||
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
|
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-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.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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
@ -363,8 +381,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
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.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@ -375,8 +393,8 @@ 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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@ -386,8 +404,8 @@ 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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
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/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
@ -395,8 +413,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
|||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
|
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
|
||||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
|
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
|||||||
20
log/log.go
20
log/log.go
@ -88,11 +88,11 @@ func SetLevel(l Level) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SetLevelString(l string) {
|
func SetLevelString(l string) {
|
||||||
level := levelFromString(l)
|
level := ParseLogLevel(l)
|
||||||
SetLevel(level)
|
SetLevel(level)
|
||||||
}
|
}
|
||||||
|
|
||||||
func levelFromString(l string) Level {
|
func ParseLogLevel(l string) Level {
|
||||||
envLevel := strings.ToLower(l)
|
envLevel := strings.ToLower(l)
|
||||||
var level Level
|
var level Level
|
||||||
switch envLevel {
|
switch envLevel {
|
||||||
@ -118,7 +118,7 @@ func SetLogLevels(levels map[string]string) {
|
|||||||
defer loggerMu.Unlock()
|
defer loggerMu.Unlock()
|
||||||
logLevels = nil
|
logLevels = nil
|
||||||
for k, v := range levels {
|
for k, v := range levels {
|
||||||
logLevels = append(logLevels, levelPath{path: k, level: levelFromString(v)})
|
logLevels = append(logLevels, levelPath{path: k, level: ParseLogLevel(v)})
|
||||||
}
|
}
|
||||||
sort.Slice(logLevels, func(i, j int) bool {
|
sort.Slice(logLevels, func(i, j int) bool {
|
||||||
return logLevels[i].path > logLevels[j].path
|
return logLevels[i].path > logLevels[j].path
|
||||||
@ -185,31 +185,31 @@ func IsGreaterOrEqualTo(level Level) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Fatal(args ...interface{}) {
|
func Fatal(args ...interface{}) {
|
||||||
log(LevelFatal, args...)
|
Log(LevelFatal, args...)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Error(args ...interface{}) {
|
func Error(args ...interface{}) {
|
||||||
log(LevelError, args...)
|
Log(LevelError, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Warn(args ...interface{}) {
|
func Warn(args ...interface{}) {
|
||||||
log(LevelWarn, args...)
|
Log(LevelWarn, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Info(args ...interface{}) {
|
func Info(args ...interface{}) {
|
||||||
log(LevelInfo, args...)
|
Log(LevelInfo, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Debug(args ...interface{}) {
|
func Debug(args ...interface{}) {
|
||||||
log(LevelDebug, args...)
|
Log(LevelDebug, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Trace(args ...interface{}) {
|
func Trace(args ...interface{}) {
|
||||||
log(LevelTrace, args...)
|
Log(LevelTrace, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func log(level Level, args ...interface{}) {
|
func Log(level Level, args ...interface{}) {
|
||||||
if !shouldLog(level, 3) {
|
if !shouldLog(level, 3) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,13 @@ package model
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Annotations struct {
|
type Annotations struct {
|
||||||
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
|
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
|
||||||
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
|
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
|
||||||
Rating int `structs:"rating" json:"rating,omitempty" `
|
Rating int `structs:"rating" json:"rating,omitempty" `
|
||||||
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
|
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
|
||||||
Starred bool `structs:"starred" json:"starred,omitempty" `
|
Starred bool `structs:"starred" json:"starred,omitempty" `
|
||||||
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
|
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
|
||||||
|
AverageRating float64 `structs:"average_rating" json:"averageRating,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnnotatedRepository interface {
|
type AnnotatedRepository interface {
|
||||||
|
|||||||
@ -39,6 +39,7 @@ type DataStore interface {
|
|||||||
UserProps(ctx context.Context) UserPropsRepository
|
UserProps(ctx context.Context) UserPropsRepository
|
||||||
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
||||||
Scrobble(ctx context.Context) ScrobbleRepository
|
Scrobble(ctx context.Context) ScrobbleRepository
|
||||||
|
Plugin(ctx context.Context) PluginRepository
|
||||||
|
|
||||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||||
|
|
||||||
|
|||||||
@ -353,6 +353,7 @@ type MediaFileCursor iter.Seq2[MediaFile, error]
|
|||||||
|
|
||||||
type MediaFileRepository interface {
|
type MediaFileRepository interface {
|
||||||
CountAll(options ...QueryOptions) (int64, error)
|
CountAll(options ...QueryOptions) (int64, error)
|
||||||
|
CountBySuffix(options ...QueryOptions) (map[string]int64, error)
|
||||||
Exists(id string) (bool, error)
|
Exists(id string) (bool, error)
|
||||||
Put(m *MediaFile) error
|
Put(m *MediaFile) error
|
||||||
Get(id string) (*MediaFile, error)
|
Get(id string) (*MediaFile, error)
|
||||||
|
|||||||
30
model/plugin.go
Normal file
30
model/plugin.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
ID string `structs:"id" json:"id"`
|
||||||
|
Path string `structs:"path" json:"path"`
|
||||||
|
Manifest string `structs:"manifest" json:"manifest"`
|
||||||
|
Config string `structs:"config" json:"config,omitempty"`
|
||||||
|
Users string `structs:"users" json:"users,omitempty"`
|
||||||
|
AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
|
||||||
|
Libraries string `structs:"libraries" json:"libraries,omitempty"`
|
||||||
|
AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
|
||||||
|
Enabled bool `structs:"enabled" json:"enabled"`
|
||||||
|
LastError string `structs:"last_error" json:"lastError,omitempty"`
|
||||||
|
SHA256 string `structs:"sha256" json:"sha256"`
|
||||||
|
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Plugins []Plugin
|
||||||
|
|
||||||
|
type PluginRepository interface {
|
||||||
|
ResourceRepository
|
||||||
|
CountAll(options ...QueryOptions) (int64, error)
|
||||||
|
Delete(id string) error
|
||||||
|
Get(id string) (*Plugin, error)
|
||||||
|
GetAll(options ...QueryOptions) (Plugins, error)
|
||||||
|
Put(p *Plugin) error
|
||||||
|
}
|
||||||
@ -46,6 +46,7 @@ type UserRepository interface {
|
|||||||
CountAll(...QueryOptions) (int64, error)
|
CountAll(...QueryOptions) (int64, error)
|
||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
Get(id string) (*User, error)
|
Get(id string) (*User, error)
|
||||||
|
GetAll(options ...QueryOptions) (Users, error)
|
||||||
Put(*User) error
|
Put(*User) error
|
||||||
UpdateLastLoginAt(id string) error
|
UpdateLastLoginAt(id string) error
|
||||||
UpdateLastAccessAt(id string) error
|
UpdateLastAccessAt(id string) error
|
||||||
|
|||||||
@ -126,6 +126,89 @@ var _ = Describe("AlbumRepository", func() {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("Album.AverageRating", func() {
|
||||||
|
It("returns 0 when no ratings exist", func() {
|
||||||
|
newID := id.NewRandom()
|
||||||
|
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "no ratings album"})).To(Succeed())
|
||||||
|
|
||||||
|
album, err := albumRepo.Get(newID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(album.AverageRating).To(Equal(0.0))
|
||||||
|
|
||||||
|
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns the user's rating as average when only one user rated", func() {
|
||||||
|
newID := id.NewRandom()
|
||||||
|
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "single rating album"})).To(Succeed())
|
||||||
|
Expect(albumRepo.SetRating(4, newID)).To(Succeed())
|
||||||
|
|
||||||
|
album, err := albumRepo.Get(newID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(album.AverageRating).To(Equal(4.0))
|
||||||
|
|
||||||
|
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
||||||
|
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("calculates average across multiple users", func() {
|
||||||
|
newID := id.NewRandom()
|
||||||
|
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "multi rating album"})).To(Succeed())
|
||||||
|
|
||||||
|
Expect(albumRepo.SetRating(4, newID)).To(Succeed())
|
||||||
|
|
||||||
|
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
|
||||||
|
user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
|
||||||
|
Expect(user2Repo.SetRating(5, newID)).To(Succeed())
|
||||||
|
|
||||||
|
album, err := albumRepo.Get(newID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(album.AverageRating).To(Equal(4.5))
|
||||||
|
|
||||||
|
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
||||||
|
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("excludes zero ratings from average calculation", func() {
|
||||||
|
newID := id.NewRandom()
|
||||||
|
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "zero rating excluded album"})).To(Succeed())
|
||||||
|
Expect(albumRepo.SetRating(3, newID)).To(Succeed())
|
||||||
|
|
||||||
|
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
|
||||||
|
user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
|
||||||
|
Expect(user2Repo.SetRating(0, newID)).To(Succeed())
|
||||||
|
|
||||||
|
album, err := albumRepo.Get(newID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(album.AverageRating).To(Equal(3.0))
|
||||||
|
|
||||||
|
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
||||||
|
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rounds to 2 decimal places", func() {
|
||||||
|
newID := id.NewRandom()
|
||||||
|
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "rounding test album"})).To(Succeed())
|
||||||
|
|
||||||
|
Expect(albumRepo.SetRating(5, newID)).To(Succeed())
|
||||||
|
|
||||||
|
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
|
||||||
|
user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
|
||||||
|
Expect(user2Repo.SetRating(4, newID)).To(Succeed())
|
||||||
|
|
||||||
|
user3Ctx := request.WithUser(GinkgoT().Context(), thirdUser)
|
||||||
|
user3Repo := NewAlbumRepository(user3Ctx, GetDBXBuilder()).(*albumRepository)
|
||||||
|
Expect(user3Repo.SetRating(4, newID)).To(Succeed())
|
||||||
|
|
||||||
|
album, err := albumRepo.Get(newID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(album.AverageRating).To(Equal(4.33)) // (5 + 4 + 4) / 3 = 4.333...
|
||||||
|
|
||||||
|
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
||||||
|
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("dbAlbum mapping", func() {
|
Describe("dbAlbum mapping", func() {
|
||||||
var (
|
var (
|
||||||
a model.Album
|
a model.Album
|
||||||
|
|||||||
@ -32,6 +32,7 @@ var _ = Describe("Collation", func() {
|
|||||||
Entry("media_file.sort_title", "media_file", "sort_title"),
|
Entry("media_file.sort_title", "media_file", "sort_title"),
|
||||||
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
|
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
|
||||||
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
|
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
|
||||||
|
Entry("playlist.name", "playlist", "name"),
|
||||||
Entry("radio.name", "radio", "name"),
|
Entry("radio.name", "radio", "name"),
|
||||||
Entry("user.name", "user", "name"),
|
Entry("user.name", "user", "name"),
|
||||||
)
|
)
|
||||||
@ -53,6 +54,7 @@ var _ = Describe("Collation", func() {
|
|||||||
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
||||||
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
||||||
Entry("media_file.path", "media_file", "path collate nocase"),
|
Entry("media_file.path", "media_file", "path collate nocase"),
|
||||||
|
Entry("playlist.name", "playlist", "name collate nocase"),
|
||||||
Entry("radio.name", "radio", "name collate nocase"),
|
Entry("radio.name", "radio", "name collate nocase"),
|
||||||
Entry("user.user_name", "user", "user_name collate nocase"),
|
Entry("user.user_name", "user", "user_name collate nocase"),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -266,6 +266,10 @@ func (r *libraryRepository) Delete(id int) error {
|
|||||||
defer libLock.Unlock()
|
defer libLock.Unlock()
|
||||||
delete(libCache, id)
|
delete(libCache, id)
|
||||||
|
|
||||||
|
// Clean up orphaned plugin references for the deleted library
|
||||||
|
if err := cleanupPluginLibraryReferences(r.db, id); err != nil {
|
||||||
|
log.Error(r.ctx, "Failed to cleanup plugin library references", "libraryID", id, err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -124,6 +124,25 @@ func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, er
|
|||||||
return r.count(query, options...)
|
return r.count(query, options...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *mediaFileRepository) CountBySuffix(options ...model.QueryOptions) (map[string]int64, error) {
|
||||||
|
sel := r.newSelect(options...).
|
||||||
|
Columns("lower(suffix) as suffix", "count(*) as count").
|
||||||
|
GroupBy("lower(suffix)")
|
||||||
|
var res []struct {
|
||||||
|
Suffix string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
err := r.queryAll(sel, &res)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
counts := make(map[string]int64, len(res))
|
||||||
|
for _, c := range res {
|
||||||
|
counts[c.Suffix] = c.Count
|
||||||
|
}
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *mediaFileRepository) Exists(id string) (bool, error) {
|
func (r *mediaFileRepository) Exists(id string) (bool, error) {
|
||||||
return r.exists(Eq{"media_file.id": id})
|
return r.exists(Eq{"media_file.id": id})
|
||||||
}
|
}
|
||||||
@ -332,15 +351,18 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries
|
// FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries
|
||||||
|
// It uses a lightweight query without annotation/bookmark joins since those are not needed for matching
|
||||||
func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
|
func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
|
||||||
sel := r.selectMediaFile().Where(And{
|
sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name").
|
||||||
NotEq{"media_file.library_id": missing.LibraryID},
|
LeftJoin("library on media_file.library_id = library.id").
|
||||||
Eq{"media_file.mbz_release_track_id": missing.MbzReleaseTrackID},
|
Where(And{
|
||||||
NotEq{"media_file.mbz_release_track_id": ""}, // Exclude empty MBZ Track IDs
|
NotEq{"media_file.library_id": missing.LibraryID},
|
||||||
Eq{"media_file.suffix": missing.Suffix},
|
Eq{"media_file.mbz_release_track_id": missing.MbzReleaseTrackID},
|
||||||
Gt{"media_file.created_at": since},
|
NotEq{"media_file.mbz_release_track_id": ""}, // Exclude empty MBZ Track IDs
|
||||||
Eq{"media_file.missing": false},
|
Eq{"media_file.suffix": missing.Suffix},
|
||||||
}).OrderBy("media_file.created_at DESC")
|
Gt{"media_file.created_at": since},
|
||||||
|
Eq{"media_file.missing": false},
|
||||||
|
}).OrderBy("media_file.created_at DESC")
|
||||||
|
|
||||||
var res dbMediaFiles
|
var res dbMediaFiles
|
||||||
err := r.queryAll(sel, &res)
|
err := r.queryAll(sel, &res)
|
||||||
@ -351,19 +373,22 @@ func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFil
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FindRecentFilesByProperties finds recently added files by intrinsic properties in other libraries
|
// FindRecentFilesByProperties finds recently added files by intrinsic properties in other libraries
|
||||||
|
// It uses a lightweight query without annotation/bookmark joins since those are not needed for matching
|
||||||
func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
|
func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
|
||||||
sel := r.selectMediaFile().Where(And{
|
sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name").
|
||||||
NotEq{"media_file.library_id": missing.LibraryID},
|
LeftJoin("library on media_file.library_id = library.id").
|
||||||
Eq{"media_file.title": missing.Title},
|
Where(And{
|
||||||
Eq{"media_file.size": missing.Size},
|
NotEq{"media_file.library_id": missing.LibraryID},
|
||||||
Eq{"media_file.suffix": missing.Suffix},
|
Eq{"media_file.title": missing.Title},
|
||||||
Eq{"media_file.disc_number": missing.DiscNumber},
|
Eq{"media_file.size": missing.Size},
|
||||||
Eq{"media_file.track_number": missing.TrackNumber},
|
Eq{"media_file.suffix": missing.Suffix},
|
||||||
Eq{"media_file.album": missing.Album},
|
Eq{"media_file.disc_number": missing.DiscNumber},
|
||||||
Eq{"media_file.mbz_release_track_id": ""}, // Exclude files with MBZ Track ID
|
Eq{"media_file.track_number": missing.TrackNumber},
|
||||||
Gt{"media_file.created_at": since},
|
Eq{"media_file.album": missing.Album},
|
||||||
Eq{"media_file.missing": false},
|
Eq{"media_file.mbz_release_track_id": ""}, // Exclude files with MBZ Track ID
|
||||||
}).OrderBy("media_file.created_at DESC")
|
Gt{"media_file.created_at": since},
|
||||||
|
Eq{"media_file.missing": false},
|
||||||
|
}).OrderBy("media_file.created_at DESC")
|
||||||
|
|
||||||
var res dbMediaFiles
|
var res dbMediaFiles
|
||||||
err := r.queryAll(sel, &res)
|
err := r.queryAll(sel, &res)
|
||||||
|
|||||||
@ -41,6 +41,44 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
Expect(mr.CountAll()).To(Equal(int64(10)))
|
Expect(mr.CountAll()).To(Equal(int64(10)))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("CountBySuffix", func() {
|
||||||
|
var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
mp3File = model.MediaFile{ID: "suffix-mp3", LibraryID: 1, Suffix: "mp3", Path: "/test/file.mp3"}
|
||||||
|
flacFile1 = model.MediaFile{ID: "suffix-flac1", LibraryID: 1, Suffix: "flac", Path: "/test/file1.flac"}
|
||||||
|
flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "/test/file2.flac"}
|
||||||
|
flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "/test/file.FLAC"}
|
||||||
|
|
||||||
|
Expect(mr.Put(&mp3File)).To(Succeed())
|
||||||
|
Expect(mr.Put(&flacFile1)).To(Succeed())
|
||||||
|
Expect(mr.Put(&flacFile2)).To(Succeed())
|
||||||
|
Expect(mr.Put(&flacUpperFile)).To(Succeed())
|
||||||
|
})
|
||||||
|
|
||||||
|
AfterEach(func() {
|
||||||
|
_ = mr.Delete(mp3File.ID)
|
||||||
|
_ = mr.Delete(flacFile1.ID)
|
||||||
|
_ = mr.Delete(flacFile2.ID)
|
||||||
|
_ = mr.Delete(flacUpperFile.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("counts media files grouped by suffix with lowercase normalization", func() {
|
||||||
|
counts, err := mr.CountBySuffix()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Should have lowercase keys only
|
||||||
|
Expect(counts).To(HaveKey("mp3"))
|
||||||
|
Expect(counts).To(HaveKey("flac"))
|
||||||
|
Expect(counts).ToNot(HaveKey("FLAC"))
|
||||||
|
|
||||||
|
// mp3: 1 file
|
||||||
|
Expect(counts["mp3"]).To(Equal(int64(1)))
|
||||||
|
// flac: 3 files (2 lowercase + 1 uppercase normalized)
|
||||||
|
Expect(counts["flac"]).To(Equal(int64(3)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
It("returns songs ordered by lyrics with a specific title/artist", func() {
|
It("returns songs ordered by lyrics with a specific title/artist", func() {
|
||||||
// attempt to mimic filters.SongsByArtistTitleWithLyricsFirst, except we want all items
|
// attempt to mimic filters.SongsByArtistTitleWithLyricsFirst, except we want all items
|
||||||
results, err := mr.GetAll(model.QueryOptions{
|
results, err := mr.GetAll(model.QueryOptions{
|
||||||
@ -119,6 +157,74 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
Expect(mf.PlayCount).To(Equal(int64(1)))
|
Expect(mf.PlayCount).To(Equal(int64(1)))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("AverageRating", func() {
|
||||||
|
var raw *mediaFileRepository
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
raw = mr.(*mediaFileRepository)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 0 when no ratings exist", func() {
|
||||||
|
newID := id.NewRandom()
|
||||||
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/no-rating.mp3"})).To(Succeed())
|
||||||
|
|
||||||
|
mf, err := mr.Get(newID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(mf.AverageRating).To(Equal(0.0))
|
||||||
|
|
||||||
|
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns the user's rating as average when only one user rated", func() {
|
||||||
|
newID := id.NewRandom()
|
||||||
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/single-rating.mp3"})).To(Succeed())
|
||||||
|
Expect(mr.SetRating(5, newID)).To(Succeed())
|
||||||
|
|
||||||
|
mf, err := mr.Get(newID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(mf.AverageRating).To(Equal(5.0))
|
||||||
|
|
||||||
|
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
||||||
|
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("calculates average across multiple users", func() {
|
||||||
|
newID := id.NewRandom()
|
||||||
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/multi-rating.mp3"})).To(Succeed())
|
||||||
|
|
||||||
|
Expect(mr.SetRating(3, newID)).To(Succeed())
|
||||||
|
|
||||||
|
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
|
||||||
|
user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder())
|
||||||
|
Expect(user2Repo.SetRating(5, newID)).To(Succeed())
|
||||||
|
|
||||||
|
mf, err := mr.Get(newID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(mf.AverageRating).To(Equal(4.0))
|
||||||
|
|
||||||
|
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
||||||
|
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("excludes zero ratings from average calculation", func() {
|
||||||
|
newID := id.NewRandom()
|
||||||
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/zero-excluded.mp3"})).To(Succeed())
|
||||||
|
|
||||||
|
Expect(mr.SetRating(4, newID)).To(Succeed())
|
||||||
|
|
||||||
|
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
|
||||||
|
user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder())
|
||||||
|
Expect(user2Repo.SetRating(0, newID)).To(Succeed())
|
||||||
|
|
||||||
|
mf, err := mr.Get(newID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(mf.AverageRating).To(Equal(4.0))
|
||||||
|
|
||||||
|
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
||||||
|
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
It("preserves play date if and only if provided date is older", func() {
|
It("preserves play date if and only if provided date is older", func() {
|
||||||
id := "incplay.playdate"
|
id := "incplay.playdate"
|
||||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
|
||||||
|
|||||||
@ -93,6 +93,10 @@ func (s *SQLStore) Scrobble(ctx context.Context) model.ScrobbleRepository {
|
|||||||
return NewScrobbleRepository(ctx, s.getDBXBuilder())
|
return NewScrobbleRepository(ctx, s.getDBXBuilder())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository {
|
||||||
|
return NewPluginRepository(ctx, s.getDBXBuilder())
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
||||||
switch m.(type) {
|
switch m.(type) {
|
||||||
case model.User:
|
case model.User:
|
||||||
@ -117,6 +121,8 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
|
|||||||
return s.Share(ctx).(model.ResourceRepository)
|
return s.Share(ctx).(model.ResourceRepository)
|
||||||
case model.Tag:
|
case model.Tag:
|
||||||
return s.Tag(ctx).(model.ResourceRepository)
|
return s.Tag(ctx).(model.ResourceRepository)
|
||||||
|
case model.Plugin:
|
||||||
|
return s.Plugin(ctx).(model.ResourceRepository)
|
||||||
}
|
}
|
||||||
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
|
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -130,7 +130,8 @@ var (
|
|||||||
var (
|
var (
|
||||||
adminUser = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true}
|
adminUser = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true}
|
||||||
regularUser = model.User{ID: "2222", UserName: "regular-user", Name: "Regular User", Email: "regular@example.com"}
|
regularUser = model.User{ID: "2222", UserName: "regular-user", Name: "Regular User", Email: "regular@example.com"}
|
||||||
testUsers = model.Users{adminUser, regularUser}
|
thirdUser = model.User{ID: "3333", UserName: "third-user", Name: "Third User", Email: "third@example.com"}
|
||||||
|
testUsers = model.Users{adminUser, regularUser, thirdUser}
|
||||||
)
|
)
|
||||||
|
|
||||||
func p(path string) string {
|
func p(path string) string {
|
||||||
|
|||||||
86
persistence/plugin_cleanup.go
Normal file
86
persistence/plugin_cleanup.go
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
package persistence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cleanupPluginUserReferences removes a user ID from all plugins' users JSON arrays
|
||||||
|
// and auto-disables plugins that lose their only permitted user (when users permission is required).
|
||||||
|
// This is called from userRepository.Delete() to maintain referential integrity.
|
||||||
|
func cleanupPluginUserReferences(db dbx.Builder, userID string) error {
|
||||||
|
// SQLite JSON function: json_remove removes the element at the path where user matches.
|
||||||
|
// We use a subquery with json_each to find and remove the user ID from the array.
|
||||||
|
// This updates all plugins where the users array contains the given user ID.
|
||||||
|
_, err := db.NewQuery(`
|
||||||
|
UPDATE plugin
|
||||||
|
SET users = (
|
||||||
|
SELECT json_group_array(value)
|
||||||
|
FROM json_each(plugin.users)
|
||||||
|
WHERE value != {:userID}
|
||||||
|
),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE users IS NOT NULL
|
||||||
|
AND users != ''
|
||||||
|
AND EXISTS (SELECT 1 FROM json_each(plugin.users) WHERE value = {:userID})
|
||||||
|
`).Bind(dbx.Params{"userID": userID}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-disable plugins that:
|
||||||
|
// 1. Are currently enabled
|
||||||
|
// 2. Require users permission (manifest has permissions.users)
|
||||||
|
// 3. Don't have allUsers enabled
|
||||||
|
// 4. Now have an empty users array after cleanup
|
||||||
|
//
|
||||||
|
// The manifest check uses JSON path to see if permissions.users exists.
|
||||||
|
_, err = db.NewQuery(`
|
||||||
|
UPDATE plugin
|
||||||
|
SET enabled = false,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE enabled = true
|
||||||
|
AND all_users = false
|
||||||
|
AND json_extract(manifest, '$.permissions.users') IS NOT NULL
|
||||||
|
AND (users IS NULL OR users = '' OR users = '[]' OR json_array_length(users) = 0)
|
||||||
|
`).Execute()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupPluginLibraryReferences removes a library ID from all plugins' libraries JSON arrays
|
||||||
|
// and auto-disables plugins that lose their only permitted library (when library permission is required).
|
||||||
|
// This is called from libraryRepository.Delete() to maintain referential integrity.
|
||||||
|
func cleanupPluginLibraryReferences(db dbx.Builder, libraryID int) error {
|
||||||
|
// SQLite JSON function: we filter out the library ID from the array.
|
||||||
|
// Libraries are stored as integers in the JSON array.
|
||||||
|
_, err := db.NewQuery(`
|
||||||
|
UPDATE plugin
|
||||||
|
SET libraries = (
|
||||||
|
SELECT json_group_array(value)
|
||||||
|
FROM json_each(plugin.libraries)
|
||||||
|
WHERE CAST(value AS INTEGER) != {:libraryID}
|
||||||
|
),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE libraries IS NOT NULL
|
||||||
|
AND libraries != ''
|
||||||
|
AND EXISTS (SELECT 1 FROM json_each(plugin.libraries) WHERE CAST(value AS INTEGER) = {:libraryID})
|
||||||
|
`).Bind(dbx.Params{"libraryID": libraryID}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-disable plugins that:
|
||||||
|
// 1. Are currently enabled
|
||||||
|
// 2. Require library permission (manifest has permissions.library)
|
||||||
|
// 3. Don't have allLibraries enabled
|
||||||
|
// 4. Now have an empty libraries array after cleanup
|
||||||
|
_, err = db.NewQuery(`
|
||||||
|
UPDATE plugin
|
||||||
|
SET enabled = false,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE enabled = true
|
||||||
|
AND all_libraries = false
|
||||||
|
AND json_extract(manifest, '$.permissions.library') IS NOT NULL
|
||||||
|
AND (libraries IS NULL OR libraries = '' OR libraries = '[]' OR json_array_length(libraries) = 0)
|
||||||
|
`).Execute()
|
||||||
|
return err
|
||||||
|
}
|
||||||
263
persistence/plugin_cleanup_test.go
Normal file
263
persistence/plugin_cleanup_test.go
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
package persistence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Plugin Cleanup", func() {
|
||||||
|
var pluginRepo model.PluginRepository
|
||||||
|
var userRepo model.UserRepository
|
||||||
|
var libraryRepo model.LibraryRepository
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
ctx := GinkgoT().Context()
|
||||||
|
ctx = request.WithUser(ctx, model.User{ID: "admin", UserName: "admin", IsAdmin: true})
|
||||||
|
db := GetDBXBuilder()
|
||||||
|
pluginRepo = NewPluginRepository(ctx, db)
|
||||||
|
userRepo = NewUserRepository(ctx, db)
|
||||||
|
libraryRepo = NewLibraryRepository(ctx, db)
|
||||||
|
|
||||||
|
// Clean up any existing plugins
|
||||||
|
all, _ := pluginRepo.GetAll()
|
||||||
|
for _, p := range all {
|
||||||
|
_ = pluginRepo.Delete(p.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
AfterEach(func() {
|
||||||
|
// Clean up after tests
|
||||||
|
all, _ := pluginRepo.GetAll()
|
||||||
|
for _, p := range all {
|
||||||
|
_ = pluginRepo.Delete(p.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("cleanupPluginUserReferences", func() {
|
||||||
|
It("removes user ID from plugin users array", func() {
|
||||||
|
// Create a plugin with multiple users
|
||||||
|
plugin := &model.Plugin{
|
||||||
|
ID: "test-plugin",
|
||||||
|
Path: "/plugins/test.wasm",
|
||||||
|
Manifest: `{"name":"test"}`,
|
||||||
|
SHA256: "abc123",
|
||||||
|
Users: `["user1","user2","user3"]`,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||||
|
|
||||||
|
// Clean up user2 reference
|
||||||
|
db := GetDBXBuilder()
|
||||||
|
Expect(cleanupPluginUserReferences(db, "user2")).To(Succeed())
|
||||||
|
|
||||||
|
// Verify user2 was removed
|
||||||
|
updated, err := pluginRepo.Get("test-plugin")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(updated.Users).To(Equal(`["user1","user3"]`))
|
||||||
|
Expect(updated.Enabled).To(BeTrue()) // Still has users, should remain enabled
|
||||||
|
})
|
||||||
|
|
||||||
|
It("auto-disables plugin when last permitted user is removed", func() {
|
||||||
|
// Create a plugin that requires users permission with only one user
|
||||||
|
plugin := &model.Plugin{
|
||||||
|
ID: "user-plugin",
|
||||||
|
Path: "/plugins/user.wasm",
|
||||||
|
Manifest: `{"name":"user-plugin","permissions":{"users":{}}}`,
|
||||||
|
SHA256: "def456",
|
||||||
|
Users: `["only-user"]`,
|
||||||
|
AllUsers: false,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||||
|
|
||||||
|
// Remove the only user
|
||||||
|
db := GetDBXBuilder()
|
||||||
|
Expect(cleanupPluginUserReferences(db, "only-user")).To(Succeed())
|
||||||
|
|
||||||
|
// Verify plugin was auto-disabled
|
||||||
|
updated, err := pluginRepo.Get("user-plugin")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(updated.Users).To(Equal(`[]`))
|
||||||
|
Expect(updated.Enabled).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not disable plugin when allUsers is true", func() {
|
||||||
|
plugin := &model.Plugin{
|
||||||
|
ID: "all-users-plugin",
|
||||||
|
Path: "/plugins/all.wasm",
|
||||||
|
Manifest: `{"name":"all-users","permissions":{"users":{}}}`,
|
||||||
|
SHA256: "ghi789",
|
||||||
|
Users: `["user1"]`,
|
||||||
|
AllUsers: true,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||||
|
|
||||||
|
// Remove the user (but allUsers is true)
|
||||||
|
db := GetDBXBuilder()
|
||||||
|
Expect(cleanupPluginUserReferences(db, "user1")).To(Succeed())
|
||||||
|
|
||||||
|
// Plugin should still be enabled because allUsers is true
|
||||||
|
updated, err := pluginRepo.Get("all-users-plugin")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(updated.Enabled).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not affect plugins without users permission requirement", func() {
|
||||||
|
plugin := &model.Plugin{
|
||||||
|
ID: "no-users-perm",
|
||||||
|
Path: "/plugins/noperm.wasm",
|
||||||
|
Manifest: `{"name":"no-perm"}`, // No permissions.users in manifest
|
||||||
|
SHA256: "jkl012",
|
||||||
|
Users: `["user1"]`,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||||
|
|
||||||
|
// Remove the user
|
||||||
|
db := GetDBXBuilder()
|
||||||
|
Expect(cleanupPluginUserReferences(db, "user1")).To(Succeed())
|
||||||
|
|
||||||
|
// Plugin should still be enabled (no users permission requirement)
|
||||||
|
updated, err := pluginRepo.Get("no-users-perm")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(updated.Users).To(Equal(`[]`))
|
||||||
|
Expect(updated.Enabled).To(BeTrue())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("cleanupPluginLibraryReferences", func() {
|
||||||
|
It("removes library ID from plugin libraries array", func() {
|
||||||
|
// Create a plugin with multiple libraries
|
||||||
|
plugin := &model.Plugin{
|
||||||
|
ID: "lib-plugin",
|
||||||
|
Path: "/plugins/lib.wasm",
|
||||||
|
Manifest: `{"name":"lib-plugin"}`,
|
||||||
|
SHA256: "mno345",
|
||||||
|
Libraries: `[1,2,3]`,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||||
|
|
||||||
|
// Clean up library 2 reference
|
||||||
|
db := GetDBXBuilder()
|
||||||
|
Expect(cleanupPluginLibraryReferences(db, 2)).To(Succeed())
|
||||||
|
|
||||||
|
// Verify library 2 was removed
|
||||||
|
updated, err := pluginRepo.Get("lib-plugin")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(updated.Libraries).To(Equal(`[1,3]`))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("auto-disables plugin when last permitted library is removed", func() {
|
||||||
|
// Create a plugin that requires library permission with only one library
|
||||||
|
plugin := &model.Plugin{
|
||||||
|
ID: "lib-only-plugin",
|
||||||
|
Path: "/plugins/libonly.wasm",
|
||||||
|
Manifest: `{"name":"lib-only","permissions":{"library":{}}}`,
|
||||||
|
SHA256: "pqr678",
|
||||||
|
Libraries: `[99]`,
|
||||||
|
AllLibraries: false,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||||
|
|
||||||
|
// Remove the only library
|
||||||
|
db := GetDBXBuilder()
|
||||||
|
Expect(cleanupPluginLibraryReferences(db, 99)).To(Succeed())
|
||||||
|
|
||||||
|
// Verify plugin was auto-disabled
|
||||||
|
updated, err := pluginRepo.Get("lib-only-plugin")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(updated.Libraries).To(Equal(`[]`))
|
||||||
|
Expect(updated.Enabled).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not disable plugin when allLibraries is true", func() {
|
||||||
|
plugin := &model.Plugin{
|
||||||
|
ID: "all-libs-plugin",
|
||||||
|
Path: "/plugins/alllibs.wasm",
|
||||||
|
Manifest: `{"name":"all-libs","permissions":{"library":{}}}`,
|
||||||
|
SHA256: "stu901",
|
||||||
|
Libraries: `[1]`,
|
||||||
|
AllLibraries: true,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||||
|
|
||||||
|
// Remove the library (but allLibraries is true)
|
||||||
|
db := GetDBXBuilder()
|
||||||
|
Expect(cleanupPluginLibraryReferences(db, 1)).To(Succeed())
|
||||||
|
|
||||||
|
// Plugin should still be enabled
|
||||||
|
updated, err := pluginRepo.Get("all-libs-plugin")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(updated.Enabled).To(BeTrue())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("User Delete integration", func() {
|
||||||
|
It("cleans up plugin references when user is deleted", func() {
|
||||||
|
// Create a test user
|
||||||
|
user := &model.User{
|
||||||
|
ID: "test-delete-user",
|
||||||
|
UserName: "plugin-cleanup-test-user",
|
||||||
|
IsAdmin: false,
|
||||||
|
}
|
||||||
|
user.NewPassword = "password123"
|
||||||
|
Expect(userRepo.Put(user)).To(Succeed())
|
||||||
|
|
||||||
|
// Create a plugin referencing this user
|
||||||
|
plugin := &model.Plugin{
|
||||||
|
ID: "user-ref-plugin",
|
||||||
|
Path: "/plugins/userref.wasm",
|
||||||
|
Manifest: `{"name":"user-ref"}`,
|
||||||
|
SHA256: "xyz123",
|
||||||
|
Users: `["test-delete-user","other-user"]`,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||||
|
|
||||||
|
// Delete the user
|
||||||
|
Expect(userRepo.Delete("test-delete-user")).To(Succeed())
|
||||||
|
|
||||||
|
// Verify user was removed from plugin
|
||||||
|
updated, err := pluginRepo.Get("user-ref-plugin")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(updated.Users).To(Equal(`["other-user"]`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Library Delete integration", func() {
|
||||||
|
It("cleans up plugin references when library is deleted", func() {
|
||||||
|
// Create a test library (ID > 1 since ID 1 cannot be deleted)
|
||||||
|
library := &model.Library{
|
||||||
|
ID: 99,
|
||||||
|
Name: "Test Library",
|
||||||
|
Path: "/tmp/test-lib",
|
||||||
|
}
|
||||||
|
Expect(libraryRepo.Put(library)).To(Succeed())
|
||||||
|
|
||||||
|
// Create a plugin referencing this library
|
||||||
|
plugin := &model.Plugin{
|
||||||
|
ID: "lib-ref-plugin",
|
||||||
|
Path: "/plugins/libref.wasm",
|
||||||
|
Manifest: `{"name":"lib-ref"}`,
|
||||||
|
SHA256: "abc789",
|
||||||
|
Libraries: `[99,1]`,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||||
|
|
||||||
|
// Delete the library
|
||||||
|
Expect(libraryRepo.Delete(99)).To(Succeed())
|
||||||
|
|
||||||
|
// Verify library was removed from plugin
|
||||||
|
updated, err := pluginRepo.Get("lib-ref-plugin")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(updated.Libraries).To(Equal(`[1]`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
161
persistence/plugin_repository.go
Normal file
161
persistence/plugin_repository.go
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
package persistence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
. "github.com/Masterminds/squirrel"
|
||||||
|
"github.com/deluan/rest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pluginRepository struct {
|
||||||
|
sqlRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPluginRepository(ctx context.Context, db dbx.Builder) model.PluginRepository {
|
||||||
|
r := &pluginRepository{}
|
||||||
|
r.ctx = ctx
|
||||||
|
r.db = db
|
||||||
|
r.registerModel(&model.Plugin{}, map[string]filterFunc{
|
||||||
|
"id": idFilter("plugin"),
|
||||||
|
"enabled": booleanFilter,
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pluginRepository) isPermitted() bool {
|
||||||
|
user := loggedUser(r.ctx)
|
||||||
|
return user.IsAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pluginRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||||
|
if !r.isPermitted() {
|
||||||
|
return 0, rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
sql := r.newSelect()
|
||||||
|
return r.count(sql, options...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pluginRepository) Delete(id string) error {
|
||||||
|
if !r.isPermitted() {
|
||||||
|
return rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
return r.delete(Eq{"id": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pluginRepository) Get(id string) (*model.Plugin, error) {
|
||||||
|
if !r.isPermitted() {
|
||||||
|
return nil, rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
sel := r.newSelect().Where(Eq{"id": id}).Columns("*")
|
||||||
|
res := model.Plugin{}
|
||||||
|
err := r.queryOne(sel, &res)
|
||||||
|
return &res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pluginRepository) GetAll(options ...model.QueryOptions) (model.Plugins, error) {
|
||||||
|
if !r.isPermitted() {
|
||||||
|
return nil, rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
sel := r.newSelect(options...).Columns("*")
|
||||||
|
res := model.Plugins{}
|
||||||
|
err := r.queryAll(sel, &res)
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pluginRepository) Put(plugin *model.Plugin) error {
|
||||||
|
if !r.isPermitted() {
|
||||||
|
return rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if plugin.ID == "" {
|
||||||
|
return errors.New("plugin ID cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert using INSERT ... ON CONFLICT for atomic operation
|
||||||
|
_, err := r.db.NewQuery(`
|
||||||
|
INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, enabled, last_error, sha256, created_at, updated_at)
|
||||||
|
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
path = excluded.path,
|
||||||
|
manifest = excluded.manifest,
|
||||||
|
config = excluded.config,
|
||||||
|
users = excluded.users,
|
||||||
|
all_users = excluded.all_users,
|
||||||
|
libraries = excluded.libraries,
|
||||||
|
all_libraries = excluded.all_libraries,
|
||||||
|
enabled = excluded.enabled,
|
||||||
|
last_error = excluded.last_error,
|
||||||
|
sha256 = excluded.sha256,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`).Bind(dbx.Params{
|
||||||
|
"id": plugin.ID,
|
||||||
|
"path": plugin.Path,
|
||||||
|
"manifest": plugin.Manifest,
|
||||||
|
"config": plugin.Config,
|
||||||
|
"users": plugin.Users,
|
||||||
|
"all_users": plugin.AllUsers,
|
||||||
|
"libraries": plugin.Libraries,
|
||||||
|
"all_libraries": plugin.AllLibraries,
|
||||||
|
"enabled": plugin.Enabled,
|
||||||
|
"last_error": plugin.LastError,
|
||||||
|
"sha256": plugin.SHA256,
|
||||||
|
"created_at": time.Now(),
|
||||||
|
"updated_at": plugin.UpdatedAt,
|
||||||
|
}).Execute()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pluginRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||||
|
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pluginRepository) EntityName() string {
|
||||||
|
return "plugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pluginRepository) NewInstance() any {
|
||||||
|
return &model.Plugin{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pluginRepository) Read(id string) (any, error) {
|
||||||
|
return r.Get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pluginRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||||
|
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pluginRepository) Save(entity any) (string, error) {
|
||||||
|
p := entity.(*model.Plugin)
|
||||||
|
if !r.isPermitted() {
|
||||||
|
return "", rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
err := r.Put(p)
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return "", rest.ErrNotFound
|
||||||
|
}
|
||||||
|
return p.ID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pluginRepository) Update(id string, entity any, cols ...string) error {
|
||||||
|
p := entity.(*model.Plugin)
|
||||||
|
p.ID = id
|
||||||
|
if !r.isPermitted() {
|
||||||
|
return rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
err := r.Put(p)
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return rest.ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ model.PluginRepository = (*pluginRepository)(nil)
|
||||||
|
var _ rest.Repository = (*pluginRepository)(nil)
|
||||||
|
var _ rest.Persistable = (*pluginRepository)(nil)
|
||||||
227
persistence/plugin_repository_test.go
Normal file
227
persistence/plugin_repository_test.go
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
package persistence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/deluan/rest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("PluginRepository", func() {
|
||||||
|
var repo model.PluginRepository
|
||||||
|
|
||||||
|
Describe("Admin User", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
ctx := GinkgoT().Context()
|
||||||
|
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||||
|
repo = NewPluginRepository(ctx, GetDBXBuilder())
|
||||||
|
|
||||||
|
// Clean up any existing plugins
|
||||||
|
all, _ := repo.GetAll()
|
||||||
|
for _, p := range all {
|
||||||
|
_ = repo.Delete(p.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
AfterEach(func() {
|
||||||
|
// Clean up after tests
|
||||||
|
all, _ := repo.GetAll()
|
||||||
|
for _, p := range all {
|
||||||
|
_ = repo.Delete(p.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("CountAll", func() {
|
||||||
|
It("returns 0 when no plugins exist", func() {
|
||||||
|
Expect(repo.CountAll()).To(Equal(int64(0)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns the number of plugins in the DB", func() {
|
||||||
|
_ = repo.Put(&model.Plugin{ID: "test-plugin-1", Path: "/plugins/test1.wasm", Manifest: "{}", SHA256: "abc123"})
|
||||||
|
_ = repo.Put(&model.Plugin{ID: "test-plugin-2", Path: "/plugins/test2.wasm", Manifest: "{}", SHA256: "def456"})
|
||||||
|
|
||||||
|
Expect(repo.CountAll()).To(Equal(int64(2)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Delete", func() {
|
||||||
|
It("deletes existing item", func() {
|
||||||
|
plugin := &model.Plugin{ID: "to-delete", Path: "/plugins/delete.wasm", Manifest: "{}", SHA256: "hash"}
|
||||||
|
_ = repo.Put(plugin)
|
||||||
|
|
||||||
|
err := repo.Delete(plugin.ID)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
|
||||||
|
_, err = repo.Get(plugin.ID)
|
||||||
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Get", func() {
|
||||||
|
It("returns an existing item", func() {
|
||||||
|
plugin := &model.Plugin{ID: "test-get", Path: "/plugins/test.wasm", Manifest: `{"name":"test"}`, SHA256: "hash123"}
|
||||||
|
_ = repo.Put(plugin)
|
||||||
|
|
||||||
|
res, err := repo.Get(plugin.ID)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(res.ID).To(Equal(plugin.ID))
|
||||||
|
Expect(res.Path).To(Equal(plugin.Path))
|
||||||
|
Expect(res.Manifest).To(Equal(plugin.Manifest))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("errors when missing", func() {
|
||||||
|
_, err := repo.Get("notanid")
|
||||||
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GetAll", func() {
|
||||||
|
It("returns all items from the DB", func() {
|
||||||
|
_ = repo.Put(&model.Plugin{ID: "plugin-a", Path: "/plugins/a.wasm", Manifest: "{}", SHA256: "hash1"})
|
||||||
|
_ = repo.Put(&model.Plugin{ID: "plugin-b", Path: "/plugins/b.wasm", Manifest: "{}", SHA256: "hash2"})
|
||||||
|
|
||||||
|
all, err := repo.GetAll()
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(all).To(HaveLen(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("supports pagination", func() {
|
||||||
|
_ = repo.Put(&model.Plugin{ID: "plugin-1", Path: "/plugins/1.wasm", Manifest: "{}", SHA256: "h1"})
|
||||||
|
_ = repo.Put(&model.Plugin{ID: "plugin-2", Path: "/plugins/2.wasm", Manifest: "{}", SHA256: "h2"})
|
||||||
|
_ = repo.Put(&model.Plugin{ID: "plugin-3", Path: "/plugins/3.wasm", Manifest: "{}", SHA256: "h3"})
|
||||||
|
|
||||||
|
page1, err := repo.GetAll(model.QueryOptions{Max: 2, Offset: 0, Sort: "id"})
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(page1).To(HaveLen(2))
|
||||||
|
|
||||||
|
page2, err := repo.GetAll(model.QueryOptions{Max: 2, Offset: 2, Sort: "id"})
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(page2).To(HaveLen(1))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Put", func() {
|
||||||
|
It("successfully creates a new plugin", func() {
|
||||||
|
plugin := &model.Plugin{
|
||||||
|
ID: "new-plugin",
|
||||||
|
Path: "/plugins/new.wasm",
|
||||||
|
Manifest: `{"name":"new","version":"1.0"}`,
|
||||||
|
Config: `{"setting":"value"}`,
|
||||||
|
SHA256: "sha256hash",
|
||||||
|
Enabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Put(plugin)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
|
||||||
|
saved, err := repo.Get(plugin.ID)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(saved.Path).To(Equal(plugin.Path))
|
||||||
|
Expect(saved.Manifest).To(Equal(plugin.Manifest))
|
||||||
|
Expect(saved.Config).To(Equal(plugin.Config))
|
||||||
|
Expect(saved.Enabled).To(BeFalse())
|
||||||
|
Expect(saved.CreatedAt).NotTo(BeZero())
|
||||||
|
Expect(saved.UpdatedAt).NotTo(BeZero())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("successfully updates an existing plugin", func() {
|
||||||
|
plugin := &model.Plugin{
|
||||||
|
ID: "update-plugin",
|
||||||
|
Path: "/plugins/update.wasm",
|
||||||
|
Manifest: `{"name":"test"}`,
|
||||||
|
SHA256: "original",
|
||||||
|
Enabled: false,
|
||||||
|
}
|
||||||
|
_ = repo.Put(plugin)
|
||||||
|
|
||||||
|
plugin.Enabled = true
|
||||||
|
plugin.Config = `{"new":"config"}`
|
||||||
|
plugin.SHA256 = "updated"
|
||||||
|
err := repo.Put(plugin)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
|
||||||
|
saved, err := repo.Get(plugin.ID)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(saved.Enabled).To(BeTrue())
|
||||||
|
Expect(saved.Config).To(Equal(`{"new":"config"}`))
|
||||||
|
Expect(saved.SHA256).To(Equal("updated"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("stores and retrieves last_error", func() {
|
||||||
|
plugin := &model.Plugin{
|
||||||
|
ID: "error-plugin",
|
||||||
|
Path: "/plugins/error.wasm",
|
||||||
|
Manifest: "{}",
|
||||||
|
SHA256: "hash",
|
||||||
|
LastError: "failed to load: missing export",
|
||||||
|
}
|
||||||
|
err := repo.Put(plugin)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
|
||||||
|
saved, err := repo.Get(plugin.ID)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(saved.LastError).To(Equal("failed to load: missing export"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("fails when ID is empty", func() {
|
||||||
|
plugin := &model.Plugin{
|
||||||
|
Path: "/plugins/noid.wasm",
|
||||||
|
Manifest: "{}",
|
||||||
|
SHA256: "hash",
|
||||||
|
}
|
||||||
|
err := repo.Put(plugin)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("ID cannot be empty"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Regular User", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
ctx := GinkgoT().Context()
|
||||||
|
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false})
|
||||||
|
repo = NewPluginRepository(ctx, GetDBXBuilder())
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("CountAll", func() {
|
||||||
|
It("fails to count items", func() {
|
||||||
|
_, err := repo.CountAll()
|
||||||
|
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Delete", func() {
|
||||||
|
It("fails to delete items", func() {
|
||||||
|
err := repo.Delete("any-id")
|
||||||
|
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Get", func() {
|
||||||
|
It("fails to get items", func() {
|
||||||
|
_, err := repo.Get("any-id")
|
||||||
|
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GetAll", func() {
|
||||||
|
It("fails to get all items", func() {
|
||||||
|
_, err := repo.GetAll()
|
||||||
|
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Put", func() {
|
||||||
|
It("fails to create/update item", func() {
|
||||||
|
err := repo.Put(&model.Plugin{
|
||||||
|
ID: "user-create",
|
||||||
|
Path: "/plugins/create.wasm",
|
||||||
|
Manifest: "{}",
|
||||||
|
SHA256: "hash",
|
||||||
|
})
|
||||||
|
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -17,7 +17,7 @@ const annotationTable = "annotation"
|
|||||||
func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder {
|
func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder {
|
||||||
userID := loggedUser(r.ctx).ID
|
userID := loggedUser(r.ctx).ID
|
||||||
if userID == invalidUserId {
|
if userID == invalidUserId {
|
||||||
return query
|
return query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
|
||||||
}
|
}
|
||||||
query = query.
|
query = query.
|
||||||
LeftJoin("annotation on ("+
|
LeftJoin("annotation on ("+
|
||||||
@ -38,6 +38,8 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
|
|||||||
query = query.Columns("coalesce(play_count, 0) as play_count")
|
query = query.Columns("coalesce(play_count, 0) as play_count")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query = query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
|
||||||
|
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,7 +81,22 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error {
|
|||||||
|
|
||||||
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
||||||
ratedAt := time.Now()
|
ratedAt := time.Now()
|
||||||
return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
|
err := r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.updateAvgRating(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r sqlRepository) updateAvgRating(itemID string) error {
|
||||||
|
upd := Update(r.tableName).
|
||||||
|
Where(Eq{"id": itemID}).
|
||||||
|
Set("average_rating", Expr(
|
||||||
|
"coalesce((select round(avg(rating), 2) from annotation where item_id = ? and item_type = ? and rating > 0), 0)",
|
||||||
|
itemID, r.tableName,
|
||||||
|
))
|
||||||
|
_, err := r.executeSQL(upd)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
||||||
|
|||||||
@ -340,7 +340,15 @@ func (r *userRepository) Delete(id string) error {
|
|||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
return rest.ErrNotFound
|
return rest.ErrNotFound
|
||||||
}
|
}
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up orphaned plugin references for the deleted user
|
||||||
|
if err := cleanupPluginUserReferences(r.db, id); err != nil {
|
||||||
|
log.Error(r.ctx, "Failed to cleanup plugin user references", "userID", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func keyTo32Bytes(input string) []byte {
|
func keyTo32Bytes(input string) []byte {
|
||||||
|
|||||||
4
plugins/.gitignore
vendored
Normal file
4
plugins/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Rust build artifacts
|
||||||
|
# Cargo.lock is not needed for library crates (this is a cdylib)
|
||||||
|
Cargo.lock
|
||||||
|
target
|
||||||
2482
plugins/README.md
2482
plugins/README.md
File diff suppressed because it is too large
Load Diff
@ -1,166 +0,0 @@
|
|||||||
package plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/plugins/api"
|
|
||||||
"github.com/tetratelabs/wazero"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewWasmMediaAgent creates a new adapter for a MetadataAgent plugin
|
|
||||||
func newWasmMediaAgent(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
|
||||||
loader, err := api.NewMetadataAgentPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error creating media metadata service plugin", "plugin", pluginID, "path", wasmPath, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &wasmMediaAgent{
|
|
||||||
baseCapability: newBaseCapability[api.MetadataAgent, *api.MetadataAgentPlugin](
|
|
||||||
wasmPath,
|
|
||||||
pluginID,
|
|
||||||
CapabilityMetadataAgent,
|
|
||||||
m.metrics,
|
|
||||||
loader,
|
|
||||||
func(ctx context.Context, l *api.MetadataAgentPlugin, path string) (api.MetadataAgent, error) {
|
|
||||||
return l.Load(ctx, path)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wasmMediaAgent adapts a MetadataAgent plugin to implement the agents.Interface
|
|
||||||
type wasmMediaAgent struct {
|
|
||||||
*baseCapability[api.MetadataAgent, *api.MetadataAgentPlugin]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wasmMediaAgent) AgentName() string {
|
|
||||||
return w.id
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wasmMediaAgent) mapError(err error) error {
|
|
||||||
if err != nil && (err.Error() == api.ErrNotFound.Error() || err.Error() == api.ErrNotImplemented.Error()) {
|
|
||||||
return agents.ErrNotFound
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Album-related methods
|
|
||||||
|
|
||||||
func (w *wasmMediaAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
|
||||||
res, err := callMethod(ctx, w, "GetAlbumInfo", func(inst api.MetadataAgent) (*api.AlbumInfoResponse, error) {
|
|
||||||
return inst.GetAlbumInfo(ctx, &api.AlbumInfoRequest{Name: name, Artist: artist, Mbid: mbid})
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, w.mapError(err)
|
|
||||||
}
|
|
||||||
if res == nil || res.Info == nil {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
info := res.Info
|
|
||||||
return &agents.AlbumInfo{
|
|
||||||
Name: info.Name,
|
|
||||||
MBID: info.Mbid,
|
|
||||||
Description: info.Description,
|
|
||||||
URL: info.Url,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wasmMediaAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
|
||||||
res, err := callMethod(ctx, w, "GetAlbumImages", func(inst api.MetadataAgent) (*api.AlbumImagesResponse, error) {
|
|
||||||
return inst.GetAlbumImages(ctx, &api.AlbumImagesRequest{Name: name, Artist: artist, Mbid: mbid})
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, w.mapError(err)
|
|
||||||
}
|
|
||||||
return convertExternalImages(res.Images), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Artist-related methods
|
|
||||||
|
|
||||||
func (w *wasmMediaAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
|
||||||
res, err := callMethod(ctx, w, "GetArtistMBID", func(inst api.MetadataAgent) (*api.ArtistMBIDResponse, error) {
|
|
||||||
return inst.GetArtistMBID(ctx, &api.ArtistMBIDRequest{Id: id, Name: name})
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", w.mapError(err)
|
|
||||||
}
|
|
||||||
return res.GetMbid(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wasmMediaAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
|
||||||
res, err := callMethod(ctx, w, "GetArtistURL", func(inst api.MetadataAgent) (*api.ArtistURLResponse, error) {
|
|
||||||
return inst.GetArtistURL(ctx, &api.ArtistURLRequest{Id: id, Name: name, Mbid: mbid})
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", w.mapError(err)
|
|
||||||
}
|
|
||||||
return res.GetUrl(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wasmMediaAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
|
||||||
res, err := callMethod(ctx, w, "GetArtistBiography", func(inst api.MetadataAgent) (*api.ArtistBiographyResponse, error) {
|
|
||||||
return inst.GetArtistBiography(ctx, &api.ArtistBiographyRequest{Id: id, Name: name, Mbid: mbid})
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", w.mapError(err)
|
|
||||||
}
|
|
||||||
return res.GetBiography(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wasmMediaAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
|
||||||
resp, err := callMethod(ctx, w, "GetSimilarArtists", func(inst api.MetadataAgent) (*api.ArtistSimilarResponse, error) {
|
|
||||||
return inst.GetSimilarArtists(ctx, &api.ArtistSimilarRequest{Id: id, Name: name, Mbid: mbid, Limit: int32(limit)})
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, w.mapError(err)
|
|
||||||
}
|
|
||||||
artists := make([]agents.Artist, 0, len(resp.GetArtists()))
|
|
||||||
for _, a := range resp.GetArtists() {
|
|
||||||
artists = append(artists, agents.Artist{
|
|
||||||
Name: a.GetName(),
|
|
||||||
MBID: a.GetMbid(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return artists, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wasmMediaAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
|
||||||
resp, err := callMethod(ctx, w, "GetArtistImages", func(inst api.MetadataAgent) (*api.ArtistImageResponse, error) {
|
|
||||||
return inst.GetArtistImages(ctx, &api.ArtistImageRequest{Id: id, Name: name, Mbid: mbid})
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, w.mapError(err)
|
|
||||||
}
|
|
||||||
return convertExternalImages(resp.Images), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wasmMediaAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
|
||||||
resp, err := callMethod(ctx, w, "GetArtistTopSongs", func(inst api.MetadataAgent) (*api.ArtistTopSongsResponse, error) {
|
|
||||||
return inst.GetArtistTopSongs(ctx, &api.ArtistTopSongsRequest{Id: id, ArtistName: artistName, Mbid: mbid, Count: int32(count)})
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, w.mapError(err)
|
|
||||||
}
|
|
||||||
songs := make([]agents.Song, 0, len(resp.GetSongs()))
|
|
||||||
for _, s := range resp.GetSongs() {
|
|
||||||
songs = append(songs, agents.Song{
|
|
||||||
Name: s.GetName(),
|
|
||||||
MBID: s.GetMbid(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return songs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to convert ExternalImage objects from the API to the agents package
|
|
||||||
func convertExternalImages(images []*api.ExternalImage) []agents.ExternalImage {
|
|
||||||
result := make([]agents.ExternalImage, 0, len(images))
|
|
||||||
for _, img := range images {
|
|
||||||
result = append(result, agents.ExternalImage{
|
|
||||||
URL: img.GetUrl(),
|
|
||||||
Size: int(img.GetSize()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@ -1,229 +0,0 @@
|
|||||||
package plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
|
||||||
"github.com/navidrome/navidrome/plugins/api"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("Adapter Media Agent", func() {
|
|
||||||
var ctx context.Context
|
|
||||||
var mgr *managerImpl
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
ctx = GinkgoT().Context()
|
|
||||||
|
|
||||||
// Ensure plugins folder is set to testdata
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.Plugins.Folder = testDataDir
|
|
||||||
conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
|
|
||||||
|
|
||||||
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() {
|
|
||||||
It("should return the plugin name", func() {
|
|
||||||
agent := mgr.LoadPlugin("multi_plugin", "MetadataAgent")
|
|
||||||
Expect(agent).NotTo(BeNil(), "multi_plugin should be loaded")
|
|
||||||
Expect(agent.PluginID()).To(Equal("multi_plugin"))
|
|
||||||
})
|
|
||||||
It("should return the agent name", func() {
|
|
||||||
agent, ok := mgr.LoadMediaAgent("multi_plugin")
|
|
||||||
Expect(ok).To(BeTrue(), "multi_plugin should be loaded as media agent")
|
|
||||||
Expect(agent.AgentName()).To(Equal("multi_plugin"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Album methods", func() {
|
|
||||||
var agent *wasmMediaAgent
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
a, ok := mgr.LoadMediaAgent("fake_album_agent")
|
|
||||||
Expect(ok).To(BeTrue(), "fake_album_agent should be loaded")
|
|
||||||
agent = a.(*wasmMediaAgent)
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("GetAlbumInfo", func() {
|
|
||||||
It("should return album information", func() {
|
|
||||||
info, err := agent.GetAlbumInfo(ctx, "Test Album", "Test Artist", "mbid")
|
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
Expect(info).NotTo(BeNil())
|
|
||||||
Expect(info.Name).To(Equal("Test Album"))
|
|
||||||
Expect(info.MBID).To(Equal("album-mbid-123"))
|
|
||||||
Expect(info.Description).To(Equal("This is a test album description"))
|
|
||||||
Expect(info.URL).To(Equal("https://example.com/album"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("should return ErrNotFound when plugin returns not found", func() {
|
|
||||||
_, err := agent.GetAlbumInfo(ctx, "Test Album", "", "mbid")
|
|
||||||
|
|
||||||
Expect(err).To(Equal(agents.ErrNotFound))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("should return ErrNotFound when plugin returns nil response", func() {
|
|
||||||
_, err := agent.GetAlbumInfo(ctx, "", "", "")
|
|
||||||
|
|
||||||
Expect(err).To(Equal(agents.ErrNotFound))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("GetAlbumImages", func() {
|
|
||||||
It("should return album images", func() {
|
|
||||||
images, err := agent.GetAlbumImages(ctx, "Test Album", "Test Artist", "mbid")
|
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
Expect(images).To(Equal([]agents.ExternalImage{
|
|
||||||
{URL: "https://example.com/album1.jpg", Size: 300},
|
|
||||||
{URL: "https://example.com/album2.jpg", Size: 400},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Artist methods", func() {
|
|
||||||
var agent *wasmMediaAgent
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
a, ok := mgr.LoadMediaAgent("fake_artist_agent")
|
|
||||||
Expect(ok).To(BeTrue(), "fake_artist_agent should be loaded")
|
|
||||||
agent = a.(*wasmMediaAgent)
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("GetArtistMBID", func() {
|
|
||||||
It("should return artist MBID", func() {
|
|
||||||
mbid, err := agent.GetArtistMBID(ctx, "artist-id", "Test Artist")
|
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
Expect(mbid).To(Equal("1234567890"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("should return ErrNotFound when plugin returns not found", func() {
|
|
||||||
_, err := agent.GetArtistMBID(ctx, "artist-id", "")
|
|
||||||
|
|
||||||
Expect(err).To(Equal(agents.ErrNotFound))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("GetArtistURL", func() {
|
|
||||||
It("should return artist URL", func() {
|
|
||||||
url, err := agent.GetArtistURL(ctx, "artist-id", "Test Artist", "mbid")
|
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
Expect(url).To(Equal("https://example.com"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("GetArtistBiography", func() {
|
|
||||||
It("should return artist biography", func() {
|
|
||||||
bio, err := agent.GetArtistBiography(ctx, "artist-id", "Test Artist", "mbid")
|
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
Expect(bio).To(Equal("This is a test biography"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("GetSimilarArtists", func() {
|
|
||||||
It("should return similar artists", func() {
|
|
||||||
artists, err := agent.GetSimilarArtists(ctx, "artist-id", "Test Artist", "mbid", 10)
|
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
Expect(artists).To(Equal([]agents.Artist{
|
|
||||||
{Name: "Similar Artist 1", MBID: "mbid1"},
|
|
||||||
{Name: "Similar Artist 2", MBID: "mbid2"},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("GetArtistImages", func() {
|
|
||||||
It("should return artist images", func() {
|
|
||||||
images, err := agent.GetArtistImages(ctx, "artist-id", "Test Artist", "mbid")
|
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
Expect(images).To(Equal([]agents.ExternalImage{
|
|
||||||
{URL: "https://example.com/image1.jpg", Size: 100},
|
|
||||||
{URL: "https://example.com/image2.jpg", Size: 200},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("GetArtistTopSongs", func() {
|
|
||||||
It("should return artist top songs", func() {
|
|
||||||
songs, err := agent.GetArtistTopSongs(ctx, "artist-id", "Test Artist", "mbid", 10)
|
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
Expect(songs).To(Equal([]agents.Song{
|
|
||||||
{Name: "Song 1", MBID: "mbid1"},
|
|
||||||
{Name: "Song 2", MBID: "mbid2"},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Helper functions", func() {
|
|
||||||
It("convertExternalImages should convert API image objects to agent image objects", func() {
|
|
||||||
apiImages := []*api.ExternalImage{
|
|
||||||
{Url: "https://example.com/image1.jpg", Size: 100},
|
|
||||||
{Url: "https://example.com/image2.jpg", Size: 200},
|
|
||||||
}
|
|
||||||
|
|
||||||
agentImages := convertExternalImages(apiImages)
|
|
||||||
Expect(agentImages).To(HaveLen(2))
|
|
||||||
|
|
||||||
for i, img := range agentImages {
|
|
||||||
Expect(img.URL).To(Equal(apiImages[i].Url))
|
|
||||||
Expect(img.Size).To(Equal(int(apiImages[i].Size)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("convertExternalImages should handle empty slice", func() {
|
|
||||||
agentImages := convertExternalImages([]*api.ExternalImage{})
|
|
||||||
Expect(agentImages).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("convertExternalImages should handle nil", func() {
|
|
||||||
agentImages := convertExternalImages(nil)
|
|
||||||
Expect(agentImages).To(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Error mapping", func() {
|
|
||||||
var agent wasmMediaAgent
|
|
||||||
|
|
||||||
It("should map API ErrNotFound to agents.ErrNotFound", func() {
|
|
||||||
err := agent.mapError(api.ErrNotFound)
|
|
||||||
Expect(err).To(Equal(agents.ErrNotFound))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("should map API ErrNotImplemented to agents.ErrNotFound", func() {
|
|
||||||
err := agent.mapError(api.ErrNotImplemented)
|
|
||||||
Expect(err).To(Equal(agents.ErrNotFound))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("should pass through other errors", func() {
|
|
||||||
testErr := errors.New("test error")
|
|
||||||
err := agent.mapError(testErr)
|
|
||||||
Expect(err).To(Equal(testErr))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("should handle nil error", func() {
|
|
||||||
err := agent.mapError(nil)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user