mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-28 12:56:41 -05:00
Compare commits
10 Commits
feat/plugi
...
custom-col
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4b1830513 | ||
|
|
eca4c5acf0 | ||
|
|
e766a5d780 | ||
|
|
90d6cd5f47 | ||
|
|
24ab04581a | ||
|
|
8e647a0e41 | ||
|
|
86c326bd4a | ||
|
|
929e7193b4 | ||
|
|
9bcefea0ca | ||
|
|
b0cb40b029 |
@@ -14,7 +14,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
|||||||
&& apt-get -y install --no-install-recommends ffmpeg
|
&& apt-get -y install --no-install-recommends ffmpeg
|
||||||
|
|
||||||
# Install TagLib from cross-taglib releases
|
# Install TagLib from cross-taglib releases
|
||||||
ARG CROSS_TAGLIB_VERSION="2.2.0-1"
|
ARG CROSS_TAGLIB_VERSION="2.1.1-1"
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
|
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
|
||||||
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
|
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// Options
|
// Options
|
||||||
"INSTALL_NODE": "true",
|
"INSTALL_NODE": "true",
|
||||||
"NODE_VERSION": "v24",
|
"NODE_VERSION": "v24",
|
||||||
"CROSS_TAGLIB_VERSION": "2.2.0-1"
|
"CROSS_TAGLIB_VERSION": "2.1.1-1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"workspaceMount": "",
|
"workspaceMount": "",
|
||||||
|
|||||||
6
.github/workflows/pipeline.yml
vendored
6
.github/workflows/pipeline.yml
vendored
@@ -14,7 +14,7 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CROSS_TAGLIB_VERSION: "2.2.0-1"
|
CROSS_TAGLIB_VERSION: "2.1.1-2"
|
||||||
CGO_CFLAGS_ALLOW: "--define-prefix"
|
CGO_CFLAGS_ALLOW: "--define-prefix"
|
||||||
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
||||||
go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
|
go test -shuffle=on -tags netgo -race ./... -v
|
||||||
|
|
||||||
- name: Test ndpgen
|
- name: Test ndpgen
|
||||||
run: |
|
run: |
|
||||||
@@ -424,7 +424,7 @@ jobs:
|
|||||||
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
|
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v7
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
version: '~> v2'
|
version: '~> v2'
|
||||||
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ version: "2"
|
|||||||
run:
|
run:
|
||||||
build-tags:
|
build-tags:
|
||||||
- netgo
|
- netgo
|
||||||
- sqlite_fts5
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- asasalint
|
- asasalint
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Before submitting a pull request, ensure that you go through the following:
|
|||||||
### Commit Conventions
|
### Commit Conventions
|
||||||
Each commit message must adhere to the following format:
|
Each commit message must adhere to the following format:
|
||||||
```
|
```
|
||||||
<type>(scope): <description>
|
<type>(scope): <description> - <issue number>
|
||||||
|
|
||||||
[optional body]
|
[optional body]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ COPY --from=xx-build /out/ /usr/bin/
|
|||||||
### Get TagLib
|
### Get TagLib
|
||||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
|
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
ARG CROSS_TAGLIB_VERSION=2.2.0-1
|
ARG CROSS_TAGLIB_VERSION=2.1.1-2
|
||||||
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
|
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
|
||||||
|
|
||||||
# wget in busybox can't follow redirects
|
# wget in busybox can't follow redirects
|
||||||
@@ -109,7 +109,7 @@ RUN --mount=type=bind,source=. \
|
|||||||
export EXT=".exe"
|
export EXT=".exe"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
go build -tags=netgo,sqlite_fts5 -ldflags="${LD_EXTRA} -w -s \
|
go build -tags=netgo -ldflags="${LD_EXTRA} -w -s \
|
||||||
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
||||||
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
||||||
-o /out/navidrome${EXT} .
|
-o /out/navidrome${EXT} .
|
||||||
|
|||||||
21
Makefile
21
Makefile
@@ -1,6 +1,5 @@
|
|||||||
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)
|
||||||
GO_BUILD_TAGS=netgo,sqlite_fts5
|
|
||||||
|
|
||||||
# Set global environment variables, required for most targets
|
# Set global environment variables, required for most targets
|
||||||
export CGO_CFLAGS_ALLOW=--define-prefix
|
export CGO_CFLAGS_ALLOW=--define-prefix
|
||||||
@@ -20,7 +19,7 @@ PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
|||||||
DOCKER_TAG ?= deluan/navidrome:develop
|
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.2.0-1
|
CROSS_TAGLIB_VERSION ?= 2.1.1-2
|
||||||
GOLANGCI_LINT_VERSION ?= v2.10.0
|
GOLANGCI_LINT_VERSION ?= v2.10.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/*")
|
||||||
@@ -47,12 +46,12 @@ stop: ##@Development Stop development servers (UI and backend)
|
|||||||
.PHONY: stop
|
.PHONY: stop
|
||||||
|
|
||||||
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||||
go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./...
|
go tool ginkgo watch -tags=netgo -notify ./...
|
||||||
.PHONY: watch
|
.PHONY: watch
|
||||||
|
|
||||||
PKG ?= ./...
|
PKG ?= ./...
|
||||||
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
|
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
|
||||||
go test -tags $(GO_BUILD_TAGS) $(PKG)
|
go test -tags netgo $(PKG)
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
||||||
test-ndpgen: ##@Development Run tests for ndpgen plugin
|
test-ndpgen: ##@Development Run tests for ndpgen plugin
|
||||||
@@ -63,7 +62,7 @@ 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
|
||||||
go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG)
|
go test -tags netgo -race -shuffle=on $(PKG)
|
||||||
.PHONY: test-race
|
.PHONY: test-race
|
||||||
|
|
||||||
test-js: ##@Development Run JS tests
|
test-js: ##@Development Run JS tests
|
||||||
@@ -109,7 +108,7 @@ format: ##@Development Format code
|
|||||||
.PHONY: format
|
.PHONY: format
|
||||||
|
|
||||||
wire: check_go_env ##@Development Update Dependency Injection
|
wire: check_go_env ##@Development Update Dependency Injection
|
||||||
go tool wire gen -tags=$(GO_BUILD_TAGS) ./...
|
go tool wire gen -tags=netgo ./...
|
||||||
.PHONY: wire
|
.PHONY: wire
|
||||||
|
|
||||||
gen: check_go_env ##@Development Run go generate for code generation
|
gen: check_go_env ##@Development Run go generate for code generation
|
||||||
@@ -145,14 +144,14 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
|
|||||||
.PHONY: setup-git
|
.PHONY: setup-git
|
||||||
|
|
||||||
build: check_go_env buildjs ##@Build Build the project
|
build: check_go_env buildjs ##@Build Build the project
|
||||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
|
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
|
|
||||||
buildall: deprecated build
|
buildall: deprecated build
|
||||||
.PHONY: buildall
|
.PHONY: buildall
|
||||||
|
|
||||||
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
|
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
|
||||||
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
|
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
|
||||||
.PHONY: debug-build
|
.PHONY: debug-build
|
||||||
|
|
||||||
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
||||||
@@ -202,8 +201,8 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows
|
|||||||
@du -h binaries/msi/*.msi
|
@du -h binaries/msi/*.msi
|
||||||
.PHONY: docker-msi
|
.PHONY: docker-msi
|
||||||
|
|
||||||
docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag=<tag>
|
run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag=<tag>
|
||||||
@if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag=<tag>"; exit 1; fi
|
@if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag=<tag>"; exit 1; fi
|
||||||
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
|
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
|
||||||
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
|
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
|
||||||
if [ -f navidrome.toml ]; then \
|
if [ -f navidrome.toml ]; then \
|
||||||
@@ -214,7 +213,7 @@ docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run
|
|||||||
fi; \
|
fi; \
|
||||||
fi; \
|
fi; \
|
||||||
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
|
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
|
||||||
.PHONY: docker-run
|
.PHONY: run-docker
|
||||||
|
|
||||||
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
|
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
|
||||||
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
|
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/core/storage/local"
|
"github.com/navidrome/navidrome/core/storage/local"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model/metadata"
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
@@ -44,7 +43,7 @@ func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e extractor) Version() string {
|
func (e extractor) Version() string {
|
||||||
return "2.2 WASM"
|
return "go-taglib (TagLib 2.1.1 WASM)"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||||
@@ -280,7 +279,4 @@ func init() {
|
|||||||
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
|
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
|
||||||
return &extractor{fsys}
|
return &extractor{fsys}
|
||||||
})
|
})
|
||||||
conf.AddHook(func() {
|
|
||||||
log.Debug("go-taglib version", "version", extractor{}.Version())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,8 +196,7 @@ func runInitialScan(ctx context.Context) func() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
|
||||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||||
if scanNeeded {
|
if scanNeeded {
|
||||||
s := CreateScanner(ctx)
|
s := CreateScanner(ctx)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
@@ -74,7 +74,7 @@ func runScanner(ctx context.Context) {
|
|||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
defer db.Db().Close()
|
defer db.Db().Close()
|
||||||
ds := persistence.New(sqlDB)
|
ds := persistence.New(sqlDB)
|
||||||
pls := playlists.NewPlaylists(ds)
|
pls := core.NewPlaylists(ds)
|
||||||
|
|
||||||
// Parse targets from command line or file
|
// Parse targets from command line or file
|
||||||
var scanTargets []model.ScanTarget
|
var scanTargets []model.ScanTarget
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
@@ -62,7 +61,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
|||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
insights := metrics.GetInstance(dataStore)
|
insights := metrics.GetInstance(dataStore)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
@@ -73,12 +72,12 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
|||||||
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)
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, 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, manager)
|
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||||
user := core.NewUser(dataStore, manager)
|
user := core.NewUser(dataStore, manager)
|
||||||
maintenance := core.NewMaintenance(dataStore)
|
maintenance := core.NewMaintenance(dataStore)
|
||||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager)
|
router := nativeapi.New(dataStore, share, playlists, insights, library, user, maintenance, manager)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,11 +98,11 @@ 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)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||||
playbackServer := playback.GetInstance(dataStore)
|
playbackServer := playback.GetInstance(dataStore)
|
||||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics)
|
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,8 +165,8 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
|||||||
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)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
return modelScanner
|
return modelScanner
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,8 +182,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
|||||||
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)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||||
return watcher
|
return watcher
|
||||||
}
|
}
|
||||||
|
|||||||
4
conf/buildtags/buildtags.go
Normal file
4
conf/buildtags/buildtags.go
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
package buildtags
|
||||||
|
|
||||||
|
// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all
|
||||||
|
// required build tags are disabled.
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// Package buildtags provides compile-time enforcement of required build tags.
|
|
||||||
//
|
|
||||||
// Each file in this package is guarded by a build constraint and exports a variable
|
|
||||||
// that main.go references. If a required tag is missing during compilation, the build
|
|
||||||
// fails with an "undefined" error, directing the developer to use `make build`.
|
|
||||||
package buildtags
|
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
package buildtags
|
package buildtags
|
||||||
|
|
||||||
// The `netgo` tag is required when compiling the project. See https://github.com/navidrome/navidrome/issues/700
|
// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project.
|
||||||
|
// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go`
|
||||||
|
// file requires it.
|
||||||
|
|
||||||
|
// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700
|
||||||
|
|
||||||
var NETGO = true
|
var NETGO = true
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
//go:build sqlite_fts5
|
|
||||||
|
|
||||||
package buildtags
|
|
||||||
|
|
||||||
// FTS5 is required for full-text search. Without this tag, the SQLite driver
|
|
||||||
// won't include FTS5 support, causing runtime failures on migrations and search queries.
|
|
||||||
|
|
||||||
var SQLITE_FTS5 = true
|
|
||||||
@@ -58,7 +58,7 @@ type configOptions struct {
|
|||||||
SmartPlaylistRefreshDelay time.Duration
|
SmartPlaylistRefreshDelay time.Duration
|
||||||
AutoTranscodeDownload bool
|
AutoTranscodeDownload bool
|
||||||
DefaultDownsamplingFormat string
|
DefaultDownsamplingFormat string
|
||||||
Search searchOptions `json:",omitzero"`
|
SearchFullString bool
|
||||||
SimilarSongsMatchThreshold int
|
SimilarSongsMatchThreshold int
|
||||||
RecentlyAddedByModTime bool
|
RecentlyAddedByModTime bool
|
||||||
PreferSortTags bool
|
PreferSortTags bool
|
||||||
@@ -82,7 +82,6 @@ type configOptions struct {
|
|||||||
DefaultTheme string
|
DefaultTheme string
|
||||||
DefaultLanguage string
|
DefaultLanguage string
|
||||||
DefaultUIVolume int
|
DefaultUIVolume int
|
||||||
UISearchDebounceMs int
|
|
||||||
EnableReplayGain bool
|
EnableReplayGain bool
|
||||||
EnableCoverAnimation bool
|
EnableCoverAnimation bool
|
||||||
EnableNowPlaying bool
|
EnableNowPlaying bool
|
||||||
@@ -155,7 +154,6 @@ type scannerOptions struct {
|
|||||||
|
|
||||||
type subsonicOptions struct {
|
type subsonicOptions struct {
|
||||||
AppendSubtitle bool
|
AppendSubtitle bool
|
||||||
AppendAlbumVersion bool
|
|
||||||
ArtistParticipations bool
|
ArtistParticipations bool
|
||||||
DefaultReportRealPath bool
|
DefaultReportRealPath bool
|
||||||
EnableAverageRating bool
|
EnableAverageRating bool
|
||||||
@@ -251,12 +249,6 @@ type pluginsOptions struct {
|
|||||||
type extAuthOptions struct {
|
type extAuthOptions struct {
|
||||||
TrustedSources string
|
TrustedSources string
|
||||||
UserHeader string
|
UserHeader string
|
||||||
LogoutURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
type searchOptions struct {
|
|
||||||
Backend string
|
|
||||||
FullString bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -347,14 +339,11 @@ func Load(noConfigDump bool) {
|
|||||||
validateBackupSchedule,
|
validateBackupSchedule,
|
||||||
validatePlaylistsPath,
|
validatePlaylistsPath,
|
||||||
validatePurgeMissingOption,
|
validatePurgeMissingOption,
|
||||||
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
|
|
||||||
|
|
||||||
if Server.BaseURL != "" {
|
if Server.BaseURL != "" {
|
||||||
u, err := url.Parse(Server.BaseURL)
|
u, err := url.Parse(Server.BaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -403,7 +392,6 @@ func Load(noConfigDump bool) {
|
|||||||
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
|
||||||
logDeprecatedOptions("SearchFullString", "Search.FullString")
|
|
||||||
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||||
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||||
@@ -551,44 +539,6 @@ func validateSchedule(schedule, field string) (string, error) {
|
|||||||
return schedule, err
|
return schedule, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateURL checks if the provided URL is valid and has either http or https scheme.
|
|
||||||
// It returns a function that can be used as a hook to validate URLs in the config.
|
|
||||||
func validateURL(optionName, optionURL string) func() error {
|
|
||||||
return func() error {
|
|
||||||
if optionURL == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
u, err := url.Parse(optionURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(fmt.Sprintf("Invalid %s: it could not be parsed", optionName), "url", optionURL, "err", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if u.Scheme != "http" && u.Scheme != "https" {
|
|
||||||
err := fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
|
|
||||||
log.Error(err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Require an absolute URL with a non-empty host and no opaque component.
|
|
||||||
if u.Host == "" || u.Opaque != "" {
|
|
||||||
err := fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
|
|
||||||
log.Error(err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeSearchBackend(value string) string {
|
|
||||||
v := strings.ToLower(strings.TrimSpace(value))
|
|
||||||
switch v {
|
|
||||||
case "fts", "legacy":
|
|
||||||
return v
|
|
||||||
default:
|
|
||||||
log.Error("Invalid Search.Backend value, falling back to 'fts'", "value", value)
|
|
||||||
return "fts"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
||||||
func AddHook(hook func()) {
|
func AddHook(hook func()) {
|
||||||
hooks = append(hooks, hook)
|
hooks = append(hooks, hook)
|
||||||
@@ -635,8 +585,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("enablemediafilecoverart", true)
|
viper.SetDefault("enablemediafilecoverart", true)
|
||||||
viper.SetDefault("autotranscodedownload", false)
|
viper.SetDefault("autotranscodedownload", false)
|
||||||
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
||||||
viper.SetDefault("search.fullstring", false)
|
viper.SetDefault("searchfullstring", false)
|
||||||
viper.SetDefault("search.backend", "fts")
|
|
||||||
viper.SetDefault("similarsongsmatchthreshold", 85)
|
viper.SetDefault("similarsongsmatchthreshold", 85)
|
||||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||||
viper.SetDefault("prefersorttags", false)
|
viper.SetDefault("prefersorttags", false)
|
||||||
@@ -655,7 +604,6 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("defaulttheme", "Dark")
|
viper.SetDefault("defaulttheme", "Dark")
|
||||||
viper.SetDefault("defaultlanguage", "")
|
viper.SetDefault("defaultlanguage", "")
|
||||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||||
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
|
||||||
viper.SetDefault("enablereplaygain", true)
|
viper.SetDefault("enablereplaygain", true)
|
||||||
viper.SetDefault("enablecoveranimation", true)
|
viper.SetDefault("enablecoveranimation", true)
|
||||||
viper.SetDefault("enablenowplaying", true)
|
viper.SetDefault("enablenowplaying", true)
|
||||||
@@ -671,7 +619,6 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("passwordencryptionkey", "")
|
viper.SetDefault("passwordencryptionkey", "")
|
||||||
viper.SetDefault("extauth.userheader", "Remote-User")
|
viper.SetDefault("extauth.userheader", "Remote-User")
|
||||||
viper.SetDefault("extauth.trustedsources", "")
|
viper.SetDefault("extauth.trustedsources", "")
|
||||||
viper.SetDefault("extauth.logouturl", "")
|
|
||||||
viper.SetDefault("prometheus.enabled", false)
|
viper.SetDefault("prometheus.enabled", false)
|
||||||
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
|
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
|
||||||
viper.SetDefault("prometheus.password", "")
|
viper.SetDefault("prometheus.password", "")
|
||||||
@@ -690,7 +637,6 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("scanner.followsymlinks", true)
|
viper.SetDefault("scanner.followsymlinks", true)
|
||||||
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
||||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||||
viper.SetDefault("subsonic.appendalbumversion", 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.enableaveragerating", true)
|
||||||
|
|||||||
@@ -52,62 +52,6 @@ var _ = Describe("Configuration", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("ValidateURL", func() {
|
|
||||||
It("accepts a valid http URL", func() {
|
|
||||||
fn := conf.ValidateURL("TestOption", "http://example.com/path")
|
|
||||||
Expect(fn()).To(Succeed())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("accepts a valid https URL", func() {
|
|
||||||
fn := conf.ValidateURL("TestOption", "https://example.com/path")
|
|
||||||
Expect(fn()).To(Succeed())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a URL with no scheme", func() {
|
|
||||||
fn := conf.ValidateURL("TestOption", "example.com/path")
|
|
||||||
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a URL with an unsupported scheme", func() {
|
|
||||||
fn := conf.ValidateURL("TestOption", "javascript://example.com/path")
|
|
||||||
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("accepts an empty URL (optional config)", func() {
|
|
||||||
fn := conf.ValidateURL("TestOption", "")
|
|
||||||
Expect(fn()).To(Succeed())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("includes the option name in the error message", func() {
|
|
||||||
fn := conf.ValidateURL("MyOption", "ftp://example.com")
|
|
||||||
Expect(fn()).To(MatchError(ContainSubstring("MyOption")))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a URL that cannot be parsed", func() {
|
|
||||||
fn := conf.ValidateURL("TestOption", "://invalid")
|
|
||||||
Expect(fn()).To(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a URL without a host", func() {
|
|
||||||
fn := conf.ValidateURL("TestOption", "http:///path")
|
|
||||||
Expect(fn()).To(MatchError(ContainSubstring("non-empty host is required")))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("NormalizeSearchBackend",
|
|
||||||
func(input, expected string) {
|
|
||||||
Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("accepts 'fts'", "fts", "fts"),
|
|
||||||
Entry("accepts 'legacy'", "legacy", "legacy"),
|
|
||||||
Entry("normalizes 'FTS' to lowercase", "FTS", "fts"),
|
|
||||||
Entry("normalizes 'Legacy' to lowercase", "Legacy", "legacy"),
|
|
||||||
Entry("trims whitespace", " fts ", "fts"),
|
|
||||||
Entry("falls back to 'fts' for 'fts5'", "fts5", "fts"),
|
|
||||||
Entry("falls back to 'fts' for unrecognized values", "invalid", "fts"),
|
|
||||||
Entry("falls back to 'fts' for empty string", "", "fts"),
|
|
||||||
)
|
|
||||||
|
|
||||||
DescribeTable("should load configuration from",
|
DescribeTable("should load configuration from",
|
||||||
func(format string) {
|
func(format string) {
|
||||||
filename := filepath.Join("testdata", "cfg."+format)
|
filename := filepath.Join("testdata", "cfg."+format)
|
||||||
|
|||||||
@@ -7,7 +7,3 @@ func ResetConf() {
|
|||||||
var SetViperDefaults = setViperDefaults
|
var SetViperDefaults = setViperDefaults
|
||||||
|
|
||||||
var ParseLanguages = parseLanguages
|
var ParseLanguages = parseLanguages
|
||||||
|
|
||||||
var ValidateURL = validateURL
|
|
||||||
|
|
||||||
var NormalizeSearchBackend = normalizeSearchBackend
|
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ const (
|
|||||||
PlaceholderAvatar = "logo-192x192.png"
|
PlaceholderAvatar = "logo-192x192.png"
|
||||||
UICoverArtSize = 300
|
UICoverArtSize = 300
|
||||||
DefaultUIVolume = 100
|
DefaultUIVolume = 100
|
||||||
DefaultUISearchDebounceMs = 200
|
|
||||||
|
|
||||||
DefaultHttpClientTimeOut = 10 * time.Second
|
DefaultHttpClientTimeOut = 10 * time.Second
|
||||||
|
|
||||||
|
|||||||
@@ -208,8 +208,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
|||||||
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
|
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
|
||||||
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
|
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
|
||||||
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
|
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
|
||||||
data.Config.SearchFullString = conf.Server.Search.FullString
|
data.Config.SearchFullString = conf.Server.SearchFullString
|
||||||
data.Config.SearchBackend = conf.Server.Search.Backend
|
|
||||||
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
|
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
|
||||||
data.Config.PreferSortTags = conf.Server.PreferSortTags
|
data.Config.PreferSortTags = conf.Server.PreferSortTags
|
||||||
data.Config.BackupSchedule = conf.Server.Backup.Schedule
|
data.Config.BackupSchedule = conf.Server.Backup.Schedule
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ type Data struct {
|
|||||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||||
SearchFullString bool `json:"searchFullString,omitempty"`
|
SearchFullString bool `json:"searchFullString,omitempty"`
|
||||||
SearchBackend string `json:"searchBackend,omitempty"`
|
|
||||||
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
|
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
|
||||||
PreferSortTags bool `json:"preferSortTags,omitempty"`
|
PreferSortTags bool `json:"preferSortTags,omitempty"`
|
||||||
BackupSchedule string `json:"backupSchedule,omitempty"`
|
BackupSchedule string `json:"backupSchedule,omitempty"`
|
||||||
|
|||||||
@@ -1,28 +1,183 @@
|
|||||||
package playlists
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/RaveNoX/go-jsoncommentstrip"
|
||||||
|
"github.com/bmatcuk/doublestar/v4"
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
"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/criteria"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
"github.com/navidrome/navidrome/utils/ioutils"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
"golang.org/x/text/unicode/norm"
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Playlists interface {
|
||||||
|
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
|
||||||
|
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
|
||||||
|
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type playlists struct {
|
||||||
|
ds model.DataStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlaylists(ds model.DataStore) Playlists {
|
||||||
|
return &playlists{ds: ds}
|
||||||
|
}
|
||||||
|
|
||||||
|
func InPlaylistsPath(folder model.Folder) bool {
|
||||||
|
if conf.Server.PlaylistsPath == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
|
||||||
|
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||||
|
if match, _ := doublestar.Match(path, rel); match {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
||||||
|
pls, err := s.parsePlaylist(ctx, filename, folder)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
|
||||||
|
err = s.updatePlaylist(ctx, pls)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||||
|
}
|
||||||
|
return pls, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
|
||||||
|
owner, _ := request.UserFrom(ctx)
|
||||||
|
pls := &model.Playlist{
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
Public: false,
|
||||||
|
Sync: false,
|
||||||
|
}
|
||||||
|
err := s.parseM3U(ctx, pls, nil, reader)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "Error parsing playlist", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = s.ds.Playlist(ctx).Put(pls)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "Error saving playlist", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pls, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
|
||||||
|
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(pls.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
reader := ioutils.UTF8Reader(file)
|
||||||
|
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||||
|
switch extension {
|
||||||
|
case ".nsp":
|
||||||
|
err = s.parseNSP(ctx, pls, reader)
|
||||||
|
default:
|
||||||
|
err = s.parseM3U(ctx, pls, folder, reader)
|
||||||
|
}
|
||||||
|
return pls, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
|
||||||
|
playlistPath := filepath.Join(baseDir, playlistFile)
|
||||||
|
info, err := os.Stat(playlistPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var extension = filepath.Ext(playlistFile)
|
||||||
|
var name = playlistFile[0 : len(playlistFile)-len(extension)]
|
||||||
|
|
||||||
|
pls := &model.Playlist{
|
||||||
|
Name: name,
|
||||||
|
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
|
||||||
|
Public: false,
|
||||||
|
Path: playlistPath,
|
||||||
|
Sync: true,
|
||||||
|
UpdatedAt: info.ModTime(),
|
||||||
|
}
|
||||||
|
return pls, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPositionFromOffset(data []byte, offset int64) (line, column int) {
|
||||||
|
line = 1
|
||||||
|
for _, b := range data[:offset] {
|
||||||
|
if b == '\n' {
|
||||||
|
line++
|
||||||
|
column = 1
|
||||||
|
} else {
|
||||||
|
column++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
|
||||||
|
nsp := &nspFile{}
|
||||||
|
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
|
||||||
|
reader = jsoncommentstrip.NewReader(reader)
|
||||||
|
input, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading SmartPlaylist: %w", err)
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(input, nsp)
|
||||||
|
if err != nil {
|
||||||
|
var syntaxErr *json.SyntaxError
|
||||||
|
if errors.As(err, &syntaxErr) {
|
||||||
|
line, col := getPositionFromOffset(input, syntaxErr.Offset)
|
||||||
|
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
|
||||||
|
}
|
||||||
|
pls.Rules = &nsp.Criteria
|
||||||
|
if nsp.Name != "" {
|
||||||
|
pls.Name = nsp.Name
|
||||||
|
}
|
||||||
|
if nsp.Comment != "" {
|
||||||
|
pls.Comment = nsp.Comment
|
||||||
|
}
|
||||||
|
if nsp.Public != nil {
|
||||||
|
pls.Public = *nsp.Public
|
||||||
|
} else {
|
||||||
|
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
|
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
|
||||||
mediaFileRepository := s.ds.MediaFile(ctx)
|
mediaFileRepository := s.ds.MediaFile(ctx)
|
||||||
resolver, err := newPathResolver(ctx, s.ds)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var mfs model.MediaFiles
|
var mfs model.MediaFiles
|
||||||
// Chunk size of 100 lines, as each line can generate up to 4 lookup candidates
|
// Chunk size of 100 lines, as each line can generate up to 4 lookup candidates
|
||||||
// (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000.
|
// (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000.
|
||||||
@@ -47,7 +202,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
|||||||
}
|
}
|
||||||
filteredLines = append(filteredLines, line)
|
filteredLines = append(filteredLines, line)
|
||||||
}
|
}
|
||||||
resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines)
|
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
|
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
|
||||||
continue
|
continue
|
||||||
@@ -103,9 +258,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
|||||||
existing[key] = idx
|
existing[key] = idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find media files in the order of the resolved paths, to keep playlist order.
|
// Find media files in the order of the resolved paths, to keep playlist order
|
||||||
// Both `existing` keys and `resolvedPaths` use the library-qualified format "libraryID:relativePath",
|
|
||||||
// so normalizing the full string produces matching keys (digits and ':' are ASCII-invariant).
|
|
||||||
for _, path := range resolvedPaths {
|
for _, path := range resolvedPaths {
|
||||||
key := strings.ToLower(norm.NFC.String(path))
|
key := strings.ToLower(norm.NFC.String(path))
|
||||||
idx, ok := existing[key]
|
idx, ok := existing[key]
|
||||||
@@ -245,10 +398,15 @@ func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
|
|||||||
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
|
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
|
||||||
// For relative paths, it resolves them to absolute paths first, then determines which
|
// For relative paths, it resolves them to absolute paths first, then determines which
|
||||||
// library they belong to. This allows playlists to reference files across library boundaries.
|
// library they belong to. This allows playlists to reference files across library boundaries.
|
||||||
func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
|
func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
|
||||||
|
resolver, err := newPathResolver(ctx, s.ds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
results := make([]string, 0, len(lines))
|
results := make([]string, 0, len(lines))
|
||||||
for idx, line := range lines {
|
for idx, line := range lines {
|
||||||
resolution := r.resolvePath(line, folder)
|
resolution := resolver.resolvePath(line, folder)
|
||||||
|
|
||||||
if !resolution.valid {
|
if !resolution.valid {
|
||||||
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
|
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
|
||||||
@@ -267,3 +425,123 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
|
|||||||
|
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||||
|
owner, _ := request.UserFrom(ctx)
|
||||||
|
|
||||||
|
// Try to find existing playlist by path. Since filesystem normalization differs across
|
||||||
|
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
|
||||||
|
// playlists that may have been imported on a different platform.
|
||||||
|
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
// Try alternate normalization form
|
||||||
|
altPath := norm.NFD.String(newPls.Path)
|
||||||
|
if altPath == newPls.Path {
|
||||||
|
altPath = norm.NFC.String(newPls.Path)
|
||||||
|
}
|
||||||
|
if altPath != newPls.Path {
|
||||||
|
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err == nil && !pls.Sync {
|
||||||
|
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
|
||||||
|
newPls.ID = pls.ID
|
||||||
|
newPls.Name = pls.Name
|
||||||
|
newPls.Comment = pls.Comment
|
||||||
|
newPls.OwnerID = pls.OwnerID
|
||||||
|
newPls.Public = pls.Public
|
||||||
|
newPls.EvaluatedAt = &time.Time{}
|
||||||
|
} else {
|
||||||
|
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||||
|
newPls.OwnerID = owner.ID
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *playlists) Update(ctx context.Context, playlistID string,
|
||||||
|
name *string, comment *string, public *bool,
|
||||||
|
idsToAdd []string, idxToRemove []int) error {
|
||||||
|
needsInfoUpdate := name != nil || comment != nil || public != nil
|
||||||
|
needsTrackRefresh := len(idxToRemove) > 0
|
||||||
|
|
||||||
|
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||||
|
var pls *model.Playlist
|
||||||
|
var err error
|
||||||
|
repo := tx.Playlist(ctx)
|
||||||
|
tracks := repo.Tracks(playlistID, true)
|
||||||
|
if tracks == nil {
|
||||||
|
return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
|
||||||
|
}
|
||||||
|
if needsTrackRefresh {
|
||||||
|
pls, err = repo.GetWithTracks(playlistID, true, false)
|
||||||
|
pls.RemoveTracks(idxToRemove)
|
||||||
|
pls.AddMediaFilesByID(idsToAdd)
|
||||||
|
} else {
|
||||||
|
if len(idsToAdd) > 0 {
|
||||||
|
_, err = tracks.Add(idsToAdd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if needsInfoUpdate {
|
||||||
|
pls, err = repo.Get(playlistID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !needsTrackRefresh && !needsInfoUpdate {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if name != nil {
|
||||||
|
pls.Name = *name
|
||||||
|
}
|
||||||
|
if comment != nil {
|
||||||
|
pls.Comment = *comment
|
||||||
|
}
|
||||||
|
if public != nil {
|
||||||
|
pls.Public = *public
|
||||||
|
}
|
||||||
|
// Special case: The playlist is now empty
|
||||||
|
if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
|
||||||
|
if err = tracks.DeleteAll(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repo.Put(pls)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type nspFile struct {
|
||||||
|
criteria.Criteria
|
||||||
|
Name string `json:"name"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Public *bool `json:"public"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||||
|
m := map[string]any{}
|
||||||
|
err := json.Unmarshal(data, &m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.Name, _ = m["name"].(string)
|
||||||
|
i.Comment, _ = m["comment"].(string)
|
||||||
|
if public, ok := m["public"].(bool); ok {
|
||||||
|
i.Public = &public
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &i.Criteria)
|
||||||
|
}
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
package playlists
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
"github.com/navidrome/navidrome/utils/ioutils"
|
|
||||||
"golang.org/x/text/unicode/norm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
|
||||||
pls, err := s.parsePlaylist(ctx, filename, folder)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
log.Debug(ctx, "Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
|
|
||||||
err = s.updatePlaylist(ctx, pls)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
|
||||||
}
|
|
||||||
return pls, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
|
|
||||||
owner, _ := request.UserFrom(ctx)
|
|
||||||
pls := &model.Playlist{
|
|
||||||
OwnerID: owner.ID,
|
|
||||||
Public: false,
|
|
||||||
Sync: false,
|
|
||||||
}
|
|
||||||
err := s.parseM3U(ctx, pls, nil, reader)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Error parsing playlist", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
err = s.ds.Playlist(ctx).Put(pls)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Error saving playlist", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return pls, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
|
|
||||||
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(pls.Path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
reader := ioutils.UTF8Reader(file)
|
|
||||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
|
||||||
switch extension {
|
|
||||||
case ".nsp":
|
|
||||||
err = s.parseNSP(ctx, pls, reader)
|
|
||||||
default:
|
|
||||||
err = s.parseM3U(ctx, pls, folder, reader)
|
|
||||||
}
|
|
||||||
return pls, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
|
||||||
owner, _ := request.UserFrom(ctx)
|
|
||||||
|
|
||||||
// Try to find existing playlist by path. Since filesystem normalization differs across
|
|
||||||
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
|
|
||||||
// playlists that may have been imported on a different platform.
|
|
||||||
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
|
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
|
||||||
// Try alternate normalization form
|
|
||||||
altPath := norm.NFD.String(newPls.Path)
|
|
||||||
if altPath == newPls.Path {
|
|
||||||
altPath = norm.NFC.String(newPls.Path)
|
|
||||||
}
|
|
||||||
if altPath != newPls.Path {
|
|
||||||
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err == nil && !pls.Sync {
|
|
||||||
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
|
|
||||||
newPls.ID = pls.ID
|
|
||||||
newPls.Name = pls.Name
|
|
||||||
newPls.Comment = pls.Comment
|
|
||||||
newPls.OwnerID = pls.OwnerID
|
|
||||||
newPls.Public = pls.Public
|
|
||||||
newPls.EvaluatedAt = &time.Time{}
|
|
||||||
} else {
|
|
||||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
|
||||||
newPls.OwnerID = owner.ID
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
package playlists
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/RaveNoX/go-jsoncommentstrip"
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/model/criteria"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
|
|
||||||
playlistPath := filepath.Join(baseDir, playlistFile)
|
|
||||||
info, err := os.Stat(playlistPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var extension = filepath.Ext(playlistFile)
|
|
||||||
var name = playlistFile[0 : len(playlistFile)-len(extension)]
|
|
||||||
|
|
||||||
pls := &model.Playlist{
|
|
||||||
Name: name,
|
|
||||||
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
|
|
||||||
Public: false,
|
|
||||||
Path: playlistPath,
|
|
||||||
Sync: true,
|
|
||||||
UpdatedAt: info.ModTime(),
|
|
||||||
}
|
|
||||||
return pls, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPositionFromOffset(data []byte, offset int64) (line, column int) {
|
|
||||||
line = 1
|
|
||||||
for _, b := range data[:offset] {
|
|
||||||
if b == '\n' {
|
|
||||||
line++
|
|
||||||
column = 1
|
|
||||||
} else {
|
|
||||||
column++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
|
|
||||||
nsp := &nspFile{}
|
|
||||||
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
|
|
||||||
reader = jsoncommentstrip.NewReader(reader)
|
|
||||||
input, err := io.ReadAll(reader)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading SmartPlaylist: %w", err)
|
|
||||||
}
|
|
||||||
err = json.Unmarshal(input, nsp)
|
|
||||||
if err != nil {
|
|
||||||
var syntaxErr *json.SyntaxError
|
|
||||||
if errors.As(err, &syntaxErr) {
|
|
||||||
line, col := getPositionFromOffset(input, syntaxErr.Offset)
|
|
||||||
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
|
|
||||||
}
|
|
||||||
pls.Rules = &nsp.Criteria
|
|
||||||
if nsp.Name != "" {
|
|
||||||
pls.Name = nsp.Name
|
|
||||||
}
|
|
||||||
if nsp.Comment != "" {
|
|
||||||
pls.Comment = nsp.Comment
|
|
||||||
}
|
|
||||||
if nsp.Public != nil {
|
|
||||||
pls.Public = *nsp.Public
|
|
||||||
} else {
|
|
||||||
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type nspFile struct {
|
|
||||||
criteria.Criteria
|
|
||||||
Name string `json:"name"`
|
|
||||||
Comment string `json:"comment"`
|
|
||||||
Public *bool `json:"public"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
|
||||||
m := map[string]any{}
|
|
||||||
err := json.Unmarshal(data, &m)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
i.Name, _ = m["name"].(string)
|
|
||||||
i.Comment, _ = m["comment"].(string)
|
|
||||||
if public, ok := m["public"].(bool); ok {
|
|
||||||
i.Public = &public
|
|
||||||
}
|
|
||||||
return json.Unmarshal(data, &i.Criteria)
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
package playlists
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/model/criteria"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("parseNSP", func() {
|
|
||||||
var s *playlists
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
s = &playlists{}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("parses a well-formed NSP with all fields", func() {
|
|
||||||
nsp := `{
|
|
||||||
"name": "My Smart Playlist",
|
|
||||||
"comment": "A test playlist",
|
|
||||||
"public": true,
|
|
||||||
"all": [{"is": {"loved": true}}],
|
|
||||||
"sort": "title",
|
|
||||||
"order": "asc",
|
|
||||||
"limit": 50
|
|
||||||
}`
|
|
||||||
pls := &model.Playlist{Name: "default-name"}
|
|
||||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(pls.Name).To(Equal("My Smart Playlist"))
|
|
||||||
Expect(pls.Comment).To(Equal("A test playlist"))
|
|
||||||
Expect(pls.Public).To(BeTrue())
|
|
||||||
Expect(pls.Rules).ToNot(BeNil())
|
|
||||||
Expect(pls.Rules.Sort).To(Equal("title"))
|
|
||||||
Expect(pls.Rules.Order).To(Equal("asc"))
|
|
||||||
Expect(pls.Rules.Limit).To(Equal(50))
|
|
||||||
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps existing name when NSP has no name field", func() {
|
|
||||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
|
||||||
pls := &model.Playlist{Name: "Original Name"}
|
|
||||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(pls.Name).To(Equal("Original Name"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps existing comment when NSP has no comment field", func() {
|
|
||||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
|
||||||
pls := &model.Playlist{Comment: "Original Comment"}
|
|
||||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(pls.Comment).To(Equal("Original Comment"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("strips JSON comments before parsing", func() {
|
|
||||||
nsp := `{
|
|
||||||
// Line comment
|
|
||||||
"name": "Commented Playlist",
|
|
||||||
/* Block comment */
|
|
||||||
"all": [{"is": {"loved": true}}]
|
|
||||||
}`
|
|
||||||
pls := &model.Playlist{}
|
|
||||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(pls.Name).To(Equal("Commented Playlist"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("uses server default when public field is absent", func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
|
||||||
|
|
||||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
|
||||||
pls := &model.Playlist{}
|
|
||||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(pls.Public).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("honors explicit public: false over server default", func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
|
||||||
|
|
||||||
nsp := `{"public": false, "all": [{"is": {"loved": true}}]}`
|
|
||||||
pls := &model.Playlist{}
|
|
||||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(pls.Public).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns a syntax error with line and column info", func() {
|
|
||||||
nsp := "{\n \"name\": \"Bad\",\n \"all\": [INVALID]\n}"
|
|
||||||
pls := &model.Playlist{}
|
|
||||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("JSON syntax error in SmartPlaylist"))
|
|
||||||
Expect(err.Error()).To(MatchRegexp(`line \d+, column \d+`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns a parsing error for completely invalid JSON", func() {
|
|
||||||
nsp := `not json at all`
|
|
||||||
pls := &model.Playlist{}
|
|
||||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("SmartPlaylist"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("gracefully handles non-string name field", func() {
|
|
||||||
nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}`
|
|
||||||
pls := &model.Playlist{Name: "Original"}
|
|
||||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
// Type assertion in UnmarshalJSON fails silently; name stays as original
|
|
||||||
Expect(pls.Name).To(Equal("Original"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("parses criteria with multiple rules", func() {
|
|
||||||
nsp := `{
|
|
||||||
"all": [
|
|
||||||
{"is": {"loved": true}},
|
|
||||||
{"contains": {"title": "rock"}}
|
|
||||||
],
|
|
||||||
"sort": "lastPlayed",
|
|
||||||
"order": "desc",
|
|
||||||
"limit": 100
|
|
||||||
}`
|
|
||||||
pls := &model.Playlist{}
|
|
||||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(pls.Rules).ToNot(BeNil())
|
|
||||||
Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
|
|
||||||
Expect(pls.Rules.Order).To(Equal("desc"))
|
|
||||||
Expect(pls.Rules.Limit).To(Equal(100))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("getPositionFromOffset", func() {
|
|
||||||
It("returns correct position on first line", func() {
|
|
||||||
data := []byte("hello world")
|
|
||||||
line, col := getPositionFromOffset(data, 5)
|
|
||||||
Expect(line).To(Equal(1))
|
|
||||||
Expect(col).To(Equal(5))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns correct position after newlines", func() {
|
|
||||||
data := []byte("line1\nline2\nline3")
|
|
||||||
// Offsets: l(0) i(1) n(2) e(3) 1(4) \n(5) l(6) i(7) n(8)
|
|
||||||
line, col := getPositionFromOffset(data, 8)
|
|
||||||
Expect(line).To(Equal(2))
|
|
||||||
Expect(col).To(Equal(3))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns correct position at start of new line", func() {
|
|
||||||
data := []byte("line1\nline2")
|
|
||||||
// After \n at offset 5, col resets to 1; offset 6 is 'l' -> col=1
|
|
||||||
line, col := getPositionFromOffset(data, 6)
|
|
||||||
Expect(line).To(Equal(2))
|
|
||||||
Expect(col).To(Equal(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles multiple newlines", func() {
|
|
||||||
data := []byte("a\nb\nc\nd")
|
|
||||||
// a(0) \n(1) b(2) \n(3) c(4) \n(5) d(6)
|
|
||||||
line, col := getPositionFromOffset(data, 6)
|
|
||||||
Expect(line).To(Equal(4))
|
|
||||||
Expect(col).To(Equal(1))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("newSyncedPlaylist", func() {
|
|
||||||
var s *playlists
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
s = &playlists{}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("creates a synced playlist with correct attributes", func() {
|
|
||||||
tmpDir := GinkgoT().TempDir()
|
|
||||||
Expect(os.WriteFile(filepath.Join(tmpDir, "test.m3u"), []byte("content"), 0600)).To(Succeed())
|
|
||||||
|
|
||||||
pls, err := s.newSyncedPlaylist(tmpDir, "test.m3u")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(pls.Name).To(Equal("test"))
|
|
||||||
Expect(pls.Comment).To(Equal("Auto-imported from 'test.m3u'"))
|
|
||||||
Expect(pls.Public).To(BeFalse())
|
|
||||||
Expect(pls.Path).To(Equal(filepath.Join(tmpDir, "test.m3u")))
|
|
||||||
Expect(pls.Sync).To(BeTrue())
|
|
||||||
Expect(pls.UpdatedAt).ToNot(BeZero())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("strips extension from filename to derive name", func() {
|
|
||||||
tmpDir := GinkgoT().TempDir()
|
|
||||||
Expect(os.WriteFile(filepath.Join(tmpDir, "My Favorites.nsp"), []byte("{}"), 0600)).To(Succeed())
|
|
||||||
|
|
||||||
pls, err := s.newSyncedPlaylist(tmpDir, "My Favorites.nsp")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(pls.Name).To(Equal("My Favorites"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error for non-existent file", func() {
|
|
||||||
tmpDir := GinkgoT().TempDir()
|
|
||||||
_, err := s.newSyncedPlaylist(tmpDir, "nonexistent.m3u")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
package playlists
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/bmatcuk/doublestar/v4"
|
|
||||||
"github.com/deluan/rest"
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Playlists interface {
|
|
||||||
// Reads
|
|
||||||
GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error)
|
|
||||||
Get(ctx context.Context, id string) (*model.Playlist, error)
|
|
||||||
GetWithTracks(ctx context.Context, id string) (*model.Playlist, error)
|
|
||||||
GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error)
|
|
||||||
|
|
||||||
// Mutations
|
|
||||||
Create(ctx context.Context, playlistId string, name string, ids []string) (string, error)
|
|
||||||
Delete(ctx context.Context, id string) error
|
|
||||||
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
|
|
||||||
|
|
||||||
// Track management
|
|
||||||
AddTracks(ctx context.Context, playlistID string, ids []string) (int, error)
|
|
||||||
AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error)
|
|
||||||
AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error)
|
|
||||||
AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error)
|
|
||||||
RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error
|
|
||||||
ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error
|
|
||||||
|
|
||||||
// Import
|
|
||||||
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
|
|
||||||
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
|
|
||||||
|
|
||||||
// REST adapters (follows Share/Library pattern)
|
|
||||||
NewRepository(ctx context.Context) rest.Repository
|
|
||||||
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
|
|
||||||
}
|
|
||||||
|
|
||||||
type playlists struct {
|
|
||||||
ds model.DataStore
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPlaylists(ds model.DataStore) Playlists {
|
|
||||||
return &playlists{ds: ds}
|
|
||||||
}
|
|
||||||
|
|
||||||
func InPath(folder model.Folder) bool {
|
|
||||||
if conf.Server.PlaylistsPath == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
|
|
||||||
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
|
||||||
if match, _ := doublestar.Match(path, rel); match {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Read operations ---
|
|
||||||
|
|
||||||
func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) {
|
|
||||||
return s.ds.Playlist(ctx).GetAll(options...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) {
|
|
||||||
return s.ds.Playlist(ctx).Get(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) {
|
|
||||||
return s.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) {
|
|
||||||
return s.ds.Playlist(ctx).GetPlaylists(mediaFileId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Mutation operations ---
|
|
||||||
|
|
||||||
// Create creates a new playlist (when name is provided) or replaces tracks on an existing
|
|
||||||
// playlist (when playlistId is provided). This matches the Subsonic createPlaylist semantics.
|
|
||||||
func (s *playlists) Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) {
|
|
||||||
usr, _ := request.UserFrom(ctx)
|
|
||||||
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
|
||||||
var pls *model.Playlist
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if playlistId != "" {
|
|
||||||
pls, err = tx.Playlist(ctx).Get(playlistId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if pls.IsSmartPlaylist() {
|
|
||||||
return model.ErrNotAuthorized
|
|
||||||
}
|
|
||||||
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
|
||||||
return model.ErrNotAuthorized
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pls = &model.Playlist{Name: name}
|
|
||||||
pls.OwnerID = usr.ID
|
|
||||||
}
|
|
||||||
pls.Tracks = nil
|
|
||||||
pls.AddMediaFilesByID(ids)
|
|
||||||
|
|
||||||
err = tx.Playlist(ctx).Put(pls)
|
|
||||||
playlistId = pls.ID
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
return playlistId, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) Delete(ctx context.Context, id string) error {
|
|
||||||
if _, err := s.checkWritable(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.ds.Playlist(ctx).Delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) Update(ctx context.Context, playlistID string,
|
|
||||||
name *string, comment *string, public *bool,
|
|
||||||
idsToAdd []string, idxToRemove []int) error {
|
|
||||||
var pls *model.Playlist
|
|
||||||
var err error
|
|
||||||
hasTrackChanges := len(idsToAdd) > 0 || len(idxToRemove) > 0
|
|
||||||
if hasTrackChanges {
|
|
||||||
pls, err = s.checkTracksEditable(ctx, playlistID)
|
|
||||||
} else {
|
|
||||||
pls, err = s.checkWritable(ctx, playlistID)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
|
||||||
repo := tx.Playlist(ctx)
|
|
||||||
|
|
||||||
if len(idxToRemove) > 0 {
|
|
||||||
tracksRepo := repo.Tracks(playlistID, false)
|
|
||||||
// Convert 0-based indices to 1-based position IDs and delete them directly,
|
|
||||||
// avoiding the need to load all tracks into memory.
|
|
||||||
positions := make([]string, len(idxToRemove))
|
|
||||||
for i, idx := range idxToRemove {
|
|
||||||
positions[i] = strconv.Itoa(idx + 1)
|
|
||||||
}
|
|
||||||
if err := tracksRepo.Delete(positions...); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(idsToAdd) > 0 {
|
|
||||||
if _, err := tracksRepo.Add(idsToAdd); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.updateMetadata(ctx, tx, pls, name, comment, public)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(idsToAdd) > 0 {
|
|
||||||
if _, err := repo.Tracks(playlistID, false).Add(idsToAdd); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if name == nil && comment == nil && public == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Reuse the playlist from checkWritable (no tracks loaded, so Put only refreshes counters)
|
|
||||||
return s.updateMetadata(ctx, tx, pls, name, comment, public)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Permission helpers ---
|
|
||||||
|
|
||||||
// checkWritable fetches the playlist and verifies the current user can modify it.
|
|
||||||
func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) {
|
|
||||||
pls, err := s.ds.Playlist(ctx).Get(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
usr, _ := request.UserFrom(ctx)
|
|
||||||
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
|
||||||
return nil, model.ErrNotAuthorized
|
|
||||||
}
|
|
||||||
return pls, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist).
|
|
||||||
func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) {
|
|
||||||
pls, err := s.checkWritable(ctx, playlistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if pls.IsSmartPlaylist() {
|
|
||||||
return nil, model.ErrNotAuthorized
|
|
||||||
}
|
|
||||||
return pls, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateMetadata applies optional metadata changes to a playlist and persists it.
|
|
||||||
// Accepts a DataStore parameter so it can be used inside transactions.
|
|
||||||
// The caller is responsible for permission checks.
|
|
||||||
func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls *model.Playlist, name *string, comment *string, public *bool) error {
|
|
||||||
if name != nil {
|
|
||||||
pls.Name = *name
|
|
||||||
}
|
|
||||||
if comment != nil {
|
|
||||||
pls.Comment = *comment
|
|
||||||
}
|
|
||||||
if public != nil {
|
|
||||||
pls.Public = *public
|
|
||||||
}
|
|
||||||
return ds.Playlist(ctx).Put(pls)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Track management operations ---
|
|
||||||
|
|
||||||
func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) {
|
|
||||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) {
|
|
||||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddAlbums(albumIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) {
|
|
||||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddArtists(artistIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) {
|
|
||||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddDiscs(discs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error {
|
|
||||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
|
||||||
return tx.Playlist(ctx).Tracks(playlistID, false).Delete(trackIds...)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error {
|
|
||||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
|
||||||
return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package playlists_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPlaylists(t *testing.T) {
|
|
||||||
tests.Init(t, false)
|
|
||||||
log.SetLevel(log.LevelFatal)
|
|
||||||
RegisterFailHandler(Fail)
|
|
||||||
RunSpecs(t, "Playlists Suite")
|
|
||||||
}
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
package playlists_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/model/criteria"
|
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("Playlists", func() {
|
|
||||||
var ds *tests.MockDataStore
|
|
||||||
var ps playlists.Playlists
|
|
||||||
var mockPlsRepo *tests.MockPlaylistRepo
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
|
||||||
ds = &tests.MockDataStore{
|
|
||||||
MockedPlaylist: mockPlsRepo,
|
|
||||||
MockedLibrary: &tests.MockLibraryRepo{},
|
|
||||||
}
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Delete", func() {
|
|
||||||
var mockTracks *tests.MockPlaylistTrackRepo
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 3}
|
|
||||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
|
||||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
|
||||||
}
|
|
||||||
mockPlsRepo.TracksRepo = mockTracks
|
|
||||||
ps = playlists.NewPlaylists(ds)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("allows owner to delete their playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
err := ps.Delete(ctx, "pls-1")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("allows admin to delete any playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
|
||||||
err := ps.Delete(ctx, "pls-1")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("denies non-owner, non-admin from deleting", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
|
||||||
err := ps.Delete(ctx, "pls-1")
|
|
||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
|
||||||
Expect(mockPlsRepo.Deleted).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when playlist not found", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
err := ps.Delete(ctx, "nonexistent")
|
|
||||||
Expect(err).To(Equal(model.ErrNotFound))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Create", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
|
||||||
"pls-1": {ID: "pls-1", Name: "Existing", OwnerID: "user-1"},
|
|
||||||
"pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
|
|
||||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
|
||||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
|
||||||
}
|
|
||||||
ps = playlists.NewPlaylists(ds)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("creates a new playlist with owner set from context", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
id, err := ps.Create(ctx, "", "New Playlist", []string{"song-1", "song-2"})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(id).ToNot(BeEmpty())
|
|
||||||
Expect(mockPlsRepo.Last.Name).To(Equal("New Playlist"))
|
|
||||||
Expect(mockPlsRepo.Last.OwnerID).To(Equal("user-1"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("replaces tracks on existing playlist when owner matches", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
id, err := ps.Create(ctx, "pls-1", "", []string{"song-3"})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(id).To(Equal("pls-1"))
|
|
||||||
Expect(mockPlsRepo.Last.Tracks).To(HaveLen(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("allows admin to replace tracks on any playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
|
||||||
id, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(id).To(Equal("pls-2"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("denies non-owner, non-admin from replacing tracks on existing playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
_, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
|
|
||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when existing playlistId not found", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
_, err := ps.Create(ctx, "nonexistent", "", []string{"song-1"})
|
|
||||||
Expect(err).To(Equal(model.ErrNotFound))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("denies replacing tracks on a smart playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
_, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
|
|
||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Update", func() {
|
|
||||||
var mockTracks *tests.MockPlaylistTrackRepo
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
|
|
||||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
|
||||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
|
||||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
|
||||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
|
||||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
|
||||||
}
|
|
||||||
mockPlsRepo.TracksRepo = mockTracks
|
|
||||||
ps = playlists.NewPlaylists(ds)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("allows owner to update their playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
newName := "Updated Name"
|
|
||||||
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("allows admin to update any playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
|
||||||
newName := "Updated Name"
|
|
||||||
err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("denies non-owner, non-admin from updating", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
|
||||||
newName := "Updated Name"
|
|
||||||
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
|
|
||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when playlist not found", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
newName := "Updated Name"
|
|
||||||
err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil)
|
|
||||||
Expect(err).To(Equal(model.ErrNotFound))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("denies adding tracks to a smart playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil)
|
|
||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("denies removing tracks from a smart playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0})
|
|
||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("allows metadata updates on a smart playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
newName := "Updated Smart"
|
|
||||||
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("AddTracks", func() {
|
|
||||||
var mockTracks *tests.MockPlaylistTrackRepo
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
|
|
||||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
|
||||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
|
||||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
|
||||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
|
||||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
|
||||||
}
|
|
||||||
mockPlsRepo.TracksRepo = mockTracks
|
|
||||||
ps = playlists.NewPlaylists(ds)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("allows owner to add tracks", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
count, err := ps.AddTracks(ctx, "pls-1", []string{"song-1", "song-2"})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(count).To(Equal(2))
|
|
||||||
Expect(mockTracks.AddedIds).To(ConsistOf("song-1", "song-2"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("allows admin to add tracks to any playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
|
||||||
count, err := ps.AddTracks(ctx, "pls-other", []string{"song-1"})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(count).To(Equal(2))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("denies non-owner, non-admin", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
|
||||||
_, err := ps.AddTracks(ctx, "pls-1", []string{"song-1"})
|
|
||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("denies editing smart playlists", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
_, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"})
|
|
||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when playlist not found", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
_, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"})
|
|
||||||
Expect(err).To(Equal(model.ErrNotFound))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("RemoveTracks", func() {
|
|
||||||
var mockTracks *tests.MockPlaylistTrackRepo
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
mockTracks = &tests.MockPlaylistTrackRepo{}
|
|
||||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
|
||||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
|
||||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
|
||||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
|
||||||
}
|
|
||||||
mockPlsRepo.TracksRepo = mockTracks
|
|
||||||
ps = playlists.NewPlaylists(ds)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("allows owner to remove tracks", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1", "track-2"})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mockTracks.DeletedIds).To(ConsistOf("track-1", "track-2"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("denies on smart playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"})
|
|
||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("denies non-owner", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
|
||||||
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"})
|
|
||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("ReorderTrack", func() {
|
|
||||||
var mockTracks *tests.MockPlaylistTrackRepo
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
mockTracks = &tests.MockPlaylistTrackRepo{}
|
|
||||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
|
||||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
|
||||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
|
||||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
|
||||||
}
|
|
||||||
mockPlsRepo.TracksRepo = mockTracks
|
|
||||||
ps = playlists.NewPlaylists(ds)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("allows owner to reorder", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
err := ps.ReorderTrack(ctx, "pls-1", 1, 3)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mockTracks.Reordered).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("denies on smart playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
|
|
||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package playlists
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/deluan/rest"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- REST adapter (follows Share/Library pattern) ---
|
|
||||||
|
|
||||||
func (s *playlists) NewRepository(ctx context.Context) rest.Repository {
|
|
||||||
return &playlistRepositoryWrapper{
|
|
||||||
ctx: ctx,
|
|
||||||
PlaylistRepository: s.ds.Playlist(ctx),
|
|
||||||
service: s,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter.
|
|
||||||
// It satisfies rest.Repository through the embedded PlaylistRepository (via ResourceRepository),
|
|
||||||
// and rest.Persistable by delegating to service methods for all mutations.
|
|
||||||
type playlistRepositoryWrapper struct {
|
|
||||||
model.PlaylistRepository
|
|
||||||
ctx context.Context
|
|
||||||
service *playlists
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
|
|
||||||
return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
|
|
||||||
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *playlistRepositoryWrapper) Delete(id string) error {
|
|
||||||
err := r.service.Delete(r.ctx, id)
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, model.ErrNotFound):
|
|
||||||
return rest.ErrNotFound
|
|
||||||
case errors.Is(err, model.ErrNotAuthorized):
|
|
||||||
return rest.ErrPermissionDenied
|
|
||||||
default:
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository {
|
|
||||||
repo := s.ds.Playlist(ctx)
|
|
||||||
tracks := repo.Tracks(playlistId, refreshSmartPlaylist)
|
|
||||||
if tracks == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return tracks.(rest.Repository)
|
|
||||||
}
|
|
||||||
|
|
||||||
// savePlaylist creates a new playlist, assigning the owner from context.
|
|
||||||
func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) {
|
|
||||||
usr, _ := request.UserFrom(ctx)
|
|
||||||
pls.OwnerID = usr.ID
|
|
||||||
pls.ID = "" // Force new creation
|
|
||||||
err := s.ds.Playlist(ctx).Put(pls)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return pls.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// updatePlaylistEntity updates playlist metadata with permission checks.
|
|
||||||
// Used by the REST API wrapper.
|
|
||||||
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
|
|
||||||
current, err := s.checkWritable(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, model.ErrNotFound):
|
|
||||||
return rest.ErrNotFound
|
|
||||||
case errors.Is(err, model.ErrNotAuthorized):
|
|
||||||
return rest.ErrPermissionDenied
|
|
||||||
default:
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
usr, _ := request.UserFrom(ctx)
|
|
||||||
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
|
|
||||||
return rest.ErrPermissionDenied
|
|
||||||
}
|
|
||||||
// Apply ownership change (admin only)
|
|
||||||
if entity.OwnerID != "" {
|
|
||||||
current.OwnerID = entity.OwnerID
|
|
||||||
}
|
|
||||||
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
package playlists_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/deluan/rest"
|
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("REST Adapter", func() {
|
|
||||||
var ds *tests.MockDataStore
|
|
||||||
var ps playlists.Playlists
|
|
||||||
var mockPlsRepo *tests.MockPlaylistRepo
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
|
||||||
ds = &tests.MockDataStore{
|
|
||||||
MockedPlaylist: mockPlsRepo,
|
|
||||||
MockedLibrary: &tests.MockLibraryRepo{},
|
|
||||||
}
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("NewRepository", func() {
|
|
||||||
var repo rest.Persistable
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
|
||||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
|
||||||
}
|
|
||||||
ps = playlists.NewPlaylists(ds)
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Save", func() {
|
|
||||||
It("sets the owner from the context user", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
|
||||||
pls := &model.Playlist{Name: "New Playlist"}
|
|
||||||
id, err := repo.Save(pls)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(id).ToNot(BeEmpty())
|
|
||||||
Expect(pls.OwnerID).To(Equal("user-1"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("forces a new creation by clearing ID", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
|
||||||
pls := &model.Playlist{ID: "should-be-cleared", Name: "New"}
|
|
||||||
_, err := repo.Save(pls)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(pls.ID).ToNot(Equal("should-be-cleared"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Update", func() {
|
|
||||||
It("allows owner to update their playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
|
||||||
pls := &model.Playlist{Name: "Updated"}
|
|
||||||
err := repo.Update("pls-1", pls)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("allows admin to update any playlist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
|
||||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
|
||||||
pls := &model.Playlist{Name: "Updated"}
|
|
||||||
err := repo.Update("pls-1", pls)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("denies non-owner, non-admin", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
|
||||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
|
||||||
pls := &model.Playlist{Name: "Updated"}
|
|
||||||
err := repo.Update("pls-1", pls)
|
|
||||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("denies regular user from changing ownership", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
|
||||||
pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"}
|
|
||||||
err := repo.Update("pls-1", pls)
|
|
||||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns rest.ErrNotFound when playlist doesn't exist", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
|
||||||
pls := &model.Playlist{Name: "Updated"}
|
|
||||||
err := repo.Update("nonexistent", pls)
|
|
||||||
Expect(err).To(Equal(rest.ErrNotFound))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Delete", func() {
|
|
||||||
It("delegates to service Delete with permission checks", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
|
||||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
|
||||||
err := repo.Delete("pls-1")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("denies non-owner", func() {
|
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
|
||||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
|
||||||
err := repo.Delete("pls-1")
|
|
||||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package playlists
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -214,7 +214,6 @@ var _ = Describe("pathResolver", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
Describe("resolvePath", func() {
|
Describe("resolvePath", func() {
|
||||||
Context("basic", func() {
|
|
||||||
It("resolves absolute paths", func() {
|
It("resolves absolute paths", func() {
|
||||||
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
|
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
|
||||||
|
|
||||||
@@ -245,7 +244,8 @@ var _ = Describe("pathResolver", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("cross-library", func() {
|
Describe("resolvePath", func() {
|
||||||
|
Context("With absolute paths", func() {
|
||||||
It("resolves path within a library", func() {
|
It("resolves path within a library", func() {
|
||||||
resolution := resolver.resolvePath("/music/track.mp3", nil)
|
resolution := resolver.resolvePath("/music/track.mp3", nil)
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package playlists_test
|
package core_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/criteria"
|
"github.com/navidrome/navidrome/model/criteria"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
@@ -19,18 +19,18 @@ import (
|
|||||||
"golang.org/x/text/unicode/norm"
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("Playlists - Import", func() {
|
var _ = Describe("Playlists", func() {
|
||||||
var ds *tests.MockDataStore
|
var ds *tests.MockDataStore
|
||||||
var ps playlists.Playlists
|
var ps core.Playlists
|
||||||
var mockPlsRepo *tests.MockPlaylistRepo
|
var mockPlsRepo mockedPlaylistRepo
|
||||||
var mockLibRepo *tests.MockLibraryRepo
|
var mockLibRepo *tests.MockLibraryRepo
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
mockPlsRepo = mockedPlaylistRepo{}
|
||||||
mockLibRepo = &tests.MockLibraryRepo{}
|
mockLibRepo = &tests.MockLibraryRepo{}
|
||||||
ds = &tests.MockDataStore{
|
ds = &tests.MockDataStore{
|
||||||
MockedPlaylist: mockPlsRepo,
|
MockedPlaylist: &mockPlsRepo,
|
||||||
MockedLibrary: mockLibRepo,
|
MockedLibrary: mockLibRepo,
|
||||||
}
|
}
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||||
@@ -39,7 +39,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
Describe("ImportFile", func() {
|
Describe("ImportFile", func() {
|
||||||
var folder *model.Folder
|
var folder *model.Folder
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = core.NewPlaylists(ds)
|
||||||
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
||||||
libPath, _ := os.Getwd()
|
libPath, _ := os.Getwd()
|
||||||
// Set up library with the actual library path that matches the folder
|
// Set up library with the actual library path that matches the folder
|
||||||
@@ -61,7 +61,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
Expect(pls.Tracks).To(HaveLen(2))
|
Expect(pls.Tracks).To(HaveLen(2))
|
||||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
|
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
|
||||||
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
|
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
|
||||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("parses playlists using LF ending", func() {
|
It("parses playlists using LF ending", func() {
|
||||||
@@ -99,7 +99,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
It("parses well-formed playlists", func() {
|
It("parses well-formed playlists", func() {
|
||||||
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||||
Expect(pls.OwnerID).To(Equal("123"))
|
Expect(pls.OwnerID).To(Equal("123"))
|
||||||
Expect(pls.Name).To(Equal("Recently Played"))
|
Expect(pls.Name).To(Equal("Recently Played"))
|
||||||
Expect(pls.Comment).To(Equal("Recently played tracks"))
|
Expect(pls.Comment).To(Equal("Recently played tracks"))
|
||||||
@@ -149,7 +149,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
tmpDir := GinkgoT().TempDir()
|
tmpDir := GinkgoT().TempDir()
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = core.NewPlaylists(ds)
|
||||||
|
|
||||||
// Create the playlist file on disk with the filesystem's normalization form
|
// Create the playlist file on disk with the filesystem's normalization form
|
||||||
plsFile := tmpDir + "/" + filesystemName + ".m3u"
|
plsFile := tmpDir + "/" + filesystemName + ".m3u"
|
||||||
@@ -163,7 +163,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
Path: storedPath,
|
Path: storedPath,
|
||||||
Sync: true,
|
Sync: true,
|
||||||
}
|
}
|
||||||
mockPlsRepo.PathMap = map[string]*model.Playlist{storedPath: existingPls}
|
mockPlsRepo.data = map[string]*model.Playlist{storedPath: existingPls}
|
||||||
|
|
||||||
// Import using the filesystem's normalization form
|
// Import using the filesystem's normalization form
|
||||||
plsFolder := &model.Folder{
|
plsFolder := &model.Folder{
|
||||||
@@ -209,7 +209,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
"def.mp3", // This is playlists/def.mp3 relative to plsDir
|
"def.mp3", // This is playlists/def.mp3 relative to plsDir
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = core.NewPlaylists(ds)
|
||||||
})
|
})
|
||||||
|
|
||||||
It("handles relative paths that reference files in other libraries", func() {
|
It("handles relative paths that reference files in other libraries", func() {
|
||||||
@@ -365,7 +365,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
// Recreate playlists service to pick up new mock
|
// Recreate playlists service to pick up new mock
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = core.NewPlaylists(ds)
|
||||||
|
|
||||||
// Create playlist in music library that references both tracks
|
// Create playlist in music library that references both tracks
|
||||||
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
|
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
|
||||||
@@ -408,7 +408,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
repo = &mockedMediaFileFromListRepo{}
|
repo = &mockedMediaFileFromListRepo{}
|
||||||
ds.MockedMediaFile = repo
|
ds.MockedMediaFile = repo
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = core.NewPlaylists(ds)
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||||
})
|
})
|
||||||
@@ -439,7 +439,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
|
Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
|
||||||
Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
|
Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
|
||||||
Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
|
Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
|
||||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
|
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
|
||||||
@@ -460,7 +460,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
Expect(pls.Tracks).To(HaveLen(2))
|
Expect(pls.Tracks).To(HaveLen(2))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns only tracks that exist in the database and in the same order as the m3u", func() {
|
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
|
||||||
repo.data = []string{
|
repo.data = []string{
|
||||||
"album1/test1.mp3",
|
"album1/test1.mp3",
|
||||||
"album2/test2.mp3",
|
"album2/test2.mp3",
|
||||||
@@ -570,7 +570,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("InPath", func() {
|
Describe("InPlaylistsPath", func() {
|
||||||
var folder model.Folder
|
var folder model.Folder
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
@@ -584,27 +584,27 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
|
|
||||||
It("returns true if PlaylistsPath is empty", func() {
|
It("returns true if PlaylistsPath is empty", func() {
|
||||||
conf.Server.PlaylistsPath = ""
|
conf.Server.PlaylistsPath = ""
|
||||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns true if PlaylistsPath is any (**/**)", func() {
|
It("returns true if PlaylistsPath is any (**/**)", func() {
|
||||||
conf.Server.PlaylistsPath = "**/**"
|
conf.Server.PlaylistsPath = "**/**"
|
||||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns true if folder is in PlaylistsPath", func() {
|
It("returns true if folder is in PlaylistsPath", func() {
|
||||||
conf.Server.PlaylistsPath = "other/**:playlists/**"
|
conf.Server.PlaylistsPath = "other/**:playlists/**"
|
||||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns false if folder is not in PlaylistsPath", func() {
|
It("returns false if folder is not in PlaylistsPath", func() {
|
||||||
conf.Server.PlaylistsPath = "other"
|
conf.Server.PlaylistsPath = "other"
|
||||||
Expect(playlists.InPath(folder)).To(BeFalse())
|
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
|
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
|
||||||
conf.Server.PlaylistsPath = "."
|
conf.Server.PlaylistsPath = "."
|
||||||
Expect(playlists.InPath(folder)).To(BeFalse())
|
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||||
|
|
||||||
folder2 := model.Folder{
|
folder2 := model.Folder{
|
||||||
LibraryPath: "/music",
|
LibraryPath: "/music",
|
||||||
@@ -612,7 +612,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
Name: ".",
|
Name: ".",
|
||||||
}
|
}
|
||||||
|
|
||||||
Expect(playlists.InPath(folder2)).To(BeTrue())
|
Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -693,3 +693,23 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
|
|||||||
}
|
}
|
||||||
return mfs, nil
|
return mfs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mockedPlaylistRepo struct {
|
||||||
|
last *model.Playlist
|
||||||
|
data map[string]*model.Playlist // keyed by path
|
||||||
|
model.PlaylistRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockedPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
|
||||||
|
if r.data != nil {
|
||||||
|
if pls, ok := r.data[path]; ok {
|
||||||
|
return pls, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, model.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error {
|
||||||
|
r.last = pls
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@ var Set = wire.NewSet(
|
|||||||
NewArchiver,
|
NewArchiver,
|
||||||
NewPlayers,
|
NewPlayers,
|
||||||
NewShare,
|
NewShare,
|
||||||
playlists.NewPlaylists,
|
NewPlaylists,
|
||||||
NewLibrary,
|
NewLibrary,
|
||||||
NewUser,
|
NewUser,
|
||||||
NewMaintenance,
|
NewMaintenance,
|
||||||
|
|||||||
9
db/db.go
9
db/db.go
@@ -6,7 +6,9 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/maruel/natural"
|
||||||
"github.com/mattn/go-sqlite3"
|
"github.com/mattn/go-sqlite3"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
_ "github.com/navidrome/navidrome/db/migrations"
|
_ "github.com/navidrome/navidrome/db/migrations"
|
||||||
@@ -31,7 +33,12 @@ func Db() *sql.DB {
|
|||||||
return singleton.GetInstance(func() *sql.DB {
|
return singleton.GetInstance(func() *sql.DB {
|
||||||
sql.Register(Driver, &sqlite3.SQLiteDriver{
|
sql.Register(Driver, &sqlite3.SQLiteDriver{
|
||||||
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||||
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
|
if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return conn.RegisterCollation("NATURALSORT", func(a, b string) int {
|
||||||
|
return natural.Compare(strings.ToLower(a), strings.ToLower(b))
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
Path = conf.Server.DbPath
|
Path = conf.Server.DbPath
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
-- Change order_*/sort_* column collation from NOCASE to NATURALSORT.
|
||||||
|
-- This way bare ORDER BY on these columns automatically uses natural sorting,
|
||||||
|
-- without needing explicit COLLATE NATURALSORT in every query.
|
||||||
|
PRAGMA writable_schema = ON;
|
||||||
|
UPDATE sqlite_master
|
||||||
|
SET sql = replace(sql, 'collate NOCASE', 'collate NATURALSORT')
|
||||||
|
WHERE type = 'table' AND name IN ('artist', 'album', 'media_file', 'playlist', 'radio');
|
||||||
|
PRAGMA writable_schema = OFF;
|
||||||
|
|
||||||
|
-- Recreate indexes on order_* and sort expression fields to use NATURALSORT collation.
|
||||||
|
-- This enables natural number ordering (e.g., "Album 2" before "Album 10").
|
||||||
|
|
||||||
|
-- Artist indexes
|
||||||
|
drop index if exists artist_order_artist_name;
|
||||||
|
create index artist_order_artist_name
|
||||||
|
on artist (order_artist_name collate NATURALSORT);
|
||||||
|
|
||||||
|
drop index if exists artist_sort_name;
|
||||||
|
create index artist_sort_name
|
||||||
|
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT);
|
||||||
|
|
||||||
|
-- Album indexes
|
||||||
|
drop index if exists album_order_album_name;
|
||||||
|
create index album_order_album_name
|
||||||
|
on album (order_album_name collate NATURALSORT);
|
||||||
|
|
||||||
|
drop index if exists album_order_album_artist_name;
|
||||||
|
create index album_order_album_artist_name
|
||||||
|
on album (order_album_artist_name collate NATURALSORT);
|
||||||
|
|
||||||
|
drop index if exists album_alphabetical_by_artist;
|
||||||
|
create index album_alphabetical_by_artist
|
||||||
|
on album (compilation, order_album_artist_name collate NATURALSORT, order_album_name collate NATURALSORT);
|
||||||
|
|
||||||
|
drop index if exists album_sort_name;
|
||||||
|
create index album_sort_name
|
||||||
|
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT);
|
||||||
|
|
||||||
|
drop index if exists album_sort_album_artist_name;
|
||||||
|
create index album_sort_album_artist_name
|
||||||
|
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NATURALSORT);
|
||||||
|
|
||||||
|
-- Media file indexes
|
||||||
|
drop index if exists media_file_order_title;
|
||||||
|
create index media_file_order_title
|
||||||
|
on media_file (order_title collate NATURALSORT);
|
||||||
|
|
||||||
|
drop index if exists media_file_order_album_name;
|
||||||
|
create index media_file_order_album_name
|
||||||
|
on media_file (order_album_name collate NATURALSORT);
|
||||||
|
|
||||||
|
drop index if exists media_file_order_artist_name;
|
||||||
|
create index media_file_order_artist_name
|
||||||
|
on media_file (order_artist_name collate NATURALSORT);
|
||||||
|
|
||||||
|
drop index if exists media_file_sort_title;
|
||||||
|
create index media_file_sort_title
|
||||||
|
on media_file (coalesce(nullif(sort_title,''),order_title) collate NATURALSORT);
|
||||||
|
|
||||||
|
drop index if exists media_file_sort_artist_name;
|
||||||
|
create index media_file_sort_artist_name
|
||||||
|
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT);
|
||||||
|
|
||||||
|
drop index if exists media_file_sort_album_name;
|
||||||
|
create index media_file_sort_album_name
|
||||||
|
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT);
|
||||||
|
|
||||||
|
-- Playlist and radio indexes: recreate to match new NATURALSORT column collation
|
||||||
|
drop index if exists playlist_name;
|
||||||
|
create index playlist_name
|
||||||
|
on playlist (name collate NATURALSORT);
|
||||||
|
|
||||||
|
drop index if exists radio_name;
|
||||||
|
create index radio_name
|
||||||
|
on radio (name collate NATURALSORT);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
-- Restore NOCASE column collation
|
||||||
|
PRAGMA writable_schema = ON;
|
||||||
|
UPDATE sqlite_master
|
||||||
|
SET sql = replace(sql, 'collate NATURALSORT', 'collate NOCASE')
|
||||||
|
WHERE type = 'table' AND name IN ('artist', 'album', 'media_file', 'playlist', 'radio');
|
||||||
|
PRAGMA writable_schema = OFF;
|
||||||
|
|
||||||
|
-- Restore NOCASE collation indexes
|
||||||
|
|
||||||
|
-- Artist indexes
|
||||||
|
drop index if exists artist_order_artist_name;
|
||||||
|
create index artist_order_artist_name
|
||||||
|
on artist (order_artist_name);
|
||||||
|
|
||||||
|
drop index if exists artist_sort_name;
|
||||||
|
create index artist_sort_name
|
||||||
|
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
|
||||||
|
|
||||||
|
-- Album indexes
|
||||||
|
drop index if exists album_order_album_name;
|
||||||
|
create index album_order_album_name
|
||||||
|
on album (order_album_name);
|
||||||
|
|
||||||
|
drop index if exists album_order_album_artist_name;
|
||||||
|
create index album_order_album_artist_name
|
||||||
|
on album (order_album_artist_name);
|
||||||
|
|
||||||
|
drop index if exists album_alphabetical_by_artist;
|
||||||
|
create index album_alphabetical_by_artist
|
||||||
|
on album (compilation, order_album_artist_name, order_album_name);
|
||||||
|
|
||||||
|
drop index if exists album_sort_name;
|
||||||
|
create index album_sort_name
|
||||||
|
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
|
||||||
|
|
||||||
|
drop index if exists album_sort_album_artist_name;
|
||||||
|
create index album_sort_album_artist_name
|
||||||
|
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NOCASE);
|
||||||
|
|
||||||
|
-- Media file indexes
|
||||||
|
drop index if exists media_file_order_title;
|
||||||
|
create index media_file_order_title
|
||||||
|
on media_file (order_title);
|
||||||
|
|
||||||
|
drop index if exists media_file_order_album_name;
|
||||||
|
create index media_file_order_album_name
|
||||||
|
on media_file (order_album_name);
|
||||||
|
|
||||||
|
drop index if exists media_file_order_artist_name;
|
||||||
|
create index media_file_order_artist_name
|
||||||
|
on media_file (order_artist_name);
|
||||||
|
|
||||||
|
drop index if exists media_file_sort_title;
|
||||||
|
create index media_file_sort_title
|
||||||
|
on media_file (coalesce(nullif(sort_title,''),order_title) collate NOCASE);
|
||||||
|
|
||||||
|
drop index if exists media_file_sort_artist_name;
|
||||||
|
create index media_file_sort_artist_name
|
||||||
|
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
|
||||||
|
|
||||||
|
drop index if exists media_file_sort_album_name;
|
||||||
|
create index media_file_sort_album_name
|
||||||
|
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
|
||||||
|
|
||||||
|
-- Restore playlist and radio indexes
|
||||||
|
drop index if exists playlist_name;
|
||||||
|
create index playlist_name
|
||||||
|
on playlist (name);
|
||||||
|
|
||||||
|
drop index if exists radio_name;
|
||||||
|
create index radio_name
|
||||||
|
on radio (name);
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/pressly/goose/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
goose.AddMigrationContext(upAddFts5Search, downAddFts5Search)
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripPunct generates a SQL expression that strips common punctuation from a column or expression.
|
|
||||||
// Used during migration to approximate the Go normalizeForFTS function for bulk-populating search_normalized.
|
|
||||||
func stripPunct(col string) string {
|
|
||||||
return fmt.Sprintf(
|
|
||||||
`REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(%s, '.', ''), '/', ''), '-', ''), '''', ''), '&', ''), ',', '')`,
|
|
||||||
col,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func upAddFts5Search(ctx context.Context, tx *sql.Tx) error {
|
|
||||||
notice(tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.")
|
|
||||||
|
|
||||||
// Step 1: Add search_participants and search_normalized columns to media_file, album, and artist
|
|
||||||
_, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("adding search_participants to media_file: %w", err)
|
|
||||||
}
|
|
||||||
_, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("adding search_normalized to media_file: %w", err)
|
|
||||||
}
|
|
||||||
_, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("adding search_participants to album: %w", err)
|
|
||||||
}
|
|
||||||
_, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("adding search_normalized to album: %w", err)
|
|
||||||
}
|
|
||||||
_, err = tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("adding search_normalized to artist: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Populate search_participants from participants JSON.
|
|
||||||
// Extract all "name" values from the participants JSON structure.
|
|
||||||
// participants is a JSON object like: {"artist":[{"name":"...","id":"..."}],"albumartist":[...]}
|
|
||||||
// We use json_each + json_extract to flatten all names into a space-separated string.
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
UPDATE media_file SET search_participants = COALESCE(
|
|
||||||
(SELECT group_concat(json_extract(je2.value, '$.name'), ' ')
|
|
||||||
FROM json_each(media_file.participants) AS je1,
|
|
||||||
json_each(je1.value) AS je2
|
|
||||||
WHERE json_extract(je2.value, '$.name') IS NOT NULL),
|
|
||||||
''
|
|
||||||
)
|
|
||||||
WHERE participants IS NOT NULL AND participants != '' AND participants != '{}'
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("populating media_file search_participants: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
UPDATE album SET search_participants = COALESCE(
|
|
||||||
(SELECT group_concat(json_extract(je2.value, '$.name'), ' ')
|
|
||||||
FROM json_each(album.participants) AS je1,
|
|
||||||
json_each(je1.value) AS je2
|
|
||||||
WHERE json_extract(je2.value, '$.name') IS NOT NULL),
|
|
||||||
''
|
|
||||||
)
|
|
||||||
WHERE participants IS NOT NULL AND participants != '' AND participants != '{}'
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("populating album search_participants: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2b: Populate search_normalized using SQL REPLACE chains for common punctuation.
|
|
||||||
// The Go code will compute the precise value on next scan; this is a best-effort approximation.
|
|
||||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
|
|
||||||
UPDATE artist SET search_normalized = %s
|
|
||||||
WHERE name != %s`,
|
|
||||||
stripPunct("name"), stripPunct("name")))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("populating artist search_normalized: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
|
|
||||||
UPDATE album SET search_normalized = TRIM(%s || ' ' || %s)
|
|
||||||
WHERE name != %s OR COALESCE(album_artist, '') != %s`,
|
|
||||||
stripPunct("name"), stripPunct("COALESCE(album_artist, '')"),
|
|
||||||
stripPunct("name"), stripPunct("COALESCE(album_artist, '')")))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("populating album search_normalized: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
|
|
||||||
UPDATE media_file SET search_normalized =
|
|
||||||
TRIM(%s || ' ' || %s || ' ' || %s || ' ' || %s)
|
|
||||||
WHERE title != %s
|
|
||||||
OR COALESCE(album, '') != %s
|
|
||||||
OR COALESCE(artist, '') != %s
|
|
||||||
OR COALESCE(album_artist, '') != %s`,
|
|
||||||
stripPunct("title"), stripPunct("COALESCE(album, '')"),
|
|
||||||
stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')"),
|
|
||||||
stripPunct("title"), stripPunct("COALESCE(album, '')"),
|
|
||||||
stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')")))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("populating media_file search_normalized: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Create FTS5 virtual tables
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS media_file_fts USING fts5(
|
|
||||||
title, album, artist, album_artist,
|
|
||||||
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
|
|
||||||
disc_subtitle, search_participants, search_normalized,
|
|
||||||
content='', content_rowid='rowid',
|
|
||||||
tokenize='unicode61 remove_diacritics 2'
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating media_file_fts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5(
|
|
||||||
name, sort_album_name, album_artist,
|
|
||||||
search_participants, discs, catalog_num, album_version, search_normalized,
|
|
||||||
content='', content_rowid='rowid',
|
|
||||||
tokenize='unicode61 remove_diacritics 2'
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating album_fts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5(
|
|
||||||
name, sort_artist_name, search_normalized,
|
|
||||||
content='', content_rowid='rowid',
|
|
||||||
tokenize='unicode61 remove_diacritics 2'
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating artist_fts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Bulk-populate FTS5 indexes from existing data
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
|
|
||||||
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
|
|
||||||
disc_subtitle, search_participants, search_normalized)
|
|
||||||
SELECT rowid, title, album, artist, album_artist,
|
|
||||||
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
|
|
||||||
COALESCE(disc_subtitle, ''), COALESCE(search_participants, ''),
|
|
||||||
COALESCE(search_normalized, '')
|
|
||||||
FROM media_file
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("populating media_file_fts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
|
|
||||||
search_participants, discs, catalog_num, album_version, search_normalized)
|
|
||||||
SELECT rowid, name, COALESCE(sort_album_name, ''), COALESCE(album_artist, ''),
|
|
||||||
COALESCE(search_participants, ''), COALESCE(discs, ''),
|
|
||||||
COALESCE(catalog_num, ''),
|
|
||||||
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
|
|
||||||
FROM json_each(album.tags, '$.albumversion') AS je), ''),
|
|
||||||
COALESCE(search_normalized, '')
|
|
||||||
FROM album
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("populating album_fts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
|
|
||||||
SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '')
|
|
||||||
FROM artist
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("populating artist_fts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Create triggers for media_file
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
CREATE TRIGGER media_file_fts_ai AFTER INSERT ON media_file BEGIN
|
|
||||||
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
|
|
||||||
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
|
|
||||||
disc_subtitle, search_participants, search_normalized)
|
|
||||||
VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
|
|
||||||
NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
|
|
||||||
COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
|
|
||||||
COALESCE(NEW.search_normalized, ''));
|
|
||||||
END
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating media_file_fts insert trigger: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
CREATE TRIGGER media_file_fts_ad AFTER DELETE ON media_file BEGIN
|
|
||||||
INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
|
|
||||||
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
|
|
||||||
disc_subtitle, search_participants, search_normalized)
|
|
||||||
VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
|
|
||||||
OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
|
|
||||||
COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
|
|
||||||
COALESCE(OLD.search_normalized, ''));
|
|
||||||
END
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating media_file_fts delete trigger: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
CREATE TRIGGER media_file_fts_au AFTER UPDATE ON media_file
|
|
||||||
WHEN
|
|
||||||
OLD.title IS NOT NEW.title OR
|
|
||||||
OLD.album IS NOT NEW.album OR
|
|
||||||
OLD.artist IS NOT NEW.artist OR
|
|
||||||
OLD.album_artist IS NOT NEW.album_artist OR
|
|
||||||
OLD.sort_title IS NOT NEW.sort_title OR
|
|
||||||
OLD.sort_album_name IS NOT NEW.sort_album_name OR
|
|
||||||
OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
|
|
||||||
OLD.sort_album_artist_name IS NOT NEW.sort_album_artist_name OR
|
|
||||||
OLD.disc_subtitle IS NOT NEW.disc_subtitle OR
|
|
||||||
OLD.search_participants IS NOT NEW.search_participants OR
|
|
||||||
OLD.search_normalized IS NOT NEW.search_normalized
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
|
|
||||||
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
|
|
||||||
disc_subtitle, search_participants, search_normalized)
|
|
||||||
VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
|
|
||||||
OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
|
|
||||||
COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
|
|
||||||
COALESCE(OLD.search_normalized, ''));
|
|
||||||
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
|
|
||||||
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
|
|
||||||
disc_subtitle, search_participants, search_normalized)
|
|
||||||
VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
|
|
||||||
NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
|
|
||||||
COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
|
|
||||||
COALESCE(NEW.search_normalized, ''));
|
|
||||||
END
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating media_file_fts update trigger: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Create triggers for album
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
CREATE TRIGGER album_fts_ai AFTER INSERT ON album BEGIN
|
|
||||||
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
|
|
||||||
search_participants, discs, catalog_num, album_version, search_normalized)
|
|
||||||
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
|
|
||||||
COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
|
|
||||||
COALESCE(NEW.catalog_num, ''),
|
|
||||||
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
|
|
||||||
FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
|
|
||||||
COALESCE(NEW.search_normalized, ''));
|
|
||||||
END
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating album_fts insert trigger: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
CREATE TRIGGER album_fts_ad AFTER DELETE ON album BEGIN
|
|
||||||
INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
|
|
||||||
search_participants, discs, catalog_num, album_version, search_normalized)
|
|
||||||
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
|
|
||||||
COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
|
|
||||||
COALESCE(OLD.catalog_num, ''),
|
|
||||||
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
|
|
||||||
FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
|
|
||||||
COALESCE(OLD.search_normalized, ''));
|
|
||||||
END
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating album_fts delete trigger: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
CREATE TRIGGER album_fts_au AFTER UPDATE ON album
|
|
||||||
WHEN
|
|
||||||
OLD.name IS NOT NEW.name OR
|
|
||||||
OLD.sort_album_name IS NOT NEW.sort_album_name OR
|
|
||||||
OLD.album_artist IS NOT NEW.album_artist OR
|
|
||||||
OLD.search_participants IS NOT NEW.search_participants OR
|
|
||||||
OLD.discs IS NOT NEW.discs OR
|
|
||||||
OLD.catalog_num IS NOT NEW.catalog_num OR
|
|
||||||
OLD.tags IS NOT NEW.tags OR
|
|
||||||
OLD.search_normalized IS NOT NEW.search_normalized
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
|
|
||||||
search_participants, discs, catalog_num, album_version, search_normalized)
|
|
||||||
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
|
|
||||||
COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
|
|
||||||
COALESCE(OLD.catalog_num, ''),
|
|
||||||
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
|
|
||||||
FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
|
|
||||||
COALESCE(OLD.search_normalized, ''));
|
|
||||||
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
|
|
||||||
search_participants, discs, catalog_num, album_version, search_normalized)
|
|
||||||
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
|
|
||||||
COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
|
|
||||||
COALESCE(NEW.catalog_num, ''),
|
|
||||||
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
|
|
||||||
FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
|
|
||||||
COALESCE(NEW.search_normalized, ''));
|
|
||||||
END
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating album_fts update trigger: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 7: Create triggers for artist
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
CREATE TRIGGER artist_fts_ai AFTER INSERT ON artist BEGIN
|
|
||||||
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
|
|
||||||
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
|
|
||||||
COALESCE(NEW.search_normalized, ''));
|
|
||||||
END
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating artist_fts insert trigger: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
CREATE TRIGGER artist_fts_ad AFTER DELETE ON artist BEGIN
|
|
||||||
INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
|
|
||||||
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
|
|
||||||
COALESCE(OLD.search_normalized, ''));
|
|
||||||
END
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating artist_fts delete trigger: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
CREATE TRIGGER artist_fts_au AFTER UPDATE ON artist
|
|
||||||
WHEN
|
|
||||||
OLD.name IS NOT NEW.name OR
|
|
||||||
OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
|
|
||||||
OLD.search_normalized IS NOT NEW.search_normalized
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
|
|
||||||
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
|
|
||||||
COALESCE(OLD.search_normalized, ''));
|
|
||||||
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
|
|
||||||
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
|
|
||||||
COALESCE(NEW.search_normalized, ''));
|
|
||||||
END
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating artist_fts update trigger: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func downAddFts5Search(ctx context.Context, tx *sql.Tx) error {
|
|
||||||
for _, trigger := range []string{
|
|
||||||
"media_file_fts_ai", "media_file_fts_ad", "media_file_fts_au",
|
|
||||||
"album_fts_ai", "album_fts_ad", "album_fts_au",
|
|
||||||
"artist_fts_ai", "artist_fts_ad", "artist_fts_au",
|
|
||||||
} {
|
|
||||||
_, err := tx.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dropping trigger %s: %w", trigger, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, table := range []string{"media_file_fts", "album_fts", "artist_fts"} {
|
|
||||||
_, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dropping table %s: %w", table, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: We don't drop search_participants columns because SQLite doesn't support DROP COLUMN
|
|
||||||
// on older versions, and the column is harmless if left in place.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
ALTER TABLE plugin ADD COLUMN allow_write_access BOOL NOT NULL DEFAULT false;
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
ALTER TABLE plugin DROP COLUMN allow_write_access;
|
|
||||||
9
go.mod
9
go.mod
@@ -1,13 +1,13 @@
|
|||||||
module github.com/navidrome/navidrome
|
module github.com/navidrome/navidrome
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25
|
||||||
|
|
||||||
replace (
|
replace (
|
||||||
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
// 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
|
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||||
|
|
||||||
// Fork to implement raw tags support
|
// Fork to implement raw tags support
|
||||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260225021432-1699562530f1
|
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -53,7 +53,7 @@ require (
|
|||||||
github.com/onsi/gomega v1.39.1
|
github.com/onsi/gomega v1.39.1
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
github.com/pocketbase/dbx v1.12.0
|
github.com/pocketbase/dbx v1.12.0
|
||||||
github.com/pressly/goose/v3 v3.27.0
|
github.com/pressly/goose/v3 v3.26.0
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/rjeczalik/notify v0.9.3
|
github.com/rjeczalik/notify v0.9.3
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
@@ -88,7 +88,7 @@ require (
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/creack/pty v1.1.24 // 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.1 // 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/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
|
||||||
@@ -140,6 +140,7 @@ require (
|
|||||||
go.yaml.in/yaml/v2 v2.4.3 // 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.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
|
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
|
|||||||
36
go.sum
36
go.sum
@@ -1,7 +1,7 @@
|
|||||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||||
@@ -34,10 +34,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
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=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/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.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/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-20260225021432-1699562530f1 h1:seWJmkPAb+M1ysRNGzTGS7FfdrUe9wQTHhB9p2fxDWg=
|
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e h1:pwx3kmHzl1N28coJV2C1zfm2ZF0qkQcGX+Z6BvXteB4=
|
||||||
github.com/deluan/go-taglib v0.0.0-20260225021432-1699562530f1/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e/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=
|
||||||
@@ -143,8 +143,8 @@ github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2Og
|
|||||||
github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
|
github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
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/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=
|
||||||
@@ -193,8 +193,8 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ
|
|||||||
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v1.0.0/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.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||||
@@ -212,8 +212,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
|
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||||
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
|
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
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=
|
||||||
@@ -321,8 +321,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
|
|||||||
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
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.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||||
@@ -423,11 +423,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
|
|||||||
5
main.go
5
main.go
@@ -9,12 +9,11 @@ import (
|
|||||||
|
|
||||||
//goland:noinspection GoBoolExpressions
|
//goland:noinspection GoBoolExpressions
|
||||||
func main() {
|
func main() {
|
||||||
// These references force the inclusion of build tags when compiling the project.
|
// This import is used to force the inclusion of the `netgo` tag when compiling the project.
|
||||||
// If you get compilation errors like "undefined: buildtags.NETGO", this means you forgot to specify
|
// If you get compilation errors like "undefined: buildtags.NETGO", this means you forgot to specify
|
||||||
// the required build tags when compiling the project.
|
// the `netgo` build tag when compiling the project.
|
||||||
// To avoid these kind of errors, you should use `make build` to compile the project.
|
// To avoid these kind of errors, you should use `make build` to compile the project.
|
||||||
_ = buildtags.NETGO
|
_ = buildtags.NETGO
|
||||||
_ = buildtags.SQLITE_FTS5
|
|
||||||
|
|
||||||
cmd.Execute()
|
cmd.Execute()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"iter"
|
"iter"
|
||||||
"math"
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
|
|
||||||
"github.com/gohugoio/hashstructure"
|
"github.com/gohugoio/hashstructure"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,13 +70,6 @@ func (a Album) CoverArtID() ArtworkID {
|
|||||||
return artworkIDFromAlbum(a)
|
return artworkIDFromAlbum(a)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a Album) FullName() string {
|
|
||||||
if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 {
|
|
||||||
return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0])
|
|
||||||
}
|
|
||||||
return a.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// Equals compares two Album structs, ignoring calculated fields
|
// Equals compares two Album structs, ignoring calculated fields
|
||||||
func (a Album) Equals(other Album) bool {
|
func (a Album) Equals(other Album) bool {
|
||||||
// Normalize float32 values to avoid false negatives
|
// Normalize float32 values to avoid false negatives
|
||||||
|
|||||||
@@ -3,30 +3,11 @@ package model_test
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
. "github.com/navidrome/navidrome/model"
|
. "github.com/navidrome/navidrome/model"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("Album", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
})
|
|
||||||
DescribeTable("FullName",
|
|
||||||
func(enabled bool, tags Tags, expected string) {
|
|
||||||
conf.Server.Subsonic.AppendAlbumVersion = enabled
|
|
||||||
a := Album{Name: "Album", Tags: tags}
|
|
||||||
Expect(a.FullName()).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album (Remastered)"),
|
|
||||||
Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"),
|
|
||||||
Entry("returns just name when tag is absent", true, Tags{}, "Album"),
|
|
||||||
Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("Albums", func() {
|
var _ = Describe("Albums", func() {
|
||||||
var albums Albums
|
var albums Albums
|
||||||
|
|
||||||
|
|||||||
@@ -95,25 +95,6 @@ func (c Criteria) ToSql() (sql string, args []any, err error) {
|
|||||||
return c.Expression.ToSql()
|
return c.Expression.ToSql()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequiredJoins inspects the expression tree and Sort field to determine which
|
|
||||||
// additional JOINs are needed when evaluating this criteria.
|
|
||||||
func (c Criteria) RequiredJoins() JoinType {
|
|
||||||
result := JoinNone
|
|
||||||
if c.Expression != nil {
|
|
||||||
result |= extractJoinTypes(c.Expression)
|
|
||||||
}
|
|
||||||
// Also check Sort fields
|
|
||||||
if c.Sort != "" {
|
|
||||||
for _, p := range strings.Split(c.Sort, ",") {
|
|
||||||
p = strings.TrimSpace(p)
|
|
||||||
p = strings.TrimLeft(p, "+-")
|
|
||||||
p = strings.TrimSpace(p)
|
|
||||||
result |= fieldJoinType(p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Criteria) ChildPlaylistIds() []string {
|
func (c Criteria) ChildPlaylistIds() []string {
|
||||||
if c.Expression == nil {
|
if c.Expression == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ var _ = Describe("Criteria", func() {
|
|||||||
StartsWith{"comment": "this"},
|
StartsWith{"comment": "this"},
|
||||||
InTheRange{"year": []int{1980, 1990}},
|
InTheRange{"year": []int{1980, 1990}},
|
||||||
IsNot{"genre": "Rock"},
|
IsNot{"genre": "Rock"},
|
||||||
Gt{"albumrating": 3},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Sort: "title",
|
Sort: "title",
|
||||||
@@ -49,8 +48,7 @@ var _ = Describe("Criteria", func() {
|
|||||||
{ "all": [
|
{ "all": [
|
||||||
{ "startsWith": {"comment": "this"} },
|
{ "startsWith": {"comment": "this"} },
|
||||||
{ "inTheRange": {"year":[1980,1990]} },
|
{ "inTheRange": {"year":[1980,1990]} },
|
||||||
{ "isNot": { "genre": "Rock" }},
|
{ "isNot": { "genre": "Rock" }}
|
||||||
{ "gt": { "albumrating": 3 } }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -70,10 +68,10 @@ var _ = Describe("Criteria", func() {
|
|||||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||||
gomega.Expect(sql).To(gomega.Equal(
|
gomega.Expect(sql).To(gomega.Equal(
|
||||||
`(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` +
|
`(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` +
|
||||||
`AND (not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) ` +
|
`AND (not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) ` +
|
||||||
`OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` +
|
`OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` +
|
||||||
`AND not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?) AND COALESCE(album_annotation.rating, 0) > ?))`))
|
`AND not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)))`))
|
||||||
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock", 3))
|
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock"))
|
||||||
})
|
})
|
||||||
It("marshals to JSON", func() {
|
It("marshals to JSON", func() {
|
||||||
j, err := json.Marshal(goObj)
|
j, err := json.Marshal(goObj)
|
||||||
@@ -174,95 +172,13 @@ var _ = Describe("Criteria", func() {
|
|||||||
sql, args, err := goObj.ToSql()
|
sql, args, err := goObj.ToSql()
|
||||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||||
gomega.Expect(sql).To(gomega.Equal(
|
gomega.Expect(sql).To(gomega.Equal(
|
||||||
`(exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) AND ` +
|
`(exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) AND ` +
|
||||||
`exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?))`,
|
`exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?))`,
|
||||||
))
|
))
|
||||||
gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%"))
|
gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("RequiredJoins", func() {
|
|
||||||
It("returns JoinNone when no annotation fields are used", func() {
|
|
||||||
c := Criteria{
|
|
||||||
Expression: All{
|
|
||||||
Contains{"title": "love"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone))
|
|
||||||
})
|
|
||||||
It("returns JoinNone for media_file annotation fields", func() {
|
|
||||||
c := Criteria{
|
|
||||||
Expression: All{
|
|
||||||
Is{"loved": true},
|
|
||||||
Gt{"playCount": 5},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone))
|
|
||||||
})
|
|
||||||
It("returns JoinAlbumAnnotation for album annotation fields", func() {
|
|
||||||
c := Criteria{
|
|
||||||
Expression: All{
|
|
||||||
Gt{"albumRating": 3},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinAlbumAnnotation))
|
|
||||||
})
|
|
||||||
It("returns JoinArtistAnnotation for artist annotation fields", func() {
|
|
||||||
c := Criteria{
|
|
||||||
Expression: All{
|
|
||||||
Is{"artistLoved": true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinArtistAnnotation))
|
|
||||||
})
|
|
||||||
It("returns both join types when both are used", func() {
|
|
||||||
c := Criteria{
|
|
||||||
Expression: All{
|
|
||||||
Gt{"albumRating": 3},
|
|
||||||
Is{"artistLoved": true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
j := c.RequiredJoins()
|
|
||||||
gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
|
|
||||||
gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue())
|
|
||||||
})
|
|
||||||
It("detects join types in nested expressions", func() {
|
|
||||||
c := Criteria{
|
|
||||||
Expression: All{
|
|
||||||
Any{
|
|
||||||
All{
|
|
||||||
Is{"albumLoved": true},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Any{
|
|
||||||
Gt{"artistPlayCount": 10},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
j := c.RequiredJoins()
|
|
||||||
gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
|
|
||||||
gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue())
|
|
||||||
})
|
|
||||||
It("detects join types from Sort field", func() {
|
|
||||||
c := Criteria{
|
|
||||||
Expression: All{
|
|
||||||
Contains{"title": "love"},
|
|
||||||
},
|
|
||||||
Sort: "albumRating",
|
|
||||||
}
|
|
||||||
gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
|
|
||||||
})
|
|
||||||
It("detects join types from Sort field with direction prefix", func() {
|
|
||||||
c := Criteria{
|
|
||||||
Expression: All{
|
|
||||||
Contains{"title": "love"},
|
|
||||||
},
|
|
||||||
Sort: "-artistRating",
|
|
||||||
}
|
|
||||||
gomega.Expect(c.RequiredJoins().Has(JoinArtistAnnotation)).To(gomega.BeTrue())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("with child playlists", func() {
|
Context("with child playlists", func() {
|
||||||
var (
|
var (
|
||||||
topLevelInPlaylistID string
|
topLevelInPlaylistID string
|
||||||
|
|||||||
@@ -9,18 +9,6 @@ import (
|
|||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JoinType is a bitmask indicating which additional JOINs are needed by a smart playlist expression.
|
|
||||||
type JoinType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
JoinNone JoinType = 0
|
|
||||||
JoinAlbumAnnotation JoinType = 1 << iota
|
|
||||||
JoinArtistAnnotation
|
|
||||||
)
|
|
||||||
|
|
||||||
// Has returns true if j contains all bits in other.
|
|
||||||
func (j JoinType) Has(other JoinType) bool { return j&other != 0 }
|
|
||||||
|
|
||||||
var fieldMap = map[string]*mappedField{
|
var fieldMap = map[string]*mappedField{
|
||||||
"title": {field: "media_file.title"},
|
"title": {field: "media_file.title"},
|
||||||
"album": {field: "media_file.album"},
|
"album": {field: "media_file.album"},
|
||||||
@@ -60,20 +48,6 @@ var fieldMap = map[string]*mappedField{
|
|||||||
"daterated": {field: "annotation.rated_at"},
|
"daterated": {field: "annotation.rated_at"},
|
||||||
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
|
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
|
||||||
"rating": {field: "COALESCE(annotation.rating, 0)"},
|
"rating": {field: "COALESCE(annotation.rating, 0)"},
|
||||||
"albumrating": {field: "COALESCE(album_annotation.rating, 0)", joinType: JoinAlbumAnnotation},
|
|
||||||
"albumloved": {field: "COALESCE(album_annotation.starred, false)", joinType: JoinAlbumAnnotation},
|
|
||||||
"albumplaycount": {field: "COALESCE(album_annotation.play_count, 0)", joinType: JoinAlbumAnnotation},
|
|
||||||
"albumlastplayed": {field: "album_annotation.play_date", joinType: JoinAlbumAnnotation},
|
|
||||||
"albumdateloved": {field: "album_annotation.starred_at", joinType: JoinAlbumAnnotation},
|
|
||||||
"albumdaterated": {field: "album_annotation.rated_at", joinType: JoinAlbumAnnotation},
|
|
||||||
|
|
||||||
"artistrating": {field: "COALESCE(artist_annotation.rating, 0)", joinType: JoinArtistAnnotation},
|
|
||||||
"artistloved": {field: "COALESCE(artist_annotation.starred, false)", joinType: JoinArtistAnnotation},
|
|
||||||
"artistplaycount": {field: "COALESCE(artist_annotation.play_count, 0)", joinType: JoinArtistAnnotation},
|
|
||||||
"artistlastplayed": {field: "artist_annotation.play_date", joinType: JoinArtistAnnotation},
|
|
||||||
"artistdateloved": {field: "artist_annotation.starred_at", joinType: JoinArtistAnnotation},
|
|
||||||
"artistdaterated": {field: "artist_annotation.rated_at", joinType: JoinArtistAnnotation},
|
|
||||||
|
|
||||||
"mbz_album_id": {field: "media_file.mbz_album_id"},
|
"mbz_album_id": {field: "media_file.mbz_album_id"},
|
||||||
"mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"},
|
"mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"},
|
||||||
"mbz_artist_id": {field: "media_file.mbz_artist_id"},
|
"mbz_artist_id": {field: "media_file.mbz_artist_id"},
|
||||||
@@ -97,7 +71,6 @@ type mappedField struct {
|
|||||||
isTag bool // true if the field is a tag imported from the file metadata
|
isTag bool // true if the field is a tag imported from the file metadata
|
||||||
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
|
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
|
||||||
numeric bool // true if the field/tag should be treated as numeric
|
numeric bool // true if the field/tag should be treated as numeric
|
||||||
joinType JoinType // which additional JOINs this field requires
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapFields(expr map[string]any) map[string]any {
|
func mapFields(expr map[string]any) map[string]any {
|
||||||
@@ -196,7 +169,7 @@ func (e tagCond) ToSql() (string, []any, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)",
|
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
|
||||||
tagName, cond)
|
tagName, cond)
|
||||||
if e.not {
|
if e.not {
|
||||||
cond = "not " + cond
|
cond = "not " + cond
|
||||||
@@ -216,7 +189,7 @@ type roleCond struct {
|
|||||||
|
|
||||||
func (e roleCond) ToSql() (string, []any, error) {
|
func (e roleCond) ToSql() (string, []any, error) {
|
||||||
cond, args, err := e.cond.ToSql()
|
cond, args, err := e.cond.ToSql()
|
||||||
cond = fmt.Sprintf(`exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)`,
|
cond = fmt.Sprintf(`exists (select 1 from json_tree(participants, '$.%s') where key='name' and %s)`,
|
||||||
e.role, cond)
|
e.role, cond)
|
||||||
if e.not {
|
if e.not {
|
||||||
cond = "not " + cond
|
cond = "not " + cond
|
||||||
@@ -224,38 +197,6 @@ func (e roleCond) ToSql() (string, []any, error) {
|
|||||||
return cond, args, err
|
return cond, args, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// fieldJoinType returns the JoinType for a given field name (case-insensitive).
|
|
||||||
func fieldJoinType(name string) JoinType {
|
|
||||||
if f, ok := fieldMap[strings.ToLower(name)]; ok {
|
|
||||||
return f.joinType
|
|
||||||
}
|
|
||||||
return JoinNone
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractJoinTypes walks an expression tree and collects all required JoinType flags.
|
|
||||||
func extractJoinTypes(expr any) JoinType {
|
|
||||||
result := JoinNone
|
|
||||||
switch e := expr.(type) {
|
|
||||||
case All:
|
|
||||||
for _, sub := range e {
|
|
||||||
result |= extractJoinTypes(sub)
|
|
||||||
}
|
|
||||||
case Any:
|
|
||||||
for _, sub := range e {
|
|
||||||
result |= extractJoinTypes(sub)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Leaf expression: use reflection to check if it's a map with field names
|
|
||||||
rv := reflect.ValueOf(expr)
|
|
||||||
if rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String {
|
|
||||||
for _, key := range rv.MapKeys() {
|
|
||||||
result |= fieldJoinType(key.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in
|
// AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in
|
||||||
// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent.
|
// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent.
|
||||||
func AddRoles(roles []string) {
|
func AddRoles(roles []string) {
|
||||||
|
|||||||
@@ -54,43 +54,23 @@ var _ = Describe("Operators", func() {
|
|||||||
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())),
|
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())),
|
||||||
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
|
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
|
||||||
|
|
||||||
// Album annotation fields
|
|
||||||
Entry("albumRating", Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3),
|
|
||||||
Entry("albumLoved", Is{"albumLoved": true}, "COALESCE(album_annotation.starred, false) = ?", true),
|
|
||||||
Entry("albumPlayCount", Gt{"albumPlayCount": 5}, "COALESCE(album_annotation.play_count, 0) > ?", 5),
|
|
||||||
Entry("albumLastPlayed", After{"albumLastPlayed": rangeStart}, "album_annotation.play_date > ?", rangeStart),
|
|
||||||
Entry("albumDateLoved", Before{"albumDateLoved": rangeStart}, "album_annotation.starred_at < ?", rangeStart),
|
|
||||||
Entry("albumDateRated", After{"albumDateRated": rangeStart}, "album_annotation.rated_at > ?", rangeStart),
|
|
||||||
Entry("albumLastPlayed inTheLast", InTheLast{"albumLastPlayed": 30}, "album_annotation.play_date > ?", StartOfPeriod(30, time.Now())),
|
|
||||||
Entry("albumLastPlayed notInTheLast", NotInTheLast{"albumLastPlayed": 30}, "(album_annotation.play_date < ? OR album_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
|
|
||||||
|
|
||||||
// Artist annotation fields
|
|
||||||
Entry("artistRating", Gt{"artistRating": 3}, "COALESCE(artist_annotation.rating, 0) > ?", 3),
|
|
||||||
Entry("artistLoved", Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true),
|
|
||||||
Entry("artistPlayCount", Gt{"artistPlayCount": 5}, "COALESCE(artist_annotation.play_count, 0) > ?", 5),
|
|
||||||
Entry("artistLastPlayed", After{"artistLastPlayed": rangeStart}, "artist_annotation.play_date > ?", rangeStart),
|
|
||||||
Entry("artistDateLoved", Before{"artistDateLoved": rangeStart}, "artist_annotation.starred_at < ?", rangeStart),
|
|
||||||
Entry("artistDateRated", After{"artistDateRated": rangeStart}, "artist_annotation.rated_at > ?", rangeStart),
|
|
||||||
Entry("artistLastPlayed inTheLast", InTheLast{"artistLastPlayed": 30}, "artist_annotation.play_date > ?", StartOfPeriod(30, time.Now())),
|
|
||||||
Entry("artistLastPlayed notInTheLast", NotInTheLast{"artistLastPlayed": 30}, "(artist_annotation.play_date < ? OR artist_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
|
|
||||||
|
|
||||||
// Tag tests
|
// Tag tests
|
||||||
Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
||||||
Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
||||||
Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value > ?)", "A"),
|
Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value > ?)", "A"),
|
||||||
Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value < ?)", "Z"),
|
Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value < ?)", "Z"),
|
||||||
Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
||||||
Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
||||||
Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"),
|
Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"),
|
||||||
Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"),
|
Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"),
|
||||||
|
|
||||||
// Artist roles tests
|
// Artist roles tests
|
||||||
Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
|
Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
|
||||||
Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
|
Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
|
||||||
Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
|
Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
|
||||||
Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
|
Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
|
||||||
Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "John%"),
|
Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "John%"),
|
||||||
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
|
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO Validate operators that are not valid for each field type.
|
// TODO Validate operators that are not valid for each field type.
|
||||||
@@ -108,7 +88,7 @@ var _ = Describe("Operators", func() {
|
|||||||
op := EndsWith{"mood": "Soft"}
|
op := EndsWith{"mood": "Soft"}
|
||||||
sql, args, err := op.ToSql()
|
sql, args, err := op.ToSql()
|
||||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.mood') where key='value' and value LIKE ?)"))
|
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)"))
|
||||||
gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
|
gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
|
||||||
})
|
})
|
||||||
It("casts numeric comparisons", func() {
|
It("casts numeric comparisons", func() {
|
||||||
@@ -116,7 +96,7 @@ var _ = Describe("Operators", func() {
|
|||||||
op := Lt{"rate": 6}
|
op := Lt{"rate": 6}
|
||||||
sql, args, err := op.ToSql()
|
sql, args, err := op.ToSql()
|
||||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
|
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
|
||||||
gomega.Expect(args).To(gomega.HaveExactElements(6))
|
gomega.Expect(args).To(gomega.HaveExactElements(6))
|
||||||
})
|
})
|
||||||
It("skips unknown tag names", func() {
|
It("skips unknown tag names", func() {
|
||||||
@@ -130,7 +110,7 @@ var _ = Describe("Operators", func() {
|
|||||||
op := Contains{"releasetype": "soundtrack"}
|
op := Contains{"releasetype": "soundtrack"}
|
||||||
sql, args, err := op.ToSql()
|
sql, args, err := op.ToSql()
|
||||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)"))
|
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
|
||||||
gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%"))
|
gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%"))
|
||||||
})
|
})
|
||||||
It("supports albumtype as alias for releasetype", func() {
|
It("supports albumtype as alias for releasetype", func() {
|
||||||
@@ -138,7 +118,7 @@ var _ = Describe("Operators", func() {
|
|||||||
op := Contains{"albumtype": "live"}
|
op := Contains{"albumtype": "live"}
|
||||||
sql, args, err := op.ToSql()
|
sql, args, err := op.ToSql()
|
||||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)"))
|
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
|
||||||
gomega.Expect(args).To(gomega.HaveExactElements("%live%"))
|
gomega.Expect(args).To(gomega.HaveExactElements("%live%"))
|
||||||
})
|
})
|
||||||
It("supports albumtype alias with Is operator", func() {
|
It("supports albumtype alias with Is operator", func() {
|
||||||
@@ -147,7 +127,7 @@ var _ = Describe("Operators", func() {
|
|||||||
sql, args, err := op.ToSql()
|
sql, args, err := op.ToSql()
|
||||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||||
// Should query $.releasetype, not $.albumtype
|
// Should query $.releasetype, not $.albumtype
|
||||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)"))
|
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
|
||||||
gomega.Expect(args).To(gomega.HaveExactElements("album"))
|
gomega.Expect(args).To(gomega.HaveExactElements("album"))
|
||||||
})
|
})
|
||||||
It("supports albumtype alias with IsNot operator", func() {
|
It("supports albumtype alias with IsNot operator", func() {
|
||||||
@@ -156,7 +136,7 @@ var _ = Describe("Operators", func() {
|
|||||||
sql, args, err := op.ToSql()
|
sql, args, err := op.ToSql()
|
||||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||||
// Should query $.releasetype, not $.albumtype
|
// Should query $.releasetype, not $.albumtype
|
||||||
gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)"))
|
gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
|
||||||
gomega.Expect(args).To(gomega.HaveExactElements("compilation"))
|
gomega.Expect(args).To(gomega.HaveExactElements("compilation"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -167,7 +147,7 @@ var _ = Describe("Operators", func() {
|
|||||||
op := EndsWith{"producer": "Eno"}
|
op := EndsWith{"producer": "Eno"}
|
||||||
sql, args, err := op.ToSql()
|
sql, args, err := op.ToSql()
|
||||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.participants, '$.producer') where key='name' and value LIKE ?)"))
|
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(participants, '$.producer') where key='name' and value LIKE ?)"))
|
||||||
gomega.Expect(args).To(gomega.HaveExactElements("%Eno"))
|
gomega.Expect(args).To(gomega.HaveExactElements("%Eno"))
|
||||||
})
|
})
|
||||||
It("skips unknown roles", func() {
|
It("skips unknown roles", func() {
|
||||||
|
|||||||
@@ -95,19 +95,12 @@ type MediaFile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (mf MediaFile) FullTitle() string {
|
func (mf MediaFile) FullTitle() string {
|
||||||
if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 {
|
if conf.Server.Subsonic.AppendSubtitle && mf.Tags[TagSubtitle] != nil {
|
||||||
return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0])
|
return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0])
|
||||||
}
|
}
|
||||||
return mf.Title
|
return mf.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mf MediaFile) FullAlbumName() string {
|
|
||||||
if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 {
|
|
||||||
return fmt.Sprintf("%s (%s)", mf.Album, mf.Tags[TagAlbumVersion][0])
|
|
||||||
}
|
|
||||||
return mf.Album
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mf MediaFile) ContentType() string {
|
func (mf MediaFile) ContentType() string {
|
||||||
return mime.TypeByExtension("." + mf.Suffix)
|
return mime.TypeByExtension("." + mf.Suffix)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -475,29 +475,7 @@ var _ = Describe("MediaFile", func() {
|
|||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
conf.Server.EnableMediaFileCoverArt = true
|
conf.Server.EnableMediaFileCoverArt = true
|
||||||
})
|
})
|
||||||
DescribeTable("FullTitle",
|
Describe(".CoverArtId()", func() {
|
||||||
func(enabled bool, tags Tags, expected string) {
|
|
||||||
conf.Server.Subsonic.AppendSubtitle = enabled
|
|
||||||
mf := MediaFile{Title: "Song", Tags: tags}
|
|
||||||
Expect(mf.FullTitle()).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("appends subtitle when enabled and tag is present", true, Tags{TagSubtitle: []string{"Live"}}, "Song (Live)"),
|
|
||||||
Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"),
|
|
||||||
Entry("returns just title when tag is absent", true, Tags{}, "Song"),
|
|
||||||
Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"),
|
|
||||||
)
|
|
||||||
DescribeTable("FullAlbumName",
|
|
||||||
func(enabled bool, tags Tags, expected string) {
|
|
||||||
conf.Server.Subsonic.AppendAlbumVersion = enabled
|
|
||||||
mf := MediaFile{Album: "Album", Tags: tags}
|
|
||||||
Expect(mf.FullAlbumName()).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album (Deluxe Edition)"),
|
|
||||||
Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"),
|
|
||||||
Entry("returns just album name when tag is absent", true, Tags{}, "Album"),
|
|
||||||
Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
|
|
||||||
)
|
|
||||||
Describe("CoverArtId()", func() {
|
|
||||||
It("returns its own id if it HasCoverArt", func() {
|
It("returns its own id if it HasCoverArt", func() {
|
||||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
||||||
id := mf.CoverArtID()
|
id := mf.CoverArtID()
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ type Plugin struct {
|
|||||||
AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
|
AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
|
||||||
Libraries string `structs:"libraries" json:"libraries,omitempty"`
|
Libraries string `structs:"libraries" json:"libraries,omitempty"`
|
||||||
AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
|
AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
|
||||||
AllowWriteAccess bool `structs:"allow_write_access" json:"allowWriteAccess,omitempty"`
|
|
||||||
Enabled bool `structs:"enabled" json:"enabled"`
|
Enabled bool `structs:"enabled" json:"enabled"`
|
||||||
LastError string `structs:"last_error" json:"lastError,omitempty"`
|
LastError string `structs:"last_error" json:"lastError,omitempty"`
|
||||||
SHA256 string `structs:"sha256" json:"sha256"`
|
SHA256 string `structs:"sha256" json:"sha256"`
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
type SearchableRepository[T any] interface {
|
type SearchableRepository[T any] interface {
|
||||||
Search(q string, options ...QueryOptions) (T, error)
|
Search(q string, offset, size int, options ...QueryOptions) (T, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
. "github.com/Masterminds/squirrel"
|
. "github.com/Masterminds/squirrel"
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
@@ -61,14 +62,11 @@ func (a *dbAlbum) PostScan() error {
|
|||||||
|
|
||||||
func (a *dbAlbum) PostMapArgs(args map[string]any) error {
|
func (a *dbAlbum) PostMapArgs(args map[string]any) error {
|
||||||
fullText := []string{a.Name, a.SortAlbumName, a.AlbumArtist}
|
fullText := []string{a.Name, a.SortAlbumName, a.AlbumArtist}
|
||||||
participantNames := a.Album.Participants.AllNames()
|
fullText = append(fullText, a.Album.Participants.AllNames()...)
|
||||||
fullText = append(fullText, participantNames...)
|
|
||||||
fullText = append(fullText, slices.Collect(maps.Values(a.Album.Discs))...)
|
fullText = append(fullText, slices.Collect(maps.Values(a.Album.Discs))...)
|
||||||
fullText = append(fullText, a.Album.Tags[model.TagAlbumVersion]...)
|
fullText = append(fullText, a.Album.Tags[model.TagAlbumVersion]...)
|
||||||
fullText = append(fullText, a.Album.Tags[model.TagCatalogNumber]...)
|
fullText = append(fullText, a.Album.Tags[model.TagCatalogNumber]...)
|
||||||
args["full_text"] = formatFullText(fullText...)
|
args["full_text"] = formatFullText(fullText...)
|
||||||
args["search_participants"] = strings.Join(participantNames, " ")
|
|
||||||
args["search_normalized"] = normalizeForFTS(a.Name, a.AlbumArtist)
|
|
||||||
|
|
||||||
args["tags"] = marshalTags(a.Album.Tags)
|
args["tags"] = marshalTags(a.Album.Tags)
|
||||||
args["participants"] = marshalParticipants(a.Album.Participants)
|
args["participants"] = marshalParticipants(a.Album.Participants)
|
||||||
@@ -352,21 +350,18 @@ func (r *albumRepository) purgeEmpty(libraryIDs ...int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var albumSearchConfig = searchConfig{
|
func (r *albumRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) {
|
||||||
NaturalOrder: "album.rowid",
|
|
||||||
OrderBy: []string{"name"},
|
|
||||||
MBIDFields: []string{"mbz_album_id", "mbz_release_group_id"},
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *albumRepository) Search(q string, options ...model.QueryOptions) (model.Albums, error) {
|
|
||||||
var opts model.QueryOptions
|
|
||||||
if len(options) > 0 {
|
|
||||||
opts = options[0]
|
|
||||||
}
|
|
||||||
var res dbAlbums
|
var res dbAlbums
|
||||||
err := r.doSearch(r.selectAlbum(options...), q, &res, albumSearchConfig, opts)
|
if uuid.Validate(q) == nil {
|
||||||
|
err := r.searchByMBID(r.selectAlbum(options...), q, []string{"mbz_album_id", "mbz_release_group_id"}, &res)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("searching album %q: %w", q, err)
|
return nil, fmt.Errorf("searching album by MBID %q: %w", q, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err := r.doSearch(r.selectAlbum(options...), q, offset, size, &res, "album.rowid", "name")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("searching album by query %q: %w", q, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return res.toModels(), nil
|
return res.toModels(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,23 +56,17 @@ var _ = Describe("AlbumRepository", func() {
|
|||||||
It("returns all records sorted", func() {
|
It("returns all records sorted", func() {
|
||||||
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
|
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
|
||||||
albumAbbeyRoad,
|
albumAbbeyRoad,
|
||||||
albumWithVersion,
|
|
||||||
albumCJK,
|
|
||||||
albumMultiDisc,
|
albumMultiDisc,
|
||||||
albumRadioactivity,
|
albumRadioactivity,
|
||||||
albumSgtPeppers,
|
albumSgtPeppers,
|
||||||
albumPunctuation,
|
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns all records sorted desc", func() {
|
It("returns all records sorted desc", func() {
|
||||||
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
|
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
|
||||||
albumPunctuation,
|
|
||||||
albumSgtPeppers,
|
albumSgtPeppers,
|
||||||
albumRadioactivity,
|
albumRadioactivity,
|
||||||
albumMultiDisc,
|
albumMultiDisc,
|
||||||
albumCJK,
|
|
||||||
albumWithVersion,
|
|
||||||
albumAbbeyRoad,
|
albumAbbeyRoad,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
. "github.com/Masterminds/squirrel"
|
. "github.com/Masterminds/squirrel"
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
@@ -101,7 +102,6 @@ func (a *dbArtist) PostMapArgs(m map[string]any) error {
|
|||||||
similarArtists, _ := json.Marshal(sa)
|
similarArtists, _ := json.Marshal(sa)
|
||||||
m["similar_artists"] = string(similarArtists)
|
m["similar_artists"] = string(similarArtists)
|
||||||
m["full_text"] = formatFullText(a.Name, a.SortArtistName)
|
m["full_text"] = formatFullText(a.Name, a.SortArtistName)
|
||||||
m["search_normalized"] = normalizeForFTS(a.Name)
|
|
||||||
|
|
||||||
// Do not override the sort_artist_name and mbz_artist_id fields if they are empty
|
// Do not override the sort_artist_name and mbz_artist_id fields if they are empty
|
||||||
// TODO: Better way to handle this?
|
// TODO: Better way to handle this?
|
||||||
@@ -134,7 +134,6 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
|||||||
"id": idFilter(r.tableName),
|
"id": idFilter(r.tableName),
|
||||||
"name": fullTextFilter(r.tableName, "mbz_artist_id"),
|
"name": fullTextFilter(r.tableName, "mbz_artist_id"),
|
||||||
"starred": annotationBoolFilter("starred"),
|
"starred": annotationBoolFilter("starred"),
|
||||||
"has_rating": annotationBoolFilter("rating"),
|
|
||||||
"role": roleFilter,
|
"role": roleFilter,
|
||||||
"missing": booleanFilter,
|
"missing": booleanFilter,
|
||||||
"library_id": artistLibraryIdFilter,
|
"library_id": artistLibraryIdFilter,
|
||||||
@@ -513,25 +512,20 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
|
|||||||
return totalRowsAffected, nil
|
return totalRowsAffected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *artistRepository) searchCfg() searchConfig {
|
func (r *artistRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) {
|
||||||
return searchConfig{
|
|
||||||
// Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist
|
|
||||||
NaturalOrder: "artist.id",
|
|
||||||
OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"},
|
|
||||||
MBIDFields: []string{"mbz_artist_id"},
|
|
||||||
LibraryFilter: r.applyLibraryFilterToArtistQuery,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *artistRepository) Search(q string, options ...model.QueryOptions) (model.Artists, error) {
|
|
||||||
var opts model.QueryOptions
|
|
||||||
if len(options) > 0 {
|
|
||||||
opts = options[0]
|
|
||||||
}
|
|
||||||
var res dbArtists
|
var res dbArtists
|
||||||
err := r.doSearch(r.selectArtist(options...), q, &res, r.searchCfg(), opts)
|
if uuid.Validate(q) == nil {
|
||||||
|
err := r.searchByMBID(r.selectArtist(options...), q, []string{"mbz_artist_id"}, &res)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("searching artist %q: %w", q, err)
|
return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist
|
||||||
|
err := r.doSearch(r.selectArtist(options...), q, offset, size, &res, "artist.id",
|
||||||
|
"sum(json_extract(stats, '$.total.m')) desc", "name")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("searching artist by query %q: %w", q, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return res.toModels(), nil
|
return res.toModels(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
Describe("Basic Operations", func() {
|
Describe("Basic Operations", func() {
|
||||||
Describe("Count", func() {
|
Describe("Count", func() {
|
||||||
It("returns the number of artists in the DB", func() {
|
It("returns the number of artists in the DB", func() {
|
||||||
Expect(repo.CountAll()).To(Equal(int64(4)))
|
Expect(repo.CountAll()).To(Equal(int64(2)))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -228,19 +228,13 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
|
|
||||||
idx, err := repo.GetIndex(false, []int{1})
|
idx, err := repo.GetIndex(false, []int{1})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(idx).To(HaveLen(4))
|
Expect(idx).To(HaveLen(2))
|
||||||
Expect(idx[0].ID).To(Equal("F"))
|
Expect(idx[0].ID).To(Equal("F"))
|
||||||
Expect(idx[0].Artists).To(HaveLen(1))
|
Expect(idx[0].Artists).To(HaveLen(1))
|
||||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||||
Expect(idx[1].ID).To(Equal("K"))
|
Expect(idx[1].ID).To(Equal("K"))
|
||||||
Expect(idx[1].Artists).To(HaveLen(1))
|
Expect(idx[1].Artists).To(HaveLen(1))
|
||||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||||
Expect(idx[2].ID).To(Equal("R"))
|
|
||||||
Expect(idx[2].Artists).To(HaveLen(1))
|
|
||||||
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
|
|
||||||
Expect(idx[3].ID).To(Equal("S"))
|
|
||||||
Expect(idx[3].Artists).To(HaveLen(1))
|
|
||||||
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
|
|
||||||
|
|
||||||
// Restore the original value
|
// Restore the original value
|
||||||
artistBeatles.SortArtistName = ""
|
artistBeatles.SortArtistName = ""
|
||||||
@@ -252,19 +246,13 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
|
XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
|
||||||
idx, err := repo.GetIndex(false, []int{1})
|
idx, err := repo.GetIndex(false, []int{1})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(idx).To(HaveLen(4))
|
Expect(idx).To(HaveLen(2))
|
||||||
Expect(idx[0].ID).To(Equal("B"))
|
Expect(idx[0].ID).To(Equal("B"))
|
||||||
Expect(idx[0].Artists).To(HaveLen(1))
|
Expect(idx[0].Artists).To(HaveLen(1))
|
||||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||||
Expect(idx[1].ID).To(Equal("K"))
|
Expect(idx[1].ID).To(Equal("K"))
|
||||||
Expect(idx[1].Artists).To(HaveLen(1))
|
Expect(idx[1].Artists).To(HaveLen(1))
|
||||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||||
Expect(idx[2].ID).To(Equal("R"))
|
|
||||||
Expect(idx[2].Artists).To(HaveLen(1))
|
|
||||||
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
|
|
||||||
Expect(idx[3].ID).To(Equal("S"))
|
|
||||||
Expect(idx[3].Artists).To(HaveLen(1))
|
|
||||||
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -280,19 +268,13 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
|
|
||||||
idx, err := repo.GetIndex(false, []int{1})
|
idx, err := repo.GetIndex(false, []int{1})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(idx).To(HaveLen(4))
|
Expect(idx).To(HaveLen(2))
|
||||||
Expect(idx[0].ID).To(Equal("B"))
|
Expect(idx[0].ID).To(Equal("B"))
|
||||||
Expect(idx[0].Artists).To(HaveLen(1))
|
Expect(idx[0].Artists).To(HaveLen(1))
|
||||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||||
Expect(idx[1].ID).To(Equal("K"))
|
Expect(idx[1].ID).To(Equal("K"))
|
||||||
Expect(idx[1].Artists).To(HaveLen(1))
|
Expect(idx[1].Artists).To(HaveLen(1))
|
||||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||||
Expect(idx[2].ID).To(Equal("R"))
|
|
||||||
Expect(idx[2].Artists).To(HaveLen(1))
|
|
||||||
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
|
|
||||||
Expect(idx[3].ID).To(Equal("S"))
|
|
||||||
Expect(idx[3].Artists).To(HaveLen(1))
|
|
||||||
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
|
|
||||||
|
|
||||||
// Restore the original value
|
// Restore the original value
|
||||||
artistBeatles.SortArtistName = ""
|
artistBeatles.SortArtistName = ""
|
||||||
@@ -303,19 +285,13 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
It("returns the index when SortArtistName is empty", func() {
|
It("returns the index when SortArtistName is empty", func() {
|
||||||
idx, err := repo.GetIndex(false, []int{1})
|
idx, err := repo.GetIndex(false, []int{1})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(idx).To(HaveLen(4))
|
Expect(idx).To(HaveLen(2))
|
||||||
Expect(idx[0].ID).To(Equal("B"))
|
Expect(idx[0].ID).To(Equal("B"))
|
||||||
Expect(idx[0].Artists).To(HaveLen(1))
|
Expect(idx[0].Artists).To(HaveLen(1))
|
||||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||||
Expect(idx[1].ID).To(Equal("K"))
|
Expect(idx[1].ID).To(Equal("K"))
|
||||||
Expect(idx[1].Artists).To(HaveLen(1))
|
Expect(idx[1].Artists).To(HaveLen(1))
|
||||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||||
Expect(idx[2].ID).To(Equal("R"))
|
|
||||||
Expect(idx[2].Artists).To(HaveLen(1))
|
|
||||||
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
|
|
||||||
Expect(idx[3].ID).To(Equal("S"))
|
|
||||||
Expect(idx[3].Artists).To(HaveLen(1))
|
|
||||||
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -401,7 +377,7 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
// Admin users can see all content when valid library IDs are provided
|
// Admin users can see all content when valid library IDs are provided
|
||||||
idx, err := repo.GetIndex(false, []int{1})
|
idx, err := repo.GetIndex(false, []int{1})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(idx).To(HaveLen(4))
|
Expect(idx).To(HaveLen(2))
|
||||||
|
|
||||||
// With non-existent library ID, admin users see no content because no artists are associated with that library
|
// With non-existent library ID, admin users see no content because no artists are associated with that library
|
||||||
idx, err = repo.GetIndex(false, []int{999})
|
idx, err = repo.GetIndex(false, []int{999})
|
||||||
@@ -512,7 +488,7 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
// Test the search
|
// Test the search
|
||||||
results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", model.QueryOptions{Max: 10})
|
results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
if shouldFind {
|
if shouldFind {
|
||||||
@@ -543,12 +519,12 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
// Restricted user should not find this artist
|
// Restricted user should not find this artist
|
||||||
results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10})
|
results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(results).To(BeEmpty())
|
Expect(results).To(BeEmpty())
|
||||||
|
|
||||||
// But admin should find it
|
// But admin should find it
|
||||||
results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10})
|
results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(results).To(HaveLen(1))
|
Expect(results).To(HaveLen(1))
|
||||||
|
|
||||||
@@ -560,7 +536,7 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
|
|
||||||
Context("Text Search", func() {
|
Context("Text Search", func() {
|
||||||
It("allows admin to find artists by name regardless of library", func() {
|
It("allows admin to find artists by name regardless of library", func() {
|
||||||
results, err := repo.Search("Beatles", model.QueryOptions{Max: 10})
|
results, err := repo.Search("Beatles", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(results).To(HaveLen(1))
|
Expect(results).To(HaveLen(1))
|
||||||
Expect(results[0].Name).To(Equal("The Beatles"))
|
Expect(results[0].Name).To(Equal("The Beatles"))
|
||||||
@@ -580,7 +556,7 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
// Restricted user should not find this artist
|
// Restricted user should not find this artist
|
||||||
results, err := restrictedRepo.Search("Unique Search Name", model.QueryOptions{Max: 10})
|
results, err := restrictedRepo.Search("Unique Search Name", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(results).To(BeEmpty(), "Text search should respect library filtering")
|
Expect(results).To(BeEmpty(), "Text search should respect library filtering")
|
||||||
|
|
||||||
@@ -649,11 +625,11 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
It("sees all artists regardless of library permissions", func() {
|
It("sees all artists regardless of library permissions", func() {
|
||||||
count, err := repo.CountAll()
|
count, err := repo.CountAll()
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(count).To(Equal(int64(4)))
|
Expect(count).To(Equal(int64(2)))
|
||||||
|
|
||||||
artists, err := repo.GetAll()
|
artists, err := repo.GetAll()
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(artists).To(HaveLen(4))
|
Expect(artists).To(HaveLen(2))
|
||||||
|
|
||||||
exists, err := repo.Exists(artistBeatles.ID)
|
exists, err := repo.Exists(artistBeatles.ID)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
@@ -685,10 +661,10 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
// Should see missing artist in GetAll by default for admin users
|
// Should see missing artist in GetAll by default for admin users
|
||||||
artists, err := repo.GetAll()
|
artists, err := repo.GetAll()
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(artists).To(HaveLen(5)) // Including the missing artist
|
Expect(artists).To(HaveLen(3)) // Including the missing artist
|
||||||
|
|
||||||
// Search never returns missing artists (hardcoded behavior)
|
// Search never returns missing artists (hardcoded behavior)
|
||||||
results, err := repo.Search("Missing Artist", model.QueryOptions{Max: 10})
|
results, err := repo.Search("Missing Artist", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(results).To(BeEmpty())
|
Expect(results).To(BeEmpty())
|
||||||
})
|
})
|
||||||
@@ -742,11 +718,11 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("Search returns empty results for users without library access", func() {
|
It("Search returns empty results for users without library access", func() {
|
||||||
results, err := restrictedRepo.Search("Beatles", model.QueryOptions{Max: 10})
|
results, err := restrictedRepo.Search("Beatles", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(results).To(BeEmpty())
|
Expect(results).To(BeEmpty())
|
||||||
|
|
||||||
results, err = restrictedRepo.Search("Kraftwerk", model.QueryOptions{Max: 10})
|
results, err = restrictedRepo.Search("Kraftwerk", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(results).To(BeEmpty())
|
Expect(results).To(BeEmpty())
|
||||||
})
|
})
|
||||||
@@ -791,19 +767,19 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
It("CountAll returns correct count after gaining access", func() {
|
It("CountAll returns correct count after gaining access", func() {
|
||||||
count, err := restrictedRepo.CountAll()
|
count, err := restrictedRepo.CountAll()
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(count).To(Equal(int64(4))) // Beatles, Kraftwerk, Seatbelts, and The Roots
|
Expect(count).To(Equal(int64(2))) // Beatles and Kraftwerk
|
||||||
})
|
})
|
||||||
|
|
||||||
It("GetAll returns artists after gaining access", func() {
|
It("GetAll returns artists after gaining access", func() {
|
||||||
artists, err := restrictedRepo.GetAll()
|
artists, err := restrictedRepo.GetAll()
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(artists).To(HaveLen(4))
|
Expect(artists).To(HaveLen(2))
|
||||||
|
|
||||||
var names []string
|
var names []string
|
||||||
for _, artist := range artists {
|
for _, artist := range artists {
|
||||||
names = append(names, artist.Name)
|
names = append(names, artist.Name)
|
||||||
}
|
}
|
||||||
Expect(names).To(ContainElements("The Beatles", "Kraftwerk", "シートベルツ", "The Roots"))
|
Expect(names).To(ContainElements("The Beatles", "Kraftwerk"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("Exists returns true for accessible artists", func() {
|
It("Exists returns true for accessible artists", func() {
|
||||||
@@ -820,7 +796,7 @@ var _ = Describe("ArtistRepository", func() {
|
|||||||
// With valid library access, should see artists
|
// With valid library access, should see artists
|
||||||
idx, err := restrictedRepo.GetIndex(false, []int{1})
|
idx, err := restrictedRepo.GetIndex(false, []int{1})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(idx).To(HaveLen(4))
|
Expect(idx).To(HaveLen(2))
|
||||||
|
|
||||||
// With non-existent library ID, should see nothing (non-admin user)
|
// With non-existent library ID, should see nothing (non-admin user)
|
||||||
idx, err = restrictedRepo.GetIndex(false, []int{999})
|
idx, err = restrictedRepo.GetIndex(false, []int{999})
|
||||||
|
|||||||
@@ -17,45 +17,45 @@ import (
|
|||||||
var _ = Describe("Collation", func() {
|
var _ = Describe("Collation", func() {
|
||||||
conn := db.Db()
|
conn := db.Db()
|
||||||
DescribeTable("Column collation",
|
DescribeTable("Column collation",
|
||||||
func(table, column string) {
|
func(table, column, expectedCollation string) {
|
||||||
Expect(checkCollation(conn, table, column)).To(Succeed())
|
Expect(checkCollation(conn, table, column, expectedCollation)).To(Succeed())
|
||||||
},
|
},
|
||||||
Entry("artist.order_artist_name", "artist", "order_artist_name"),
|
Entry("artist.order_artist_name", "artist", "order_artist_name", "NATURALSORT"),
|
||||||
Entry("artist.sort_artist_name", "artist", "sort_artist_name"),
|
Entry("artist.sort_artist_name", "artist", "sort_artist_name", "NATURALSORT"),
|
||||||
Entry("album.order_album_name", "album", "order_album_name"),
|
Entry("album.order_album_name", "album", "order_album_name", "NATURALSORT"),
|
||||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name"),
|
Entry("album.order_album_artist_name", "album", "order_album_artist_name", "NATURALSORT"),
|
||||||
Entry("album.sort_album_name", "album", "sort_album_name"),
|
Entry("album.sort_album_name", "album", "sort_album_name", "NATURALSORT"),
|
||||||
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name"),
|
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name", "NATURALSORT"),
|
||||||
Entry("media_file.order_title", "media_file", "order_title"),
|
Entry("media_file.order_title", "media_file", "order_title", "NATURALSORT"),
|
||||||
Entry("media_file.order_album_name", "media_file", "order_album_name"),
|
Entry("media_file.order_album_name", "media_file", "order_album_name", "NATURALSORT"),
|
||||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name"),
|
Entry("media_file.order_artist_name", "media_file", "order_artist_name", "NATURALSORT"),
|
||||||
Entry("media_file.sort_title", "media_file", "sort_title"),
|
Entry("media_file.sort_title", "media_file", "sort_title", "NATURALSORT"),
|
||||||
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
|
Entry("media_file.sort_album_name", "media_file", "sort_album_name", "NATURALSORT"),
|
||||||
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
|
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name", "NATURALSORT"),
|
||||||
Entry("playlist.name", "playlist", "name"),
|
Entry("playlist.name", "playlist", "name", "NATURALSORT"),
|
||||||
Entry("radio.name", "radio", "name"),
|
Entry("radio.name", "radio", "name", "NATURALSORT"),
|
||||||
Entry("user.name", "user", "name"),
|
Entry("user.name", "user", "name", "NOCASE"),
|
||||||
)
|
)
|
||||||
|
|
||||||
DescribeTable("Index collation",
|
DescribeTable("Index collation",
|
||||||
func(table, column string) {
|
func(table, column string) {
|
||||||
Expect(checkIndexUsage(conn, table, column)).To(Succeed())
|
Expect(checkIndexUsage(conn, table, column)).To(Succeed())
|
||||||
},
|
},
|
||||||
Entry("artist.order_artist_name", "artist", "order_artist_name collate nocase"),
|
Entry("artist.order_artist_name", "artist", "order_artist_name collate NATURALSORT"),
|
||||||
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT"),
|
||||||
Entry("album.order_album_name", "album", "order_album_name collate nocase"),
|
Entry("album.order_album_name", "album", "order_album_name collate NATURALSORT"),
|
||||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate nocase"),
|
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate NATURALSORT"),
|
||||||
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT"),
|
||||||
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate nocase"),
|
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NATURALSORT"),
|
||||||
Entry("media_file.order_title", "media_file", "order_title collate nocase"),
|
Entry("media_file.order_title", "media_file", "order_title collate NATURALSORT"),
|
||||||
Entry("media_file.order_album_name", "media_file", "order_album_name collate nocase"),
|
Entry("media_file.order_album_name", "media_file", "order_album_name collate NATURALSORT"),
|
||||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate nocase"),
|
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate NATURALSORT"),
|
||||||
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate nocase"),
|
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate NATURALSORT"),
|
||||||
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 NATURALSORT"),
|
||||||
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 NATURALSORT"),
|
||||||
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("playlist.name", "playlist", "name collate NATURALSORT"),
|
||||||
Entry("radio.name", "radio", "name collate nocase"),
|
Entry("radio.name", "radio", "name collate NATURALSORT"),
|
||||||
Entry("user.user_name", "user", "user_name collate nocase"),
|
Entry("user.user_name", "user", "user_name collate nocase"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -91,7 +91,7 @@ order by %[2]s`, table, column))
|
|||||||
return errors.New("no rows returned")
|
return errors.New("no rows returned")
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkCollation(conn *sql.DB, table string, column string) error {
|
func checkCollation(conn *sql.DB, table, column, expectedCollation string) error {
|
||||||
rows, err := conn.Query(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND tbl_name='%s'", table))
|
rows, err := conn.Query(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND tbl_name='%s'", table))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -113,12 +113,12 @@ func checkCollation(conn *sql.DB, table string, column string) error {
|
|||||||
if !re.MatchString(res) {
|
if !re.MatchString(res) {
|
||||||
return fmt.Errorf("column '%s' not found in table '%s'", column, table)
|
return fmt.Errorf("column '%s' not found in table '%s'", column, table)
|
||||||
}
|
}
|
||||||
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+NOCASE`, column))
|
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+%s`, column, expectedCollation))
|
||||||
if re.MatchString(res) {
|
if re.MatchString(res) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("table '%s' not found", table)
|
return fmt.Errorf("table '%s' not found", table)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("column '%s' in table '%s' does not have NOCASE collation", column, table)
|
return fmt.Errorf("column '%s' in table '%s' does not have %s collation", column, table, expectedCollation)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,11 +82,11 @@ func (e existsCond) ToSql() (string, []any, error) {
|
|||||||
|
|
||||||
var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`)
|
var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`)
|
||||||
|
|
||||||
// Convert the order_* columns to an expression using sort_* columns. Example:
|
// mapSortOrder converts order_* columns to an expression using sort_* columns with NATURALSORT collation. Example:
|
||||||
// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
|
// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate NATURALSORT)
|
||||||
// It finds order column names anywhere in the substring
|
// It finds order column names anywhere in the substring
|
||||||
func mapSortOrder(tableName, order string) string {
|
func mapSortOrder(tableName, order string) string {
|
||||||
order = strings.ToLower(order)
|
order = strings.ToLower(order)
|
||||||
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName)
|
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate NATURALSORT)", tableName)
|
||||||
return sortOrderRegex.ReplaceAllString(order, repl)
|
return sortOrderRegex.ReplaceAllString(order, repl)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,13 +94,13 @@ var _ = Describe("Helpers", func() {
|
|||||||
sort := "ORDER_ALBUM_NAME asc"
|
sort := "ORDER_ALBUM_NAME asc"
|
||||||
mapped := mapSortOrder("album", sort)
|
mapped := mapSortOrder("album", sort)
|
||||||
Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` +
|
Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` +
|
||||||
` collate nocase) asc`))
|
` collate NATURALSORT) asc`))
|
||||||
})
|
})
|
||||||
It("changes multiple order columns to sort expressions", func() {
|
It("changes multiple order columns to sort expressions", func() {
|
||||||
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
|
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
|
||||||
mapped := mapSortOrder("album", sort)
|
mapped := mapSortOrder("album", sort)
|
||||||
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
|
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate NATURALSORT) asc,` +
|
||||||
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
|
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate NATURALSORT) desc, year desc`))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
. "github.com/Masterminds/squirrel"
|
. "github.com/Masterminds/squirrel"
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
@@ -57,11 +58,8 @@ func (m *dbMediaFile) PostScan() error {
|
|||||||
func (m *dbMediaFile) PostMapArgs(args map[string]any) error {
|
func (m *dbMediaFile) PostMapArgs(args map[string]any) error {
|
||||||
fullText := []string{m.FullTitle(), m.Album, m.Artist, m.AlbumArtist,
|
fullText := []string{m.FullTitle(), m.Album, m.Artist, m.AlbumArtist,
|
||||||
m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle}
|
m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle}
|
||||||
participantNames := m.MediaFile.Participants.AllNames()
|
fullText = append(fullText, m.MediaFile.Participants.AllNames()...)
|
||||||
fullText = append(fullText, participantNames...)
|
|
||||||
args["full_text"] = formatFullText(fullText...)
|
args["full_text"] = formatFullText(fullText...)
|
||||||
args["search_participants"] = strings.Join(participantNames, " ")
|
|
||||||
args["search_normalized"] = normalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist)
|
|
||||||
args["tags"] = marshalTags(m.MediaFile.Tags)
|
args["tags"] = marshalTags(m.MediaFile.Tags)
|
||||||
args["participants"] = marshalParticipants(m.MediaFile.Participants)
|
args["participants"] = marshalParticipants(m.MediaFile.Participants)
|
||||||
return nil
|
return nil
|
||||||
@@ -98,7 +96,6 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
|
|||||||
"id": idFilter("media_file"),
|
"id": idFilter("media_file"),
|
||||||
"title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"),
|
"title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"),
|
||||||
"starred": annotationBoolFilter("starred"),
|
"starred": annotationBoolFilter("starred"),
|
||||||
"has_rating": annotationBoolFilter("rating"),
|
|
||||||
"genre_id": tagIDFilter,
|
"genre_id": tagIDFilter,
|
||||||
"missing": booleanFilter,
|
"missing": booleanFilter,
|
||||||
"artists_id": artistFilter,
|
"artists_id": artistFilter,
|
||||||
@@ -428,21 +425,18 @@ func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFil
|
|||||||
return res.toModels(), nil
|
return res.toModels(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var mediaFileSearchConfig = searchConfig{
|
func (r *mediaFileRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
NaturalOrder: "media_file.rowid",
|
|
||||||
OrderBy: []string{"title"},
|
|
||||||
MBIDFields: []string{"mbz_recording_id", "mbz_release_track_id"},
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
|
||||||
var opts model.QueryOptions
|
|
||||||
if len(options) > 0 {
|
|
||||||
opts = options[0]
|
|
||||||
}
|
|
||||||
var res dbMediaFiles
|
var res dbMediaFiles
|
||||||
err := r.doSearch(r.selectMediaFile(options...), q, &res, mediaFileSearchConfig, opts)
|
if uuid.Validate(q) == nil {
|
||||||
|
err := r.searchByMBID(r.selectMediaFile(options...), q, []string{"mbz_recording_id", "mbz_release_track_id"}, &res)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("searching media_file %q: %w", q, err)
|
return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err := r.doSearch(r.selectMediaFile(options...), q, offset, size, &res, "media_file.rowid", "title")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("searching media_file by query %q: %w", q, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return res.toModels(), nil
|
return res.toModels(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("counts the number of mediafiles in the DB", func() {
|
It("counts the number of mediafiles in the DB", func() {
|
||||||
Expect(mr.CountAll()).To(Equal(int64(13)))
|
Expect(mr.CountAll()).To(Equal(int64(10)))
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("CountBySuffix", func() {
|
Describe("CountBySuffix", func() {
|
||||||
@@ -527,7 +527,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
Describe("Search", func() {
|
Describe("Search", func() {
|
||||||
Context("text search", func() {
|
Context("text search", func() {
|
||||||
It("finds media files by title", func() {
|
It("finds media files by title", func() {
|
||||||
results, err := mr.Search("Antenna", model.QueryOptions{Max: 10})
|
results, err := mr.Search("Antenna", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2
|
Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
@@ -536,7 +536,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("finds media files case insensitively", func() {
|
It("finds media files case insensitively", func() {
|
||||||
results, err := mr.Search("antenna", model.QueryOptions{Max: 10})
|
results, err := mr.Search("antenna", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(results).To(HaveLen(3))
|
Expect(results).To(HaveLen(3))
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
@@ -545,7 +545,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("returns empty result when no matches found", func() {
|
It("returns empty result when no matches found", func() {
|
||||||
results, err := mr.Search("nonexistent", model.QueryOptions{Max: 10})
|
results, err := mr.Search("nonexistent", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(results).To(BeEmpty())
|
Expect(results).To(BeEmpty())
|
||||||
})
|
})
|
||||||
@@ -578,7 +578,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("finds media file by mbz_recording_id", func() {
|
It("finds media file by mbz_recording_id", func() {
|
||||||
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", model.QueryOptions{Max: 10})
|
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(results).To(HaveLen(1))
|
Expect(results).To(HaveLen(1))
|
||||||
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
|
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
|
||||||
@@ -586,7 +586,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("finds media file by mbz_release_track_id", func() {
|
It("finds media file by mbz_release_track_id", func() {
|
||||||
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", model.QueryOptions{Max: 10})
|
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(results).To(HaveLen(1))
|
Expect(results).To(HaveLen(1))
|
||||||
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
|
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
|
||||||
@@ -594,7 +594,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("returns empty result when MBID is not found", func() {
|
It("returns empty result when MBID is not found", func() {
|
||||||
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", model.QueryOptions{Max: 10})
|
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(results).To(BeEmpty())
|
Expect(results).To(BeEmpty())
|
||||||
})
|
})
|
||||||
@@ -614,7 +614,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
// Search never returns missing media files (hardcoded behavior)
|
// Search never returns missing media files (hardcoded behavior)
|
||||||
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", model.QueryOptions{Max: 10})
|
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(results).To(BeEmpty())
|
Expect(results).To(BeEmpty())
|
||||||
|
|
||||||
|
|||||||
@@ -56,22 +56,12 @@ func al(al model.Album) model.Album {
|
|||||||
return al
|
return al
|
||||||
}
|
}
|
||||||
|
|
||||||
func alWithTags(a model.Album, tags model.Tags) model.Album {
|
|
||||||
a = al(a)
|
|
||||||
a.Tags = tags
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
|
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
|
||||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
|
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
|
||||||
artistCJK = model.Artist{ID: "4", Name: "シートベルツ", SortArtistName: "Seatbelts", OrderArtistName: "seatbelts"}
|
|
||||||
artistPunctuation = model.Artist{ID: "5", Name: "The Roots", OrderArtistName: "roots"}
|
|
||||||
testArtists = model.Artists{
|
testArtists = model.Artists{
|
||||||
artistKraftwerk,
|
artistKraftwerk,
|
||||||
artistBeatles,
|
artistBeatles,
|
||||||
artistCJK,
|
|
||||||
artistPunctuation,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,18 +70,11 @@ var (
|
|||||||
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
|
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
|
||||||
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
|
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
|
||||||
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4})
|
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4})
|
||||||
albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("/seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1})
|
|
||||||
albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019},
|
|
||||||
model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}})
|
|
||||||
albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("/roots/things/track1.mp3"), SongCount: 1})
|
|
||||||
testAlbums = model.Albums{
|
testAlbums = model.Albums{
|
||||||
albumSgtPeppers,
|
albumSgtPeppers,
|
||||||
albumAbbeyRoad,
|
albumAbbeyRoad,
|
||||||
albumRadioactivity,
|
albumRadioactivity,
|
||||||
albumMultiDisc,
|
albumMultiDisc,
|
||||||
albumCJK,
|
|
||||||
albumWithVersion,
|
|
||||||
albumPunctuation,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,9 +101,6 @@ var (
|
|||||||
songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||||
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||||
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||||
songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("/seatbelts/cowboy-bebop/track1.mp3")})
|
|
||||||
songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("/beatles/2/come together.mp3")})
|
|
||||||
songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("/roots/things/track1.mp3")})
|
|
||||||
testSongs = model.MediaFiles{
|
testSongs = model.MediaFiles{
|
||||||
songDayInALife,
|
songDayInALife,
|
||||||
songComeTogether,
|
songComeTogether,
|
||||||
@@ -132,9 +112,6 @@ var (
|
|||||||
songDisc1Track01,
|
songDisc1Track01,
|
||||||
songDisc2Track01,
|
songDisc2Track01,
|
||||||
songDisc1Track02,
|
songDisc1Track02,
|
||||||
songCJK,
|
|
||||||
songVersioned,
|
|
||||||
songPunctuation,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ func (r *playlistRepository) Exists(id string) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *playlistRepository) Delete(id string) error {
|
func (r *playlistRepository) Delete(id string) error {
|
||||||
|
usr := loggedUser(r.ctx)
|
||||||
|
if !usr.IsAdmin {
|
||||||
|
pls, err := r.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if pls.OwnerID != usr.ID {
|
||||||
|
return rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
}
|
||||||
return r.delete(And{Eq{"id": id}, r.userFilter()})
|
return r.delete(And{Eq{"id": id}, r.userFilter()})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +113,14 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
|
|||||||
pls := dbPlaylist{Playlist: *p}
|
pls := dbPlaylist{Playlist: *p}
|
||||||
if pls.ID == "" {
|
if pls.ID == "" {
|
||||||
pls.CreatedAt = time.Now()
|
pls.CreatedAt = time.Now()
|
||||||
|
} else {
|
||||||
|
ok, err := r.Exists(pls.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return model.ErrNotAuthorized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pls.UpdatedAt = time.Now()
|
pls.UpdatedAt = time.Now()
|
||||||
|
|
||||||
@@ -114,6 +132,7 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
|
|||||||
|
|
||||||
if p.IsSmartPlaylist() {
|
if p.IsSmartPlaylist() {
|
||||||
// Do not update tracks at this point, as it may take a long time and lock the DB, breaking the scan process
|
// Do not update tracks at this point, as it may take a long time and lock the DB, breaking the scan process
|
||||||
|
//r.refreshSmartPlaylist(p)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Only update tracks if they were specified
|
// Only update tracks if they were specified
|
||||||
@@ -244,22 +263,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
|||||||
From("media_file").LeftJoin("annotation on (" +
|
From("media_file").LeftJoin("annotation on (" +
|
||||||
"annotation.item_id = media_file.id" +
|
"annotation.item_id = media_file.id" +
|
||||||
" AND annotation.item_type = 'media_file'" +
|
" AND annotation.item_type = 'media_file'" +
|
||||||
" AND annotation.user_id = ?)", usr.ID)
|
" AND annotation.user_id = '" + usr.ID + "')")
|
||||||
|
|
||||||
// Conditionally join album/artist annotation tables only when referenced by criteria or sort
|
|
||||||
requiredJoins := rules.RequiredJoins()
|
|
||||||
if requiredJoins.Has(criteria.JoinAlbumAnnotation) {
|
|
||||||
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
|
|
||||||
"album_annotation.item_id = media_file.album_id"+
|
|
||||||
" AND album_annotation.item_type = 'album'"+
|
|
||||||
" AND album_annotation.user_id = ?)", usr.ID)
|
|
||||||
}
|
|
||||||
if requiredJoins.Has(criteria.JoinArtistAnnotation) {
|
|
||||||
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
|
|
||||||
"artist_annotation.item_id = media_file.artist_id"+
|
|
||||||
" AND artist_annotation.item_type = 'artist'"+
|
|
||||||
" AND artist_annotation.user_id = ?)", usr.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only include media files from libraries the user has access to
|
// Only include media files from libraries the user has access to
|
||||||
sq = r.applyLibraryFilter(sq, "media_file")
|
sq = r.applyLibraryFilter(sq, "media_file")
|
||||||
@@ -316,6 +320,10 @@ func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []string) error {
|
func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []string) error {
|
||||||
|
if !r.isWritable(playlistId) {
|
||||||
|
return rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
// Remove old tracks
|
// Remove old tracks
|
||||||
del := Delete("playlist_tracks").Where(Eq{"playlist_id": playlistId})
|
del := Delete("playlist_tracks").Where(Eq{"playlist_id": playlistId})
|
||||||
_, err := r.executeSQL(del)
|
_, err := r.executeSQL(del)
|
||||||
@@ -431,7 +439,8 @@ func (r *playlistRepository) NewInstance() any {
|
|||||||
|
|
||||||
func (r *playlistRepository) Save(entity any) (string, error) {
|
func (r *playlistRepository) Save(entity any) (string, error) {
|
||||||
pls := entity.(*model.Playlist)
|
pls := entity.(*model.Playlist)
|
||||||
pls.ID = "" // Force new creation
|
pls.OwnerID = loggedUser(r.ctx).ID
|
||||||
|
pls.ID = "" // Make sure we don't override an existing playlist
|
||||||
err := r.Put(pls)
|
err := r.Put(pls)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -441,9 +450,24 @@ func (r *playlistRepository) Save(entity any) (string, error) {
|
|||||||
|
|
||||||
func (r *playlistRepository) Update(id string, entity any, cols ...string) error {
|
func (r *playlistRepository) Update(id string, entity any, cols ...string) error {
|
||||||
pls := dbPlaylist{Playlist: *entity.(*model.Playlist)}
|
pls := dbPlaylist{Playlist: *entity.(*model.Playlist)}
|
||||||
|
current, err := r.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
usr := loggedUser(r.ctx)
|
||||||
|
if !usr.IsAdmin {
|
||||||
|
// Only the owner can update the playlist
|
||||||
|
if current.OwnerID != usr.ID {
|
||||||
|
return rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
// Regular users can't change the ownership of a playlist
|
||||||
|
if pls.OwnerID != "" && pls.OwnerID != usr.ID {
|
||||||
|
return rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
}
|
||||||
pls.ID = id
|
pls.ID = id
|
||||||
pls.UpdatedAt = time.Now()
|
pls.UpdatedAt = time.Now()
|
||||||
_, err := r.put(id, pls, append(cols, "updatedAt")...)
|
_, err = r.put(id, pls, append(cols, "updatedAt")...)
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
return rest.ErrNotFound
|
return rest.ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -483,31 +507,23 @@ func (r *playlistRepository) removeOrphans() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// renumber updates the position of all tracks in the playlist to be sequential starting from 1, ordered by their
|
|
||||||
// current position. This is needed after removing orphan tracks, to ensure there are no gaps in the track numbering.
|
|
||||||
// The two-step approach (negate then reassign via CTE) avoids UNIQUE constraint violations on (playlist_id, id).
|
|
||||||
func (r *playlistRepository) renumber(id string) error {
|
func (r *playlistRepository) renumber(id string) error {
|
||||||
// Step 1: Negate all IDs to clear the positive ID space
|
var ids []string
|
||||||
_, err := r.executeSQL(Expr(
|
sq := Select("media_file_id").From("playlist_tracks").Where(Eq{"playlist_id": id}).OrderBy("id")
|
||||||
`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id > 0`, id))
|
err := r.queryAllSlice(sq, &ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Step 2: Assign new sequential positive IDs using UPDATE...FROM with a CTE.
|
return r.updatePlaylist(id, ids)
|
||||||
// The CTE is fully materialized before the UPDATE begins, avoiding self-referencing issues.
|
|
||||||
// ORDER BY id DESC restores original order since IDs are now negative.
|
|
||||||
_, err = r.executeSQL(Expr(
|
|
||||||
`WITH new_ids AS (
|
|
||||||
SELECT rowid as rid, ROW_NUMBER() OVER (ORDER BY id DESC) as new_id
|
|
||||||
FROM playlist_tracks WHERE playlist_id = ?
|
|
||||||
)
|
|
||||||
UPDATE playlist_tracks SET id = new_ids.new_id
|
|
||||||
FROM new_ids
|
|
||||||
WHERE playlist_tracks.rowid = new_ids.rid AND playlist_tracks.playlist_id = ?`, id, id))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return r.refreshCounters(&model.Playlist{ID: id})
|
|
||||||
|
func (r *playlistRepository) isWritable(playlistId string) bool {
|
||||||
|
usr := loggedUser(r.ctx)
|
||||||
|
if usr.IsAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
pls, err := r.Get(playlistId)
|
||||||
|
return err == nil && pls.OwnerID == usr.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ model.PlaylistRepository = (*playlistRepository)(nil)
|
var _ model.PlaylistRepository = (*playlistRepository)(nil)
|
||||||
|
|||||||
@@ -287,106 +287,6 @@ var _ = Describe("PlaylistRepository", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Smart Playlists with Album/Artist Annotation Criteria", func() {
|
|
||||||
var testPlaylistID string
|
|
||||||
|
|
||||||
AfterEach(func() {
|
|
||||||
if testPlaylistID != "" {
|
|
||||||
_ = repo.Delete(testPlaylistID)
|
|
||||||
testPlaylistID = ""
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("matches tracks from starred albums using albumLoved", func() {
|
|
||||||
// albumRadioactivity (ID "103") is starred in test fixtures
|
|
||||||
// Songs in album 103: 1003, 1004, 1005, 1006
|
|
||||||
rules := &criteria.Criteria{
|
|
||||||
Expression: criteria.All{
|
|
||||||
criteria.Is{"albumLoved": true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules}
|
|
||||||
Expect(repo.Put(&newPls)).To(Succeed())
|
|
||||||
testPlaylistID = newPls.ID
|
|
||||||
|
|
||||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
|
||||||
pls, err := repo.GetWithTracks(newPls.ID, true, false)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
trackIDs := make([]string, len(pls.Tracks))
|
|
||||||
for i, t := range pls.Tracks {
|
|
||||||
trackIDs[i] = t.MediaFileID
|
|
||||||
}
|
|
||||||
Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("matches tracks from starred artists using artistLoved", func() {
|
|
||||||
// artistBeatles (ID "3") is starred in test fixtures
|
|
||||||
// Songs with ArtistID "3": 1001, 1002, 3002
|
|
||||||
rules := &criteria.Criteria{
|
|
||||||
Expression: criteria.All{
|
|
||||||
criteria.Is{"artistLoved": true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules}
|
|
||||||
Expect(repo.Put(&newPls)).To(Succeed())
|
|
||||||
testPlaylistID = newPls.ID
|
|
||||||
|
|
||||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
|
||||||
pls, err := repo.GetWithTracks(newPls.ID, true, false)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
trackIDs := make([]string, len(pls.Tracks))
|
|
||||||
for i, t := range pls.Tracks {
|
|
||||||
trackIDs[i] = t.MediaFileID
|
|
||||||
}
|
|
||||||
Expect(trackIDs).To(ConsistOf("1001", "1002", "3002"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("matches tracks with combined album and artist criteria", func() {
|
|
||||||
// albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006)
|
|
||||||
// artistLoved=true → songs with artist 3 (1001, 1002)
|
|
||||||
// Using Any: union of both sets
|
|
||||||
rules := &criteria.Criteria{
|
|
||||||
Expression: criteria.Any{
|
|
||||||
criteria.Is{"albumLoved": true},
|
|
||||||
criteria.Is{"artistLoved": true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules}
|
|
||||||
Expect(repo.Put(&newPls)).To(Succeed())
|
|
||||||
testPlaylistID = newPls.ID
|
|
||||||
|
|
||||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
|
||||||
pls, err := repo.GetWithTracks(newPls.ID, true, false)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
trackIDs := make([]string, len(pls.Tracks))
|
|
||||||
for i, t := range pls.Tracks {
|
|
||||||
trackIDs[i] = t.MediaFileID
|
|
||||||
}
|
|
||||||
Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns no tracks when no albums/artists match", func() {
|
|
||||||
// No album has rating 5 in fixtures
|
|
||||||
rules := &criteria.Criteria{
|
|
||||||
Expression: criteria.All{
|
|
||||||
criteria.Is{"albumRating": 5},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules}
|
|
||||||
Expect(repo.Put(&newPls)).To(Succeed())
|
|
||||||
testPlaylistID = newPls.ID
|
|
||||||
|
|
||||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
|
||||||
pls, err := repo.GetWithTracks(newPls.ID, true, false)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
Expect(pls.Tracks).To(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Smart Playlists with Tag Criteria", func() {
|
Describe("Smart Playlists with Tag Criteria", func() {
|
||||||
var mfRepo model.MediaFileRepository
|
var mfRepo model.MediaFileRepository
|
||||||
var testPlaylistID string
|
var testPlaylistID string
|
||||||
@@ -501,79 +401,6 @@ var _ = Describe("PlaylistRepository", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Track Deletion and Renumbering", func() {
|
|
||||||
var testPlaylistID string
|
|
||||||
|
|
||||||
AfterEach(func() {
|
|
||||||
if testPlaylistID != "" {
|
|
||||||
Expect(repo.Delete(testPlaylistID)).To(BeNil())
|
|
||||||
testPlaylistID = ""
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// helper to get track positions and media file IDs
|
|
||||||
getTrackInfo := func(playlistID string) (ids []string, mediaFileIDs []string) {
|
|
||||||
pls, err := repo.GetWithTracks(playlistID, false, false)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
for _, t := range pls.Tracks {
|
|
||||||
ids = append(ids, t.ID)
|
|
||||||
mediaFileIDs = append(mediaFileIDs, t.MediaFileID)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
It("renumbers correctly after deleting a track from the middle", func() {
|
|
||||||
By("creating a playlist with 4 tracks")
|
|
||||||
newPls := model.Playlist{Name: "Renumber Test Middle", OwnerID: "userid"}
|
|
||||||
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003", "1004"})
|
|
||||||
Expect(repo.Put(&newPls)).To(Succeed())
|
|
||||||
testPlaylistID = newPls.ID
|
|
||||||
|
|
||||||
By("deleting the second track (position 2)")
|
|
||||||
tracksRepo := repo.Tracks(newPls.ID, false)
|
|
||||||
Expect(tracksRepo.Delete("2")).To(Succeed())
|
|
||||||
|
|
||||||
By("verifying remaining tracks are renumbered sequentially")
|
|
||||||
ids, mediaFileIDs := getTrackInfo(newPls.ID)
|
|
||||||
Expect(ids).To(Equal([]string{"1", "2", "3"}))
|
|
||||||
Expect(mediaFileIDs).To(Equal([]string{"1001", "1003", "1004"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("renumbers correctly after deleting the first track", func() {
|
|
||||||
By("creating a playlist with 3 tracks")
|
|
||||||
newPls := model.Playlist{Name: "Renumber Test First", OwnerID: "userid"}
|
|
||||||
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"})
|
|
||||||
Expect(repo.Put(&newPls)).To(Succeed())
|
|
||||||
testPlaylistID = newPls.ID
|
|
||||||
|
|
||||||
By("deleting the first track (position 1)")
|
|
||||||
tracksRepo := repo.Tracks(newPls.ID, false)
|
|
||||||
Expect(tracksRepo.Delete("1")).To(Succeed())
|
|
||||||
|
|
||||||
By("verifying remaining tracks are renumbered sequentially")
|
|
||||||
ids, mediaFileIDs := getTrackInfo(newPls.ID)
|
|
||||||
Expect(ids).To(Equal([]string{"1", "2"}))
|
|
||||||
Expect(mediaFileIDs).To(Equal([]string{"1002", "1003"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("renumbers correctly after deleting the last track", func() {
|
|
||||||
By("creating a playlist with 3 tracks")
|
|
||||||
newPls := model.Playlist{Name: "Renumber Test Last", OwnerID: "userid"}
|
|
||||||
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"})
|
|
||||||
Expect(repo.Put(&newPls)).To(Succeed())
|
|
||||||
testPlaylistID = newPls.ID
|
|
||||||
|
|
||||||
By("deleting the last track (position 3)")
|
|
||||||
tracksRepo := repo.Tracks(newPls.ID, false)
|
|
||||||
Expect(tracksRepo.Delete("3")).To(Succeed())
|
|
||||||
|
|
||||||
By("verifying remaining tracks are renumbered sequentially")
|
|
||||||
ids, mediaFileIDs := getTrackInfo(newPls.ID)
|
|
||||||
Expect(ids).To(Equal([]string{"1", "2"}))
|
|
||||||
Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Smart Playlists Library Filtering", func() {
|
Describe("Smart Playlists Library Filtering", func() {
|
||||||
var mfRepo model.MediaFileRepository
|
var mfRepo model.MediaFileRepository
|
||||||
var testPlaylistID string
|
var testPlaylistID string
|
||||||
|
|||||||
@@ -140,7 +140,15 @@ func (r *playlistTrackRepository) NewInstance() any {
|
|||||||
return &model.PlaylistTrack{}
|
return &model.PlaylistTrack{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *playlistTrackRepository) isTracksEditable() bool {
|
||||||
|
return r.playlistRepo.isWritable(r.playlistId) && !r.playlist.IsSmartPlaylist()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
|
func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
|
||||||
|
if !r.isTracksEditable() {
|
||||||
|
return 0, rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
if len(mediaFileIds) > 0 {
|
if len(mediaFileIds) > 0 {
|
||||||
log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds)
|
log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds)
|
||||||
} else {
|
} else {
|
||||||
@@ -188,7 +196,22 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) {
|
|||||||
return r.addMediaFileIds(clauses)
|
return r.addMediaFileIds(clauses)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get ids from all current tracks
|
||||||
|
func (r *playlistTrackRepository) getTracks() ([]string, error) {
|
||||||
|
all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id")
|
||||||
|
var ids []string
|
||||||
|
err := r.queryAllSlice(all, &ids)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(r.ctx, "Error querying current tracks from playlist", "playlistId", r.playlistId, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *playlistTrackRepository) Delete(ids ...string) error {
|
func (r *playlistTrackRepository) Delete(ids ...string) error {
|
||||||
|
if !r.isTracksEditable() {
|
||||||
|
return rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}})
|
err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -198,6 +221,9 @@ func (r *playlistTrackRepository) Delete(ids ...string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *playlistTrackRepository) DeleteAll() error {
|
func (r *playlistTrackRepository) DeleteAll() error {
|
||||||
|
if !r.isTracksEditable() {
|
||||||
|
return rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
err := r.delete(Eq{"playlist_id": r.playlistId})
|
err := r.delete(Eq{"playlist_id": r.playlistId})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -206,45 +232,16 @@ func (r *playlistTrackRepository) DeleteAll() error {
|
|||||||
return r.playlistRepo.renumber(r.playlistId)
|
return r.playlistRepo.renumber(r.playlistId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reorder moves a track from pos to newPos, shifting other tracks accordingly.
|
|
||||||
func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
|
func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
|
||||||
if pos == newPos {
|
if !r.isTracksEditable() {
|
||||||
return nil
|
return rest.ErrPermissionDenied
|
||||||
}
|
}
|
||||||
pid := r.playlistId
|
ids, err := r.getTracks()
|
||||||
|
|
||||||
// Step 1: Move the source track out of the way (temporary sentinel value)
|
|
||||||
_, err := r.executeSQL(Expr(
|
|
||||||
`UPDATE playlist_tracks SET id = -999999 WHERE playlist_id = ? AND id = ?`, pid, pos))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
newOrder := slice.Move(ids, pos-1, newPos-1)
|
||||||
// Step 2: Shift the affected range using negative values to avoid unique constraint violations
|
return r.playlistRepo.updatePlaylist(r.playlistId, newOrder)
|
||||||
if pos < newPos {
|
|
||||||
_, err = r.executeSQL(Expr(
|
|
||||||
`UPDATE playlist_tracks SET id = -(id - 1) WHERE playlist_id = ? AND id > ? AND id <= ?`,
|
|
||||||
pid, pos, newPos))
|
|
||||||
} else {
|
|
||||||
_, err = r.executeSQL(Expr(
|
|
||||||
`UPDATE playlist_tracks SET id = -(id + 1) WHERE playlist_id = ? AND id >= ? AND id < ?`,
|
|
||||||
pid, newPos, pos))
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Flip the shifted range back to positive
|
|
||||||
_, err = r.executeSQL(Expr(
|
|
||||||
`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0 AND id != -999999`, pid))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Place the source track at its new position
|
|
||||||
_, err = r.executeSQL(Expr(
|
|
||||||
`UPDATE playlist_tracks SET id = ? WHERE playlist_id = ? AND id = -999999`, newPos, pid))
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil)
|
var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil)
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error {
|
|||||||
|
|
||||||
// Upsert using INSERT ... ON CONFLICT for atomic operation
|
// Upsert using INSERT ... ON CONFLICT for atomic operation
|
||||||
_, err := r.db.NewQuery(`
|
_, err := r.db.NewQuery(`
|
||||||
INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, allow_write_access, enabled, last_error, sha256, created_at, updated_at)
|
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}, {:allow_write_access}, {: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
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
path = excluded.path,
|
path = excluded.path,
|
||||||
manifest = excluded.manifest,
|
manifest = excluded.manifest,
|
||||||
@@ -89,7 +89,6 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error {
|
|||||||
all_users = excluded.all_users,
|
all_users = excluded.all_users,
|
||||||
libraries = excluded.libraries,
|
libraries = excluded.libraries,
|
||||||
all_libraries = excluded.all_libraries,
|
all_libraries = excluded.all_libraries,
|
||||||
allow_write_access = excluded.allow_write_access,
|
|
||||||
enabled = excluded.enabled,
|
enabled = excluded.enabled,
|
||||||
last_error = excluded.last_error,
|
last_error = excluded.last_error,
|
||||||
sha256 = excluded.sha256,
|
sha256 = excluded.sha256,
|
||||||
@@ -103,7 +102,6 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error {
|
|||||||
"all_users": plugin.AllUsers,
|
"all_users": plugin.AllUsers,
|
||||||
"libraries": plugin.Libraries,
|
"libraries": plugin.Libraries,
|
||||||
"all_libraries": plugin.AllLibraries,
|
"all_libraries": plugin.AllLibraries,
|
||||||
"allow_write_access": plugin.AllowWriteAccess,
|
|
||||||
"enabled": plugin.Enabled,
|
"enabled": plugin.Enabled,
|
||||||
"last_error": plugin.LastError,
|
"last_error": plugin.LastError,
|
||||||
"sha256": plugin.SHA256,
|
"sha256": plugin.SHA256,
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun
|
|||||||
//
|
//
|
||||||
// If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression,
|
// If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression,
|
||||||
// which gives precedence to sort tags.
|
// which gives precedence to sort tags.
|
||||||
// Ex: order_title => (coalesce(nullif(sort_title,”),order_title) collate nocase)
|
// Ex: order_title => (coalesce(nullif(sort_title,""), order_title) collate NATURALSORT)
|
||||||
// To avoid performance issues, indexes should be created for these sort expressions
|
// To avoid performance issues, indexes should be created for these sort expressions.
|
||||||
//
|
//
|
||||||
// NOTE: if an individual item has spaces, it should be wrapped in parentheses. For example,
|
// NOTE: if an individual item has spaces, it should be wrapped in parentheses. For example,
|
||||||
// you should write "(lyrics != '[]')". This prevents the item being split unexpectedly.
|
// you should write "(lyrics != '[]')". This prevents the item being split unexpectedly.
|
||||||
|
|||||||
@@ -109,10 +109,11 @@ func booleanFilter(field string, value any) Sqlizer {
|
|||||||
func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer {
|
func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer {
|
||||||
return func(field string, value any) Sqlizer {
|
return func(field string, value any) Sqlizer {
|
||||||
v := strings.ToLower(value.(string))
|
v := strings.ToLower(value.(string))
|
||||||
return cmp.Or[Sqlizer](
|
cond := cmp.Or(
|
||||||
mbidExpr(tableName, v, mbidFields...),
|
mbidExpr(tableName, v, mbidFields...),
|
||||||
getSearchStrategy(tableName, v),
|
fullTextExpr(tableName, v),
|
||||||
)
|
)
|
||||||
|
return cond
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ var _ = Describe("sqlRestful", func() {
|
|||||||
Expect(r.parseRestFilters(context.Background(), options)).To(BeNil())
|
Expect(r.parseRestFilters(context.Background(), options)).To(BeNil())
|
||||||
})
|
})
|
||||||
|
|
||||||
It(`returns nil if tries a filter with legacySearchExpr("'")`, func() {
|
It(`returns nil if tries a filter with fullTextExpr("'")`, func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.Search.Backend = "legacy"
|
|
||||||
r.filterMappings = map[string]filterFunc{
|
r.filterMappings = map[string]filterFunc{
|
||||||
"name": fullTextFilter("table"),
|
"name": fullTextFilter("table"),
|
||||||
}
|
}
|
||||||
@@ -79,7 +77,6 @@ var _ = Describe("sqlRestful", func() {
|
|||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
conf.Server.Search.Backend = "legacy"
|
|
||||||
tableName = "test_table"
|
tableName = "test_table"
|
||||||
mbidFields = []string{"mbid", "artist_mbid"}
|
mbidFields = []string{"mbid", "artist_mbid"}
|
||||||
filter = fullTextFilter(tableName, mbidFields...)
|
filter = fullTextFilter(tableName, mbidFields...)
|
||||||
@@ -102,11 +99,11 @@ var _ = Describe("sqlRestful", func() {
|
|||||||
uuid := "550e8400-e29b-41d4-a716-446655440000"
|
uuid := "550e8400-e29b-41d4-a716-446655440000"
|
||||||
result := noMbidFilter("search", uuid)
|
result := noMbidFilter("search", uuid)
|
||||||
|
|
||||||
// mbidExpr with no fields returns nil, so cmp.Or falls back to search strategy
|
// mbidExpr with no fields returns nil, so cmp.Or falls back to fullTextExpr
|
||||||
sql, args, err := result.ToSql()
|
expected := squirrel.And{
|
||||||
Expect(err).ToNot(HaveOccurred())
|
squirrel.Like{"test_table.full_text": "% 550e8400-e29b-41d4-a716-446655440000%"},
|
||||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
}
|
||||||
Expect(args).To(ContainElement("% 550e8400-e29b-41d4-a716-446655440000%"))
|
Expect(result).To(Equal(expected))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -114,75 +111,54 @@ var _ = Describe("sqlRestful", func() {
|
|||||||
It("returns full text search condition only", func() {
|
It("returns full text search condition only", func() {
|
||||||
result := filter("search", "beatles")
|
result := filter("search", "beatles")
|
||||||
|
|
||||||
// mbidExpr returns nil for non-UUIDs, so search strategy result is returned directly
|
// mbidExpr returns nil for non-UUIDs, so fullTextExpr result is returned directly
|
||||||
sql, args, err := result.ToSql()
|
expected := squirrel.And{
|
||||||
Expect(err).ToNot(HaveOccurred())
|
squirrel.Like{"test_table.full_text": "% beatles%"},
|
||||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
}
|
||||||
Expect(args).To(ContainElement("% beatles%"))
|
Expect(result).To(Equal(expected))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("handles multi-word search terms", func() {
|
It("handles multi-word search terms", func() {
|
||||||
result := filter("search", "the beatles abbey road")
|
result := filter("search", "the beatles abbey road")
|
||||||
|
|
||||||
sql, args, err := result.ToSql()
|
// Should return And condition directly
|
||||||
Expect(err).ToNot(HaveOccurred())
|
andCondition, ok := result.(squirrel.And)
|
||||||
// All words should be present as LIKE conditions
|
Expect(ok).To(BeTrue())
|
||||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
Expect(andCondition).To(HaveLen(4))
|
||||||
Expect(args).To(HaveLen(4))
|
|
||||||
Expect(args).To(ContainElement("% the%"))
|
// Check that all words are present (order may vary)
|
||||||
Expect(args).To(ContainElement("% beatles%"))
|
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% the%"}))
|
||||||
Expect(args).To(ContainElement("% abbey%"))
|
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% beatles%"}))
|
||||||
Expect(args).To(ContainElement("% road%"))
|
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% abbey%"}))
|
||||||
|
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% road%"}))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("when SearchFullString config changes behavior", func() {
|
Context("when SearchFullString config changes behavior", func() {
|
||||||
It("uses different separator with SearchFullString=false", func() {
|
It("uses different separator with SearchFullString=false", func() {
|
||||||
conf.Server.Search.FullString = false
|
conf.Server.SearchFullString = false
|
||||||
result := filter("search", "test query")
|
result := filter("search", "test query")
|
||||||
|
|
||||||
sql, args, err := result.ToSql()
|
andCondition, ok := result.(squirrel.And)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(ok).To(BeTrue())
|
||||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
Expect(andCondition).To(HaveLen(2))
|
||||||
Expect(args).To(HaveLen(2))
|
|
||||||
Expect(args).To(ContainElement("% test%"))
|
// Check that all words are present with leading space (order may vary)
|
||||||
Expect(args).To(ContainElement("% query%"))
|
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% test%"}))
|
||||||
|
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% query%"}))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("uses no separator with SearchFullString=true", func() {
|
It("uses no separator with SearchFullString=true", func() {
|
||||||
conf.Server.Search.FullString = true
|
conf.Server.SearchFullString = true
|
||||||
result := filter("search", "test query")
|
result := filter("search", "test query")
|
||||||
|
|
||||||
sql, args, err := result.ToSql()
|
andCondition, ok := result.(squirrel.And)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(ok).To(BeTrue())
|
||||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
Expect(andCondition).To(HaveLen(2))
|
||||||
Expect(args).To(HaveLen(2))
|
|
||||||
Expect(args).To(ContainElement("%test%"))
|
|
||||||
Expect(args).To(ContainElement("%query%"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("single-character queries (regression: must not be rejected)", func() {
|
// Check that all words are present without leading space (order may vary)
|
||||||
It("returns valid filter for single-char query with legacy backend", func() {
|
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%test%"}))
|
||||||
conf.Server.Search.Backend = "legacy"
|
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%query%"}))
|
||||||
result := filter("search", "a")
|
|
||||||
Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
|
|
||||||
sql, args, err := result.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("LIKE"))
|
|
||||||
Expect(args).ToNot(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns valid filter for single-char query with FTS backend", func() {
|
|
||||||
conf.Server.Search.Backend = "fts"
|
|
||||||
conf.Server.Search.FullString = false
|
|
||||||
ftsFilter := fullTextFilter(tableName, mbidFields...)
|
|
||||||
result := ftsFilter("search", "a")
|
|
||||||
Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
|
|
||||||
sql, args, err := result.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("MATCH"))
|
|
||||||
Expect(args).ToNot(BeEmpty())
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -200,10 +176,10 @@ var _ = Describe("sqlRestful", func() {
|
|||||||
It("handles special characters that are sanitized", func() {
|
It("handles special characters that are sanitized", func() {
|
||||||
result := filter("search", "don't")
|
result := filter("search", "don't")
|
||||||
|
|
||||||
sql, args, err := result.ToSql()
|
expected := squirrel.And{
|
||||||
Expect(err).ToNot(HaveOccurred())
|
squirrel.Like{"test_table.full_text": "% dont%"}, // str.SanitizeStrings removes quotes
|
||||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
}
|
||||||
Expect(args).To(ContainElement("% dont%"))
|
Expect(result).To(Equal(expected))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns nil for single quote (SQL injection protection)", func() {
|
It("returns nil for single quote (SQL injection protection)", func() {
|
||||||
@@ -227,30 +203,31 @@ var _ = Describe("sqlRestful", func() {
|
|||||||
result := filter("search", "550e8400-invalid-uuid")
|
result := filter("search", "550e8400-invalid-uuid")
|
||||||
|
|
||||||
// Should return full text filter since UUID is invalid
|
// Should return full text filter since UUID is invalid
|
||||||
sql, args, err := result.ToSql()
|
expected := squirrel.And{
|
||||||
Expect(err).ToNot(HaveOccurred())
|
squirrel.Like{"test_table.full_text": "% 550e8400-invalid-uuid%"},
|
||||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
}
|
||||||
Expect(args).To(ContainElement("% 550e8400-invalid-uuid%"))
|
Expect(result).To(Equal(expected))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("handles empty mbid fields array", func() {
|
It("handles empty mbid fields array", func() {
|
||||||
emptyMbidFilter := fullTextFilter(tableName, []string{}...)
|
emptyMbidFilter := fullTextFilter(tableName, []string{}...)
|
||||||
result := emptyMbidFilter("search", "test")
|
result := emptyMbidFilter("search", "test")
|
||||||
|
|
||||||
// mbidExpr with empty fields returns nil, so search strategy result is returned directly
|
// mbidExpr with empty fields returns nil, so cmp.Or falls back to fullTextExpr
|
||||||
sql, args, err := result.ToSql()
|
expected := squirrel.And{
|
||||||
Expect(err).ToNot(HaveOccurred())
|
squirrel.Like{"test_table.full_text": "% test%"},
|
||||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
}
|
||||||
Expect(args).To(ContainElement("% test%"))
|
Expect(result).To(Equal(expected))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("converts value to lowercase before processing", func() {
|
It("converts value to lowercase before processing", func() {
|
||||||
result := filter("search", "TEST")
|
result := filter("search", "TEST")
|
||||||
|
|
||||||
sql, args, err := result.ToSql()
|
// The function converts to lowercase internally
|
||||||
Expect(err).ToNot(HaveOccurred())
|
expected := squirrel.And{
|
||||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
squirrel.Like{"test_table.full_text": "% test%"},
|
||||||
Expect(args).To(ContainElement("% test%"))
|
}
|
||||||
|
Expect(result).To(Equal(expected))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,71 +15,36 @@ func formatFullText(text ...string) string {
|
|||||||
return " " + fullText
|
return " " + fullText
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchConfig holds per-repository constants for doSearch.
|
// doSearch performs a full-text search with the specified parameters.
|
||||||
type searchConfig struct {
|
// The naturalOrder is used to sort results when no full-text filter is applied. It is useful for cases like
|
||||||
NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid")
|
// OpenSubsonic, where an empty search query should return all results in a natural order. Normally the parameter
|
||||||
OrderBy []string // ORDER BY for text search results (e.g. ["name"])
|
// should be `tableName + ".rowid"`, but some repositories (ex: artist) may use a different natural order.
|
||||||
MBIDFields []string // columns to match when query is a UUID
|
func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, results any, naturalOrder string, orderBys ...string) error {
|
||||||
// LibraryFilter overrides the default applyLibraryFilter for FTS Phase 1.
|
|
||||||
// Needed when library access requires a junction table (e.g. artist → library_artist).
|
|
||||||
LibraryFilter func(sq SelectBuilder) SelectBuilder
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchStrategy defines how to execute a text search against a repository table.
|
|
||||||
// options carries filters and pagination that must reach all query phases,
|
|
||||||
// including FTS Phase 1 which builds its own query outside sq.
|
|
||||||
type searchStrategy interface {
|
|
||||||
Sqlizer
|
|
||||||
execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSearchStrategy returns the appropriate search strategy based on config and query content.
|
|
||||||
// Returns nil when the query produces no searchable tokens.
|
|
||||||
func getSearchStrategy(tableName, query string) searchStrategy {
|
|
||||||
if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString {
|
|
||||||
return newLegacySearch(tableName, query)
|
|
||||||
}
|
|
||||||
if containsCJK(query) {
|
|
||||||
return newLikeSearch(tableName, query)
|
|
||||||
}
|
|
||||||
return newFTSSearch(tableName, query)
|
|
||||||
}
|
|
||||||
|
|
||||||
// doSearch dispatches a search query: empty → natural order, UUID → MBID match,
|
|
||||||
// otherwise delegates to getSearchStrategy. sq must already have LIMIT/OFFSET set
|
|
||||||
// via newSelect(options...). options is forwarded so FTS Phase 1 can apply the same
|
|
||||||
// filters and pagination independently.
|
|
||||||
func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg searchConfig, options model.QueryOptions) error {
|
|
||||||
q = strings.TrimSpace(q)
|
q = strings.TrimSpace(q)
|
||||||
q = strings.TrimSuffix(q, "*")
|
q = strings.TrimSuffix(q, "*")
|
||||||
|
|
||||||
sq = sq.Where(Eq{r.tableName + ".missing": false})
|
|
||||||
|
|
||||||
// Empty query (OpenSubsonic `search3?query=""`) — return all in natural order.
|
|
||||||
if q == "" || q == `""` {
|
|
||||||
sq = sq.OrderBy(cfg.NaturalOrder)
|
|
||||||
return r.queryAll(sq, results, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MBID search: if query is a valid UUID, search by MBID fields instead
|
|
||||||
if uuid.Validate(q) == nil && len(cfg.MBIDFields) > 0 {
|
|
||||||
sq = sq.Where(mbidExpr(r.tableName, q, cfg.MBIDFields...))
|
|
||||||
return r.queryAll(sq, results)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Min-length guard: single-character queries are too broad for search3.
|
|
||||||
// This check lives here (not in the strategies) so that fullTextFilter
|
|
||||||
// (REST filter path) can still use single-character queries.
|
|
||||||
if len(q) < 2 {
|
if len(q) < 2 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
strategy := getSearchStrategy(r.tableName, q)
|
filter := fullTextExpr(r.tableName, q)
|
||||||
if strategy == nil {
|
if filter != nil {
|
||||||
return nil
|
sq = sq.Where(filter)
|
||||||
|
sq = sq.OrderBy(orderBys...)
|
||||||
|
} else {
|
||||||
|
// This is to speed up the results of `search3?query=""`, for OpenSubsonic
|
||||||
|
// If the filter is empty, we sort by the specified natural order.
|
||||||
|
sq = sq.OrderBy(naturalOrder)
|
||||||
|
}
|
||||||
|
sq = sq.Where(Eq{r.tableName + ".missing": false})
|
||||||
|
sq = sq.Limit(uint64(size)).Offset(uint64(offset))
|
||||||
|
return r.queryAll(sq, results, model.QueryOptions{Offset: offset})
|
||||||
}
|
}
|
||||||
|
|
||||||
return strategy.execute(r, sq, results, cfg, options)
|
func (r sqlRepository) searchByMBID(sq SelectBuilder, mbid string, mbidFields []string, results any) error {
|
||||||
|
sq = sq.Where(mbidExpr(r.tableName, mbid, mbidFields...))
|
||||||
|
sq = sq.Where(Eq{r.tableName + ".missing": false})
|
||||||
|
|
||||||
|
return r.queryAll(sq, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
|
func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
|
||||||
@@ -93,3 +58,20 @@ func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
|
|||||||
}
|
}
|
||||||
return Or(cond)
|
return Or(cond)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fullTextExpr(tableName string, s string) Sqlizer {
|
||||||
|
q := str.SanitizeStrings(s)
|
||||||
|
if q == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var sep string
|
||||||
|
if !conf.Server.SearchFullString {
|
||||||
|
sep = " "
|
||||||
|
}
|
||||||
|
parts := strings.Split(q, " ")
|
||||||
|
filters := And{}
|
||||||
|
for _, part := range parts {
|
||||||
|
filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"})
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,422 +0,0 @@
|
|||||||
package persistence
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
. "github.com/Masterminds/squirrel"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// containsCJK returns true if the string contains any CJK (Chinese/Japanese/Korean) characters.
|
|
||||||
// CJK text doesn't use spaces between words, so FTS5's unicode61 tokenizer treats entire
|
|
||||||
// CJK phrases as single tokens, making token-based search ineffective for CJK content.
|
|
||||||
func containsCJK(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if unicode.Is(unicode.Han, r) ||
|
|
||||||
unicode.Is(unicode.Hiragana, r) ||
|
|
||||||
unicode.Is(unicode.Katakana, r) ||
|
|
||||||
unicode.Is(unicode.Hangul, r) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// fts5SpecialChars matches characters that should be stripped from user input.
|
|
||||||
// We keep only Unicode letters, numbers, whitespace, * (prefix wildcard), " (phrase quotes),
|
|
||||||
// and \x00 (internal placeholder marker). All punctuation is removed because the unicode61
|
|
||||||
// tokenizer treats it as token separators, and characters like ' can cause FTS5 parse errors
|
|
||||||
// as unbalanced string delimiters.
|
|
||||||
var fts5SpecialChars = regexp.MustCompile(`[^\p{L}\p{N}\s*"\x00]`)
|
|
||||||
|
|
||||||
// fts5PunctStrip strips everything except letters and numbers (no whitespace, wildcards, or quotes).
|
|
||||||
// Used for normalizing words at index time to create concatenated forms (e.g., "R.E.M." → "REM").
|
|
||||||
var fts5PunctStrip = regexp.MustCompile(`[^\p{L}\p{N}]`)
|
|
||||||
|
|
||||||
// fts5Operators matches FTS5 boolean operators as whole words (case-insensitive).
|
|
||||||
var fts5Operators = regexp.MustCompile(`(?i)\b(AND|OR|NOT|NEAR)\b`)
|
|
||||||
|
|
||||||
// fts5LeadingStar matches a * at the start of a token. FTS5 only supports * at the end (prefix queries).
|
|
||||||
var fts5LeadingStar = regexp.MustCompile(`(^|[\s])\*+`)
|
|
||||||
|
|
||||||
// normalizeForFTS takes multiple strings, strips non-letter/non-number characters from each word,
|
|
||||||
// and returns a space-separated string of words that changed after stripping (deduplicated).
|
|
||||||
// This is used at index time to create concatenated forms: "R.E.M." → "REM", "AC/DC" → "ACDC".
|
|
||||||
func normalizeForFTS(values ...string) string {
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
var result []string
|
|
||||||
for _, v := range values {
|
|
||||||
for _, word := range strings.Fields(v) {
|
|
||||||
stripped := fts5PunctStrip.ReplaceAllString(word, "")
|
|
||||||
if stripped == "" || stripped == word {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lower := strings.ToLower(stripped)
|
|
||||||
if _, ok := seen[lower]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[lower] = struct{}{}
|
|
||||||
result = append(result, stripped)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(result, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// isSingleUnicodeLetter returns true if token is exactly one Unicode letter.
|
|
||||||
func isSingleUnicodeLetter(token string) bool {
|
|
||||||
r, size := utf8.DecodeRuneInString(token)
|
|
||||||
return size == len(token) && size > 0 && unicode.IsLetter(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// namePunctuation is the set of characters commonly used as separators in artist/album
|
|
||||||
// names (hyphens, slashes, dots, apostrophes). Only words containing these are candidates
|
|
||||||
// for punctuated-word processing; other special characters (^, :, &) are just stripped.
|
|
||||||
const namePunctuation = `-/.''`
|
|
||||||
|
|
||||||
// processPunctuatedWords handles words with embedded name punctuation before the general
|
|
||||||
// special-character stripping. For each punctuated word it produces either:
|
|
||||||
// - A quoted phrase for dotted abbreviations: R.E.M. → "R E M"
|
|
||||||
// - A phrase+concat OR for other patterns: a-ha → ("a ha" OR aha*)
|
|
||||||
func processPunctuatedWords(input string, phrases []string) (string, []string) {
|
|
||||||
words := strings.Fields(input)
|
|
||||||
var result []string
|
|
||||||
for _, w := range words {
|
|
||||||
if strings.HasPrefix(w, "\x00") || strings.ContainsAny(w, `*"`) || !strings.ContainsAny(w, namePunctuation) {
|
|
||||||
result = append(result, w)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
concat := fts5PunctStrip.ReplaceAllString(w, "")
|
|
||||||
if concat == "" || concat == w {
|
|
||||||
result = append(result, w)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
subTokens := strings.Fields(fts5SpecialChars.ReplaceAllString(w, " "))
|
|
||||||
if len(subTokens) < 2 {
|
|
||||||
// Single sub-token after splitting (e.g., N' → N): just use the stripped form
|
|
||||||
result = append(result, concat)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Dotted abbreviations (R.E.M., U.K.) — all single letters separated by dots only
|
|
||||||
if isDottedAbbreviation(w, subTokens) {
|
|
||||||
phrases = append(phrases, fmt.Sprintf(`"%s"`, strings.Join(subTokens, " ")))
|
|
||||||
} else {
|
|
||||||
// Punctuated names (a-ha, AC/DC, Jay-Z) — phrase for adjacency + concat for search_normalized
|
|
||||||
phrases = append(phrases, fmt.Sprintf(`("%s" OR %s*)`, strings.Join(subTokens, " "), concat))
|
|
||||||
}
|
|
||||||
result = append(result, fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1))
|
|
||||||
}
|
|
||||||
return strings.Join(result, " "), phrases
|
|
||||||
}
|
|
||||||
|
|
||||||
// isDottedAbbreviation returns true if w uses only dots as punctuation and all sub-tokens
|
|
||||||
// are single letters (e.g., "R.E.M.", "U.K." but not "a-ha" or "AC/DC").
|
|
||||||
func isDottedAbbreviation(w string, subTokens []string) bool {
|
|
||||||
for _, r := range w {
|
|
||||||
if !unicode.IsLetter(r) && !unicode.IsNumber(r) && r != '.' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, st := range subTokens {
|
|
||||||
if !isSingleUnicodeLetter(st) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildFTS5Query preprocesses user input into a safe FTS5 MATCH expression.
|
|
||||||
// It preserves quoted phrases and * prefix wildcards, neutralizes FTS5 operators
|
|
||||||
// (by lowercasing them, since FTS5 operators are case-sensitive) and strips
|
|
||||||
// special characters to prevent query injection.
|
|
||||||
func buildFTS5Query(userInput string) string {
|
|
||||||
q := strings.TrimSpace(userInput)
|
|
||||||
if q == "" || q == `""` {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var phrases []string
|
|
||||||
result := q
|
|
||||||
for {
|
|
||||||
start := strings.Index(result, `"`)
|
|
||||||
if start == -1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
end := strings.Index(result[start+1:], `"`)
|
|
||||||
if end == -1 {
|
|
||||||
// Unmatched quote — remove it
|
|
||||||
result = result[:start] + result[start+1:]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
end += start + 1
|
|
||||||
phrase := result[start : end+1] // includes quotes
|
|
||||||
phrases = append(phrases, phrase)
|
|
||||||
result = result[:start] + fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1) + result[end+1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neutralize FTS5 operators by lowercasing them (FTS5 operators are case-sensitive:
|
|
||||||
// AND, OR, NOT, NEAR are operators, but and, or, not, near are plain tokens)
|
|
||||||
result = fts5Operators.ReplaceAllStringFunc(result, strings.ToLower)
|
|
||||||
|
|
||||||
// Handle words with embedded punctuation (a-ha, AC/DC, R.E.M.) before stripping
|
|
||||||
result, phrases = processPunctuatedWords(result, phrases)
|
|
||||||
|
|
||||||
result = fts5SpecialChars.ReplaceAllString(result, " ")
|
|
||||||
result = fts5LeadingStar.ReplaceAllString(result, "$1")
|
|
||||||
tokens := strings.Fields(result)
|
|
||||||
|
|
||||||
// Append * to plain tokens for prefix matching (e.g., "love" → "love*").
|
|
||||||
// Skip tokens that are already wildcarded or are quoted phrase placeholders.
|
|
||||||
for i, t := range tokens {
|
|
||||||
if strings.HasPrefix(t, "\x00") || strings.HasSuffix(t, "*") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tokens[i] = t + "*"
|
|
||||||
}
|
|
||||||
|
|
||||||
result = strings.Join(tokens, " ")
|
|
||||||
|
|
||||||
for i, phrase := range phrases {
|
|
||||||
placeholder := fmt.Sprintf("\x00PHRASE%d\x00", i)
|
|
||||||
result = strings.ReplaceAll(result, placeholder, phrase)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ftsColumn pairs an FTS5 column name with its BM25 relevance weight.
|
|
||||||
type ftsColumn struct {
|
|
||||||
Name string
|
|
||||||
Weight float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// ftsColumnDefs defines FTS5 columns and their BM25 relevance weights.
|
|
||||||
// The order MUST match the column order in the FTS5 table definition (see migrations).
|
|
||||||
// All columns are both searched and ranked. When adding indexed-but-not-searched
|
|
||||||
// columns in the future, use Weight: 0 to exclude from the search column filter.
|
|
||||||
var ftsColumnDefs = map[string][]ftsColumn{
|
|
||||||
"media_file": {
|
|
||||||
{"title", 10.0},
|
|
||||||
{"album", 5.0},
|
|
||||||
{"artist", 3.0},
|
|
||||||
{"album_artist", 3.0},
|
|
||||||
{"sort_title", 1.0},
|
|
||||||
{"sort_album_name", 1.0},
|
|
||||||
{"sort_artist_name", 1.0},
|
|
||||||
{"sort_album_artist_name", 1.0},
|
|
||||||
{"disc_subtitle", 1.0},
|
|
||||||
{"search_participants", 2.0},
|
|
||||||
{"search_normalized", 1.0},
|
|
||||||
},
|
|
||||||
"album": {
|
|
||||||
{"name", 10.0},
|
|
||||||
{"sort_album_name", 1.0},
|
|
||||||
{"album_artist", 3.0},
|
|
||||||
{"search_participants", 2.0},
|
|
||||||
{"discs", 1.0},
|
|
||||||
{"catalog_num", 1.0},
|
|
||||||
{"album_version", 1.0},
|
|
||||||
{"search_normalized", 1.0},
|
|
||||||
},
|
|
||||||
"artist": {
|
|
||||||
{"name", 10.0},
|
|
||||||
{"sort_artist_name", 1.0},
|
|
||||||
{"search_normalized", 1.0},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ftsColumnFilters and ftsBM25Weights are precomputed from ftsColumnDefs at init time
|
|
||||||
// to avoid per-query allocations.
|
|
||||||
var (
|
|
||||||
ftsColumnFilters = map[string]string{}
|
|
||||||
ftsBM25Weights = map[string]string{}
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
for table, cols := range ftsColumnDefs {
|
|
||||||
var names []string
|
|
||||||
weights := make([]string, len(cols))
|
|
||||||
for i, c := range cols {
|
|
||||||
if c.Weight > 0 {
|
|
||||||
names = append(names, c.Name)
|
|
||||||
}
|
|
||||||
weights[i] = fmt.Sprintf("%.1f", c.Weight)
|
|
||||||
}
|
|
||||||
ftsColumnFilters[table] = "{" + strings.Join(names, " ") + "}"
|
|
||||||
ftsBM25Weights[table] = strings.Join(weights, ", ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ftsSearch implements searchStrategy using FTS5 full-text search with BM25 ranking.
|
|
||||||
type ftsSearch struct {
|
|
||||||
tableName string
|
|
||||||
ftsTable string
|
|
||||||
matchExpr string
|
|
||||||
rankExpr string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToSql returns a single-query fallback for the REST filter path (no two-phase split).
|
|
||||||
func (s *ftsSearch) ToSql() (string, []interface{}, error) {
|
|
||||||
sql := s.tableName + ".rowid IN (SELECT rowid FROM " + s.ftsTable + " WHERE " + s.ftsTable + " MATCH ?)"
|
|
||||||
return sql, []interface{}{s.matchExpr}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// execute runs a two-phase FTS5 search:
|
|
||||||
// - Phase 1: lightweight rowid query (main table + FTS + library filter) for ranking and pagination.
|
|
||||||
// - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid set.
|
|
||||||
//
|
|
||||||
// Complex ORDER BY (function calls, aggregations) are dropped from Phase 1.
|
|
||||||
func (s *ftsSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error {
|
|
||||||
qualifiedOrderBys := []string{s.rankExpr}
|
|
||||||
for _, ob := range cfg.OrderBy {
|
|
||||||
if qualified := qualifyOrderBy(s.tableName, ob); qualified != "" {
|
|
||||||
qualifiedOrderBys = append(qualifiedOrderBys, qualified)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 1: fresh query — must set LIMIT/OFFSET from options explicitly.
|
|
||||||
// Mirror applyOptions behavior: Max=0 means no limit, not LIMIT 0.
|
|
||||||
rowidQuery := Select(s.tableName+".rowid").
|
|
||||||
From(s.tableName).
|
|
||||||
Join(s.ftsTable+" ON "+s.ftsTable+".rowid = "+s.tableName+".rowid AND "+s.ftsTable+" MATCH ?", s.matchExpr).
|
|
||||||
Where(Eq{s.tableName + ".missing": false}).
|
|
||||||
OrderBy(qualifiedOrderBys...)
|
|
||||||
if options.Max > 0 {
|
|
||||||
rowidQuery = rowidQuery.Limit(uint64(options.Max))
|
|
||||||
}
|
|
||||||
if options.Offset > 0 {
|
|
||||||
rowidQuery = rowidQuery.Offset(uint64(options.Offset))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Library filter + musicFolderId must be applied here, before pagination.
|
|
||||||
if cfg.LibraryFilter != nil {
|
|
||||||
rowidQuery = cfg.LibraryFilter(rowidQuery)
|
|
||||||
} else {
|
|
||||||
rowidQuery = r.applyLibraryFilter(rowidQuery)
|
|
||||||
}
|
|
||||||
if options.Filters != nil {
|
|
||||||
rowidQuery = rowidQuery.Where(options.Filters)
|
|
||||||
}
|
|
||||||
|
|
||||||
rowidSQL, rowidArgs, err := rowidQuery.ToSql()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("building FTS rowid query: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: strip LIMIT/OFFSET from sq (Phase 1 handled pagination),
|
|
||||||
// join on the ranked rowid set to hydrate with full columns.
|
|
||||||
sq = sq.RemoveLimit().RemoveOffset()
|
|
||||||
rankedSubquery := fmt.Sprintf(
|
|
||||||
"(SELECT rowid as _rid, row_number() OVER () AS _rn FROM (%s)) AS _ranked",
|
|
||||||
rowidSQL,
|
|
||||||
)
|
|
||||||
sq = sq.Join(rankedSubquery+" ON "+s.tableName+".rowid = _ranked._rid", rowidArgs...)
|
|
||||||
sq = sq.OrderBy("_ranked._rn")
|
|
||||||
return r.queryAll(sq, dest)
|
|
||||||
}
|
|
||||||
|
|
||||||
// qualifyOrderBy prepends tableName to a simple column name. Returns empty string for
|
|
||||||
// complex expressions (function calls, aggregations) that can't be used in Phase 1.
|
|
||||||
func qualifyOrderBy(tableName, orderBy string) string {
|
|
||||||
orderBy = strings.TrimSpace(orderBy)
|
|
||||||
if orderBy == "" || strings.ContainsAny(orderBy, "(,") {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
parts := strings.Fields(orderBy)
|
|
||||||
if !strings.Contains(parts[0], ".") {
|
|
||||||
parts[0] = tableName + "." + parts[0]
|
|
||||||
}
|
|
||||||
return strings.Join(parts, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ftsQueryDegraded returns true when the FTS query lost significant discriminating
|
|
||||||
// content compared to the original input. This happens when special characters that
|
|
||||||
// are part of the entity name (e.g., "1+", "C++", "!!!", "C#") get stripped by FTS
|
|
||||||
// tokenization, leaving only very short/broad tokens. Also detects quoted phrases
|
|
||||||
// that would be degraded by FTS5's unicode61 tokenizer (e.g., "1+" → token "1").
|
|
||||||
func ftsQueryDegraded(original, ftsQuery string) bool {
|
|
||||||
original = strings.TrimSpace(original)
|
|
||||||
if original == "" || ftsQuery == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Strip quotes from original for comparison — we want the raw content
|
|
||||||
stripped := strings.ReplaceAll(original, `"`, "")
|
|
||||||
// Extract the alphanumeric content from the original query
|
|
||||||
alphaNum := fts5PunctStrip.ReplaceAllString(stripped, "")
|
|
||||||
// If the original is entirely alphanumeric, nothing was stripped — not degraded
|
|
||||||
if len(alphaNum) == len(stripped) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Check if all effective FTS tokens are very short (≤2 chars).
|
|
||||||
// Short tokens with prefix matching are too broad when special chars were stripped.
|
|
||||||
// For quoted phrases, extract the content and check the tokens inside.
|
|
||||||
tokens := strings.Fields(ftsQuery)
|
|
||||||
for _, t := range tokens {
|
|
||||||
t = strings.TrimSuffix(t, "*")
|
|
||||||
// Skip internal phrase placeholders
|
|
||||||
if strings.HasPrefix(t, "\x00") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// For OR groups from processPunctuatedWords (e.g., ("a ha" OR aha*)),
|
|
||||||
// the punctuated word was already handled meaningfully — not degraded.
|
|
||||||
if strings.HasPrefix(t, "(") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// For quoted phrases, check the tokens inside as FTS5 will tokenize them
|
|
||||||
if strings.HasPrefix(t, `"`) {
|
|
||||||
// Extract content between quotes
|
|
||||||
inner := strings.Trim(t, `"`)
|
|
||||||
innerAlpha := fts5PunctStrip.ReplaceAllString(inner, " ")
|
|
||||||
for _, it := range strings.Fields(innerAlpha) {
|
|
||||||
if len(it) > 2 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(t) > 2 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// newFTSSearch creates an FTS5 search strategy. Falls back to LIKE search if the
|
|
||||||
// query produces no FTS tokens (e.g., punctuation-only like "!!!!!!!") or if FTS
|
|
||||||
// tokenization stripped significant content from the query (e.g., "1+" → "1*").
|
|
||||||
// Returns nil when the query produces no searchable tokens at all.
|
|
||||||
func newFTSSearch(tableName, query string) searchStrategy {
|
|
||||||
q := buildFTS5Query(query)
|
|
||||||
if q == "" || ftsQueryDegraded(query, q) {
|
|
||||||
// Fallback: try LIKE search with the raw query
|
|
||||||
cleaned := strings.TrimSpace(strings.ReplaceAll(query, `"`, ""))
|
|
||||||
if cleaned != "" {
|
|
||||||
log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", cleaned)
|
|
||||||
return newLikeSearch(tableName, cleaned)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ftsTable := tableName + "_fts"
|
|
||||||
matchExpr := q
|
|
||||||
if cols, ok := ftsColumnFilters[tableName]; ok {
|
|
||||||
matchExpr = cols + " : (" + q + ")"
|
|
||||||
}
|
|
||||||
|
|
||||||
rankExpr := ftsTable + ".rank"
|
|
||||||
if weights, ok := ftsBM25Weights[tableName]; ok {
|
|
||||||
rankExpr = "bm25(" + ftsTable + ", " + weights + ")"
|
|
||||||
}
|
|
||||||
|
|
||||||
s := &ftsSearch{
|
|
||||||
tableName: tableName,
|
|
||||||
ftsTable: ftsTable,
|
|
||||||
matchExpr: matchExpr,
|
|
||||||
rankExpr: rankExpr,
|
|
||||||
}
|
|
||||||
log.Trace("Search using FTS5 backend", "table", tableName, "query", q, "filter", s)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
package persistence
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = DescribeTable("buildFTS5Query",
|
|
||||||
func(input, expected string) {
|
|
||||||
Expect(buildFTS5Query(input)).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("returns empty string for empty input", "", ""),
|
|
||||||
Entry("returns empty string for whitespace-only input", " ", ""),
|
|
||||||
Entry("appends * to a single word for prefix matching", "beatles", "beatles*"),
|
|
||||||
Entry("appends * to each word for prefix matching", "abbey road", "abbey* road*"),
|
|
||||||
Entry("preserves quoted phrases without appending *", `"the beatles"`, `"the beatles"`),
|
|
||||||
Entry("does not double-append * to existing prefix wildcard", "beat*", "beat*"),
|
|
||||||
Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* or* not* near*"),
|
|
||||||
Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* col* val*"),
|
|
||||||
Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" abbey*`),
|
|
||||||
Entry("handles prefix with multiple words", "beat* abbey", "beat* abbey*"),
|
|
||||||
Entry("collapses multiple spaces", "abbey road", "abbey* road*"),
|
|
||||||
Entry("strips leading * from tokens and appends trailing *", "*livia", "livia*"),
|
|
||||||
Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* oliv*"),
|
|
||||||
Entry("strips standalone *", "*", ""),
|
|
||||||
Entry("strips apostrophe from input", "Guns N' Roses", "Guns* N* Roses*"),
|
|
||||||
Entry("converts slashed word to phrase+concat OR", "AC/DC", `("AC DC" OR ACDC*)`),
|
|
||||||
Entry("converts hyphenated word to phrase+concat OR", "a-ha", `("a ha" OR aha*)`),
|
|
||||||
Entry("converts partial hyphenated word to phrase+concat OR", "a-h", `("a h" OR ah*)`),
|
|
||||||
Entry("converts hyphenated name to phrase+concat OR", "Jay-Z", `("Jay Z" OR JayZ*)`),
|
|
||||||
Entry("converts contraction to phrase+concat OR", "it's", `("it s" OR its*)`),
|
|
||||||
Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* of* ("a ha" OR aha*)`),
|
|
||||||
Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* roll* vol* 2*"),
|
|
||||||
Entry("preserves unicode characters with diacritics", "Björk début", "Björk* début*"),
|
|
||||||
Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`),
|
|
||||||
Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`),
|
|
||||||
Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* of* "R E M"`),
|
|
||||||
Entry("collapses two-letter abbreviation", "U.K.", `"U K"`),
|
|
||||||
Entry("does not collapse single letter surrounded by words", "I am fine", "I* am* fine*"),
|
|
||||||
Entry("does not collapse single standalone letter", "A test", "A* test*"),
|
|
||||||
Entry("preserves quoted phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`),
|
|
||||||
Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`),
|
|
||||||
Entry("returns empty string for punctuation-only input", "!!!!!!!", ""),
|
|
||||||
Entry("returns empty string for mixed punctuation", "!@#$%^&", ""),
|
|
||||||
Entry("returns empty string for empty quoted phrase", `""`, ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = DescribeTable("ftsQueryDegraded",
|
|
||||||
func(original, ftsQuery string, expected bool) {
|
|
||||||
Expect(ftsQueryDegraded(original, ftsQuery)).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("not degraded for empty original", "", "1*", false),
|
|
||||||
Entry("not degraded for empty ftsQuery", "1+", "", false),
|
|
||||||
Entry("not degraded for purely alphanumeric query", "beatles", "beatles*", false),
|
|
||||||
Entry("not degraded when long tokens remain", "test^val", "test* val*", false),
|
|
||||||
Entry("not degraded for quoted phrase with long tokens", `"the beatles"`, `"the beatles"`, false),
|
|
||||||
Entry("degraded for quoted phrase with only short tokens after tokenizer strips special chars", `"1+"`, `"1+"`, true),
|
|
||||||
Entry("not degraded for quoted phrase with meaningful content", `"C++ programming"`, `"C++ programming"`, false),
|
|
||||||
Entry("degraded when special chars stripped leaving short token", "1+", "1*", true),
|
|
||||||
Entry("degraded when special chars stripped leaving two short tokens", "C# 1", "C* 1*", true),
|
|
||||||
Entry("not degraded when at least one long token remains", "1+ beatles", "1* beatles*", false),
|
|
||||||
Entry("not degraded for OR groups from processPunctuatedWords", "AC/DC", `("AC DC" OR ACDC*)`, false),
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = DescribeTable("normalizeForFTS",
|
|
||||||
func(expected string, values ...string) {
|
|
||||||
Expect(normalizeForFTS(values...)).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("strips dots and concatenates", "REM", "R.E.M."),
|
|
||||||
Entry("strips slash", "ACDC", "AC/DC"),
|
|
||||||
Entry("strips hyphen", "Aha", "A-ha"),
|
|
||||||
Entry("skips unchanged words", "", "The Beatles"),
|
|
||||||
Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"),
|
|
||||||
Entry("deduplicates", "REM", "R.E.M.", "R.E.M."),
|
|
||||||
Entry("strips apostrophe from word", "N", "Guns N' Roses"),
|
|
||||||
Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"),
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = DescribeTable("containsCJK",
|
|
||||||
func(input string, expected bool) {
|
|
||||||
Expect(containsCJK(input)).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("returns false for empty string", "", false),
|
|
||||||
Entry("returns false for ASCII text", "hello world", false),
|
|
||||||
Entry("returns false for Latin with diacritics", "Björk début", false),
|
|
||||||
Entry("detects Chinese characters (Han)", "周杰伦", true),
|
|
||||||
Entry("detects Japanese Hiragana", "こんにちは", true),
|
|
||||||
Entry("detects Japanese Katakana", "カタカナ", true),
|
|
||||||
Entry("detects Korean Hangul", "한국어", true),
|
|
||||||
Entry("detects CJK mixed with Latin", "best of 周杰伦", true),
|
|
||||||
Entry("detects single CJK character", "a曲b", true),
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = DescribeTable("qualifyOrderBy",
|
|
||||||
func(tableName, orderBy, expected string) {
|
|
||||||
Expect(qualifyOrderBy(tableName, orderBy)).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("returns empty string for empty input", "artist", "", ""),
|
|
||||||
Entry("qualifies simple column with table name", "artist", "name", "artist.name"),
|
|
||||||
Entry("qualifies column with direction", "artist", "name desc", "artist.name desc"),
|
|
||||||
Entry("preserves already-qualified column", "artist", "artist.name", "artist.name"),
|
|
||||||
Entry("preserves already-qualified column with direction", "artist", "artist.name desc", "artist.name desc"),
|
|
||||||
Entry("returns empty for function call expression", "artist", "sum(json_extract(stats, '$.total.m')) desc", ""),
|
|
||||||
Entry("returns empty for expression with comma", "artist", "a, b", ""),
|
|
||||||
Entry("qualifies media_file column", "media_file", "title", "media_file.title"),
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("ftsColumnDefs helpers", func() {
|
|
||||||
Describe("ftsColumnFilters", func() {
|
|
||||||
It("returns column filter for media_file", func() {
|
|
||||||
Expect(ftsColumnFilters).To(HaveKeyWithValue("media_file",
|
|
||||||
"{title album artist album_artist sort_title sort_album_name sort_artist_name sort_album_artist_name disc_subtitle search_participants search_normalized}",
|
|
||||||
))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns column filter for album", func() {
|
|
||||||
Expect(ftsColumnFilters).To(HaveKeyWithValue("album",
|
|
||||||
"{name sort_album_name album_artist search_participants discs catalog_num album_version search_normalized}",
|
|
||||||
))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns column filter for artist", func() {
|
|
||||||
Expect(ftsColumnFilters).To(HaveKeyWithValue("artist",
|
|
||||||
"{name sort_artist_name search_normalized}",
|
|
||||||
))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("has no entry for unknown table", func() {
|
|
||||||
Expect(ftsColumnFilters).ToNot(HaveKey("unknown"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("ftsBM25Weights", func() {
|
|
||||||
It("returns weight CSV for media_file", func() {
|
|
||||||
Expect(ftsBM25Weights).To(HaveKeyWithValue("media_file",
|
|
||||||
"10.0, 5.0, 3.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 1.0",
|
|
||||||
))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns weight CSV for album", func() {
|
|
||||||
Expect(ftsBM25Weights).To(HaveKeyWithValue("album",
|
|
||||||
"10.0, 1.0, 3.0, 2.0, 1.0, 1.0, 1.0, 1.0",
|
|
||||||
))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns weight CSV for artist", func() {
|
|
||||||
Expect(ftsBM25Weights).To(HaveKeyWithValue("artist",
|
|
||||||
"10.0, 1.0, 1.0",
|
|
||||||
))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("has no entry for unknown table", func() {
|
|
||||||
Expect(ftsBM25Weights).ToNot(HaveKey("unknown"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
It("has definitions for all known tables", func() {
|
|
||||||
for _, table := range []string{"media_file", "album", "artist"} {
|
|
||||||
Expect(ftsColumnDefs).To(HaveKey(table))
|
|
||||||
Expect(ftsColumnDefs[table]).ToNot(BeEmpty())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("has matching column count between filter and weights", func() {
|
|
||||||
for table, cols := range ftsColumnDefs {
|
|
||||||
// Column filter only includes Weight > 0 columns
|
|
||||||
filterCount := 0
|
|
||||||
for _, c := range cols {
|
|
||||||
if c.Weight > 0 {
|
|
||||||
filterCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For now, all columns have Weight > 0, so filter count == total count
|
|
||||||
Expect(filterCount).To(Equal(len(cols)), "table %s: all columns should have positive weights", table)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("newFTSSearch", func() {
|
|
||||||
It("returns nil for empty query", func() {
|
|
||||||
Expect(newFTSSearch("media_file", "")).To(BeNil())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns non-nil for single-character query", func() {
|
|
||||||
strategy := newFTSSearch("media_file", "a")
|
|
||||||
Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here")
|
|
||||||
sql, _, err := strategy.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("MATCH"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ftsSearch with correct table names and MATCH expression", func() {
|
|
||||||
strategy := newFTSSearch("media_file", "beatles")
|
|
||||||
fts, ok := strategy.(*ftsSearch)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(fts.tableName).To(Equal("media_file"))
|
|
||||||
Expect(fts.ftsTable).To(Equal("media_file_fts"))
|
|
||||||
Expect(fts.matchExpr).To(HavePrefix("{title album artist album_artist"))
|
|
||||||
Expect(fts.matchExpr).To(ContainSubstring("beatles*"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("ToSql generates rowid IN subquery with MATCH (fallback path)", func() {
|
|
||||||
strategy := newFTSSearch("media_file", "beatles")
|
|
||||||
sql, args, err := strategy.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("media_file.rowid IN"))
|
|
||||||
Expect(sql).To(ContainSubstring("media_file_fts"))
|
|
||||||
Expect(sql).To(ContainSubstring("MATCH"))
|
|
||||||
Expect(args).To(HaveLen(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("generates correct FTS table name per entity", func() {
|
|
||||||
for _, table := range []string{"media_file", "album", "artist"} {
|
|
||||||
strategy := newFTSSearch(table, "test")
|
|
||||||
fts, ok := strategy.(*ftsSearch)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(fts.tableName).To(Equal(table))
|
|
||||||
Expect(fts.ftsTable).To(Equal(table + "_fts"))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("builds bm25() rank expression with column weights", func() {
|
|
||||||
strategy := newFTSSearch("media_file", "beatles")
|
|
||||||
fts, ok := strategy.(*ftsSearch)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(fts.rankExpr).To(HavePrefix("bm25(media_file_fts,"))
|
|
||||||
Expect(fts.rankExpr).To(ContainSubstring("10.0"))
|
|
||||||
|
|
||||||
strategy = newFTSSearch("artist", "beatles")
|
|
||||||
fts, ok = strategy.(*ftsSearch)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(fts.rankExpr).To(HavePrefix("bm25(artist_fts,"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to ftsTable.rank for unknown tables", func() {
|
|
||||||
strategy := newFTSSearch("unknown_table", "test")
|
|
||||||
fts, ok := strategy.(*ftsSearch)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(fts.rankExpr).To(Equal("unknown_table_fts.rank"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("wraps query with column filter for known tables", func() {
|
|
||||||
strategy := newFTSSearch("artist", "Beatles")
|
|
||||||
fts, ok := strategy.(*ftsSearch)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("passes query without column filter for unknown tables", func() {
|
|
||||||
strategy := newFTSSearch("unknown_table", "test")
|
|
||||||
fts, ok := strategy.(*ftsSearch)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(fts.matchExpr).To(Equal("test*"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("preserves phrase queries inside column filter", func() {
|
|
||||||
strategy := newFTSSearch("media_file", `"the beatles"`)
|
|
||||||
fts, ok := strategy.(*ftsSearch)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(fts.matchExpr).To(ContainSubstring(`"the beatles"`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("preserves prefix queries inside column filter", func() {
|
|
||||||
strategy := newFTSSearch("media_file", "beat*")
|
|
||||||
fts, ok := strategy.(*ftsSearch)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(fts.matchExpr).To(ContainSubstring("beat*"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to LIKE search for punctuation-only query", func() {
|
|
||||||
strategy := newFTSSearch("media_file", "!!!!!!!")
|
|
||||||
Expect(strategy).ToNot(BeNil())
|
|
||||||
_, ok := strategy.(*ftsSearch)
|
|
||||||
Expect(ok).To(BeFalse(), "punctuation-only should fall back to LIKE, not FTS")
|
|
||||||
sql, args, err := strategy.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("LIKE"))
|
|
||||||
Expect(args).To(ContainElement("%!!!!!!!%"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to LIKE search for degraded query (special chars stripped leaving short tokens)", func() {
|
|
||||||
strategy := newFTSSearch("album", "1+")
|
|
||||||
Expect(strategy).ToNot(BeNil())
|
|
||||||
_, ok := strategy.(*ftsSearch)
|
|
||||||
Expect(ok).To(BeFalse(), "degraded query should fall back to LIKE, not FTS")
|
|
||||||
sql, args, err := strategy.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("LIKE"))
|
|
||||||
Expect(args).To(ContainElement("%1+%"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns nil for empty string even with LIKE fallback", func() {
|
|
||||||
Expect(newFTSSearch("media_file", "")).To(BeNil())
|
|
||||||
Expect(newFTSSearch("media_file", " ")).To(BeNil())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns nil for empty quoted phrase", func() {
|
|
||||||
Expect(newFTSSearch("media_file", `""`)).To(BeNil())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("FTS5 Integration Search", func() {
|
|
||||||
var (
|
|
||||||
mr model.MediaFileRepository
|
|
||||||
alr model.AlbumRepository
|
|
||||||
arr model.ArtistRepository
|
|
||||||
)
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
ctx := log.NewContext(context.TODO())
|
|
||||||
ctx = request.WithUser(ctx, adminUser)
|
|
||||||
conn := GetDBXBuilder()
|
|
||||||
mr = NewMediaFileRepository(ctx, conn)
|
|
||||||
alr = NewAlbumRepository(ctx, conn)
|
|
||||||
arr = NewArtistRepository(ctx, conn)
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("MediaFile search", func() {
|
|
||||||
It("finds media files by title", func() {
|
|
||||||
results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).To(HaveLen(1))
|
|
||||||
Expect(results[0].Title).To(Equal("Radioactivity"))
|
|
||||||
Expect(results[0].ID).To(Equal(songRadioactivity.ID))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("finds media files by artist name", func() {
|
|
||||||
results, err := mr.Search("Beatles", model.QueryOptions{Max: 10})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).To(HaveLen(3))
|
|
||||||
for _, r := range results {
|
|
||||||
Expect(r.Artist).To(Equal("The Beatles"))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Album search", func() {
|
|
||||||
It("finds albums by name", func() {
|
|
||||||
results, err := alr.Search("Sgt Peppers", model.QueryOptions{Max: 10})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).To(HaveLen(1))
|
|
||||||
Expect(results[0].Name).To(Equal("Sgt Peppers"))
|
|
||||||
Expect(results[0].ID).To(Equal(albumSgtPeppers.ID))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("finds albums with multi-word search", func() {
|
|
||||||
results, err := alr.Search("Abbey Road", model.QueryOptions{Max: 10})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).To(HaveLen(2))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Artist search", func() {
|
|
||||||
It("finds artists by name", func() {
|
|
||||||
results, err := arr.Search("Kraftwerk", model.QueryOptions{Max: 10})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).To(HaveLen(1))
|
|
||||||
Expect(results[0].Name).To(Equal("Kraftwerk"))
|
|
||||||
Expect(results[0].ID).To(Equal(artistKraftwerk.ID))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("CJK search", func() {
|
|
||||||
It("finds media files by CJK title", func() {
|
|
||||||
results, err := mr.Search("プラチナ", model.QueryOptions{Max: 10})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).To(HaveLen(1))
|
|
||||||
Expect(results[0].Title).To(Equal("プラチナ・ジェット"))
|
|
||||||
Expect(results[0].ID).To(Equal(songCJK.ID))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("finds media files by CJK artist name", func() {
|
|
||||||
results, err := mr.Search("シートベルツ", model.QueryOptions{Max: 10})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).To(HaveLen(1))
|
|
||||||
Expect(results[0].Artist).To(Equal("シートベルツ"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("finds albums by CJK artist name", func() {
|
|
||||||
results, err := alr.Search("シートベルツ", model.QueryOptions{Max: 10})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).To(HaveLen(1))
|
|
||||||
Expect(results[0].Name).To(Equal("COWBOY BEBOP"))
|
|
||||||
Expect(results[0].ID).To(Equal(albumCJK.ID))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("finds artists by CJK name", func() {
|
|
||||||
results, err := arr.Search("シートベルツ", model.QueryOptions{Max: 10})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).To(HaveLen(1))
|
|
||||||
Expect(results[0].Name).To(Equal("シートベルツ"))
|
|
||||||
Expect(results[0].ID).To(Equal(artistCJK.ID))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Album version search", func() {
|
|
||||||
It("finds albums by version tag via FTS", func() {
|
|
||||||
results, err := alr.Search("Deluxe", model.QueryOptions{Max: 10})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).To(HaveLen(1))
|
|
||||||
Expect(results[0].ID).To(Equal(albumWithVersion.ID))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Punctuation-only search", func() {
|
|
||||||
It("finds media files with punctuation-only title", func() {
|
|
||||||
results, err := mr.Search("!!!!!!!", model.QueryOptions{Max: 10})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).To(HaveLen(1))
|
|
||||||
Expect(results[0].Title).To(Equal("!!!!!!!"))
|
|
||||||
Expect(results[0].ID).To(Equal(songPunctuation.ID))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Single-character search (doSearch min-length guard)", func() {
|
|
||||||
It("returns empty results for single-char query via Search", func() {
|
|
||||||
results, err := mr.Search("a", model.QueryOptions{Max: 10})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).To(BeEmpty(), "doSearch should reject single-char queries")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Max=0 means no limit (regression: must not produce LIMIT 0)", func() {
|
|
||||||
It("returns results with Max=0", func() {
|
|
||||||
results, err := mr.Search("Beatles", model.QueryOptions{Max: 0})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package persistence
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
. "github.com/Masterminds/squirrel"
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/utils/str"
|
|
||||||
)
|
|
||||||
|
|
||||||
// likeSearch implements searchStrategy using LIKE-based SQL filters.
|
|
||||||
// Used for legacy full_text searches, CJK fallback, and punctuation-only fallback.
|
|
||||||
type likeSearch struct {
|
|
||||||
filter Sqlizer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *likeSearch) ToSql() (string, []interface{}, error) {
|
|
||||||
return s.filter.ToSql()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *likeSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error {
|
|
||||||
sq = sq.Where(s.filter)
|
|
||||||
sq = sq.OrderBy(cfg.OrderBy...)
|
|
||||||
return r.queryAll(sq, dest, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newLegacySearch creates a LIKE search against the full_text column.
|
|
||||||
// Returns nil when the query produces no searchable tokens.
|
|
||||||
func newLegacySearch(tableName, query string) searchStrategy {
|
|
||||||
filter := legacySearchExpr(tableName, query)
|
|
||||||
if filter == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &likeSearch{filter: filter}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newLikeSearch creates a LIKE search against core entity columns (CJK, punctuation fallback).
|
|
||||||
// No minimum length is enforced, since single CJK characters are meaningful words.
|
|
||||||
// Returns nil when the query produces no searchable tokens.
|
|
||||||
func newLikeSearch(tableName, query string) searchStrategy {
|
|
||||||
filter := likeSearchExpr(tableName, query)
|
|
||||||
if filter == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &likeSearch{filter: filter}
|
|
||||||
}
|
|
||||||
|
|
||||||
// legacySearchExpr generates LIKE-based search filters against the full_text column.
|
|
||||||
// This is the original search implementation, used when Search.Backend="legacy".
|
|
||||||
func legacySearchExpr(tableName string, s string) Sqlizer {
|
|
||||||
q := str.SanitizeStrings(s)
|
|
||||||
if q == "" {
|
|
||||||
log.Trace("Search using legacy backend, query is empty", "table", tableName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var sep string
|
|
||||||
if !conf.Server.Search.FullString {
|
|
||||||
sep = " "
|
|
||||||
}
|
|
||||||
parts := strings.Split(q, " ")
|
|
||||||
filters := And{}
|
|
||||||
for _, part := range parts {
|
|
||||||
filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"})
|
|
||||||
}
|
|
||||||
log.Trace("Search using legacy backend", "query", filters, "table", tableName)
|
|
||||||
return filters
|
|
||||||
}
|
|
||||||
|
|
||||||
// likeSearchColumns defines the core columns to search with LIKE queries.
|
|
||||||
// These are the primary user-visible fields for each entity type.
|
|
||||||
// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
|
|
||||||
var likeSearchColumns = map[string][]string{
|
|
||||||
"media_file": {"title", "album", "artist", "album_artist"},
|
|
||||||
"album": {"name", "album_artist"},
|
|
||||||
"artist": {"name"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// likeSearchExpr generates LIKE-based search filters against core columns.
|
|
||||||
// Each word in the query must match at least one column (AND between words),
|
|
||||||
// and each word can match any column (OR within a word).
|
|
||||||
// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
|
|
||||||
func likeSearchExpr(tableName string, s string) Sqlizer {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if s == "" {
|
|
||||||
log.Trace("Search using LIKE backend, query is empty", "table", tableName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
columns, ok := likeSearchColumns[tableName]
|
|
||||||
if !ok {
|
|
||||||
log.Trace("Search using LIKE backend, couldn't find columns for this table", "table", tableName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
words := strings.Fields(s)
|
|
||||||
wordFilters := And{}
|
|
||||||
for _, word := range words {
|
|
||||||
colFilters := Or{}
|
|
||||||
for _, col := range columns {
|
|
||||||
colFilters = append(colFilters, Like{tableName + "." + col: "%" + word + "%"})
|
|
||||||
}
|
|
||||||
wordFilters = append(wordFilters, colFilters)
|
|
||||||
}
|
|
||||||
log.Trace("Search using LIKE backend", "query", wordFilters, "table", tableName)
|
|
||||||
return wordFilters
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
package persistence
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("newLegacySearch", func() {
|
|
||||||
It("returns non-nil for single-character query", func() {
|
|
||||||
strategy := newLegacySearch("media_file", "a")
|
|
||||||
Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here")
|
|
||||||
sql, _, err := strategy.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("LIKE"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("legacySearchExpr", func() {
|
|
||||||
It("returns nil for empty query", func() {
|
|
||||||
Expect(legacySearchExpr("media_file", "")).To(BeNil())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("generates LIKE filter for single word", func() {
|
|
||||||
expr := legacySearchExpr("media_file", "beatles")
|
|
||||||
sql, args, err := expr.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("media_file.full_text LIKE"))
|
|
||||||
Expect(args).To(ContainElement("% beatles%"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("generates AND of LIKE filters for multiple words", func() {
|
|
||||||
expr := legacySearchExpr("media_file", "abbey road")
|
|
||||||
sql, args, err := expr.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("AND"))
|
|
||||||
Expect(args).To(HaveLen(2))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("likeSearchExpr", func() {
|
|
||||||
It("returns nil for empty query", func() {
|
|
||||||
Expect(likeSearchExpr("media_file", "")).To(BeNil())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns nil for whitespace-only query", func() {
|
|
||||||
Expect(likeSearchExpr("media_file", " ")).To(BeNil())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("generates LIKE filters against core columns for single CJK word", func() {
|
|
||||||
expr := likeSearchExpr("media_file", "周杰伦")
|
|
||||||
sql, args, err := expr.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
// Should have OR between columns for the single word
|
|
||||||
Expect(sql).To(ContainSubstring("OR"))
|
|
||||||
Expect(sql).To(ContainSubstring("media_file.title LIKE"))
|
|
||||||
Expect(sql).To(ContainSubstring("media_file.album LIKE"))
|
|
||||||
Expect(sql).To(ContainSubstring("media_file.artist LIKE"))
|
|
||||||
Expect(sql).To(ContainSubstring("media_file.album_artist LIKE"))
|
|
||||||
Expect(args).To(HaveLen(4))
|
|
||||||
for _, arg := range args {
|
|
||||||
Expect(arg).To(Equal("%周杰伦%"))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("generates AND of OR groups for multi-word query", func() {
|
|
||||||
expr := likeSearchExpr("media_file", "周杰伦 greatest")
|
|
||||||
sql, args, err := expr.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
// Two groups AND'd together, each with 4 columns OR'd
|
|
||||||
Expect(sql).To(ContainSubstring("AND"))
|
|
||||||
Expect(args).To(HaveLen(8))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("uses correct columns for album table", func() {
|
|
||||||
expr := likeSearchExpr("album", "周杰伦")
|
|
||||||
sql, args, err := expr.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("album.name LIKE"))
|
|
||||||
Expect(sql).To(ContainSubstring("album.album_artist LIKE"))
|
|
||||||
Expect(args).To(HaveLen(2))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("uses correct columns for artist table", func() {
|
|
||||||
expr := likeSearchExpr("artist", "周杰伦")
|
|
||||||
sql, args, err := expr.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("artist.name LIKE"))
|
|
||||||
Expect(args).To(HaveLen(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns nil for unknown table", func() {
|
|
||||||
Expect(likeSearchExpr("unknown_table", "周杰伦")).To(BeNil())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("Legacy Integration Search", func() {
|
|
||||||
var mr model.MediaFileRepository
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.Search.Backend = "legacy"
|
|
||||||
|
|
||||||
ctx := log.NewContext(context.TODO())
|
|
||||||
ctx = request.WithUser(ctx, adminUser)
|
|
||||||
conn := GetDBXBuilder()
|
|
||||||
mr = NewMediaFileRepository(ctx, conn)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns results using legacy LIKE-based search", func() {
|
|
||||||
results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).To(HaveLen(1))
|
|
||||||
Expect(results[0].Title).To(Equal("Radioactivity"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns empty results for single-char query (doSearch min-length guard)", func() {
|
|
||||||
results, err := mr.Search("a", model.QueryOptions{Max: 10})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).To(BeEmpty(), "doSearch should reject single-char queries")
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns results with Max=0 (regression: must not produce LIMIT 0)", func() {
|
|
||||||
results, err := mr.Search("Beatles", model.QueryOptions{Max: 0})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package persistence
|
package persistence
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@@ -13,100 +11,4 @@ var _ = Describe("sqlRepository", func() {
|
|||||||
Expect(formatFullText("legiao urbana")).To(Equal(" legiao urbana"))
|
Expect(formatFullText("legiao urbana")).To(Equal(" legiao urbana"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("getSearchStrategy", func() {
|
|
||||||
It("returns FTS strategy by default", func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.Search.Backend = "fts"
|
|
||||||
conf.Server.Search.FullString = false
|
|
||||||
|
|
||||||
strategy := getSearchStrategy("media_file", "test")
|
|
||||||
Expect(strategy).ToNot(BeNil())
|
|
||||||
sql, _, err := strategy.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("MATCH"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns legacy LIKE strategy when SearchBackend is legacy", func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.Search.Backend = "legacy"
|
|
||||||
conf.Server.Search.FullString = false
|
|
||||||
|
|
||||||
strategy := getSearchStrategy("media_file", "test")
|
|
||||||
Expect(strategy).ToNot(BeNil())
|
|
||||||
sql, _, err := strategy.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("LIKE"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to legacy LIKE strategy when SearchFullString is enabled", func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.Search.Backend = "fts"
|
|
||||||
conf.Server.Search.FullString = true
|
|
||||||
|
|
||||||
strategy := getSearchStrategy("media_file", "test")
|
|
||||||
Expect(strategy).ToNot(BeNil())
|
|
||||||
sql, _, err := strategy.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("LIKE"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("routes CJK queries to LIKE strategy instead of FTS", func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.Search.Backend = "fts"
|
|
||||||
conf.Server.Search.FullString = false
|
|
||||||
|
|
||||||
strategy := getSearchStrategy("media_file", "周杰伦")
|
|
||||||
Expect(strategy).ToNot(BeNil())
|
|
||||||
sql, _, err := strategy.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
// CJK should use LIKE, not MATCH
|
|
||||||
Expect(sql).To(ContainSubstring("LIKE"))
|
|
||||||
Expect(sql).NotTo(ContainSubstring("MATCH"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("routes non-CJK queries to FTS strategy", func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.Search.Backend = "fts"
|
|
||||||
conf.Server.Search.FullString = false
|
|
||||||
|
|
||||||
strategy := getSearchStrategy("media_file", "beatles")
|
|
||||||
Expect(strategy).ToNot(BeNil())
|
|
||||||
sql, _, err := strategy.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(sql).To(ContainSubstring("MATCH"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns non-nil for single-character query (no min-length in strategy)", func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.Search.Backend = "fts"
|
|
||||||
conf.Server.Search.FullString = false
|
|
||||||
|
|
||||||
strategy := getSearchStrategy("media_file", "a")
|
|
||||||
Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by strategies (min-length is enforced in doSearch)")
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns non-nil for single-character query with legacy backend", func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.Search.Backend = "legacy"
|
|
||||||
conf.Server.Search.FullString = false
|
|
||||||
|
|
||||||
strategy := getSearchStrategy("media_file", "a")
|
|
||||||
Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by legacy strategy (min-length is enforced in doSearch)")
|
|
||||||
})
|
|
||||||
|
|
||||||
It("uses legacy for CJK when SearchBackend is legacy", func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.Search.Backend = "legacy"
|
|
||||||
conf.Server.Search.FullString = false
|
|
||||||
|
|
||||||
strategy := getSearchStrategy("media_file", "周杰伦")
|
|
||||||
Expect(strategy).ToNot(BeNil())
|
|
||||||
sql, _, err := strategy.ToSql()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
// Legacy should still use full_text column LIKE
|
|
||||||
Expect(sql).To(ContainSubstring("LIKE"))
|
|
||||||
Expect(sql).To(ContainSubstring("full_text"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package capabilities
|
|
||||||
|
|
||||||
// TaskWorker provides task execution handling.
|
|
||||||
// This capability allows plugins to receive callbacks when their queued tasks
|
|
||||||
// are ready to execute. Plugins that use the taskqueue host service must
|
|
||||||
// implement this capability.
|
|
||||||
//
|
|
||||||
//nd:capability name=taskworker
|
|
||||||
type TaskWorker interface {
|
|
||||||
// OnTaskExecute is called when a queued task is ready to run.
|
|
||||||
// The returned string is a status/result message stored in the tasks table.
|
|
||||||
// Return an error to trigger retry (if retries are configured).
|
|
||||||
//nd:export name=nd_task_execute
|
|
||||||
OnTaskExecute(TaskExecuteRequest) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TaskExecuteRequest is the request provided when a task is ready to execute.
|
|
||||||
type TaskExecuteRequest struct {
|
|
||||||
// QueueName is the name of the queue this task belongs to.
|
|
||||||
QueueName string `json:"queueName"`
|
|
||||||
// TaskID is the unique identifier for this task.
|
|
||||||
TaskID string `json:"taskId"`
|
|
||||||
// Payload is the opaque data provided when the task was enqueued.
|
|
||||||
Payload []byte `json:"payload"`
|
|
||||||
// Attempt is the current attempt number (1-based: first attempt = 1).
|
|
||||||
Attempt int32 `json:"attempt"`
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
version: v1-draft
|
|
||||||
exports:
|
|
||||||
nd_task_execute:
|
|
||||||
description: |-
|
|
||||||
OnTaskExecute is called when a queued task is ready to run.
|
|
||||||
The returned string is a status/result message stored in the tasks table.
|
|
||||||
Return an error to trigger retry (if retries are configured).
|
|
||||||
input:
|
|
||||||
$ref: '#/components/schemas/TaskExecuteRequest'
|
|
||||||
contentType: application/json
|
|
||||||
output:
|
|
||||||
type: string
|
|
||||||
contentType: application/json
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
TaskExecuteRequest:
|
|
||||||
description: TaskExecuteRequest is the request provided when a task is ready to execute.
|
|
||||||
properties:
|
|
||||||
queueName:
|
|
||||||
type: string
|
|
||||||
description: QueueName is the name of the queue this task belongs to.
|
|
||||||
taskId:
|
|
||||||
type: string
|
|
||||||
description: TaskID is the unique identifier for this task.
|
|
||||||
payload:
|
|
||||||
type: array
|
|
||||||
description: Payload is the opaque data provided when the task was enqueued.
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
attempt:
|
|
||||||
type: integer
|
|
||||||
format: int32
|
|
||||||
description: 'Attempt is the current attempt number (1-based: first attempt = 1).'
|
|
||||||
required:
|
|
||||||
- queueName
|
|
||||||
- taskId
|
|
||||||
- payload
|
|
||||||
- attempt
|
|
||||||
@@ -282,6 +282,9 @@ type ServiceB interface {
|
|||||||
|
|
||||||
Entry("option pattern (value, exists bool)",
|
Entry("option pattern (value, exists bool)",
|
||||||
"config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"),
|
"config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"),
|
||||||
|
|
||||||
|
Entry("raw=true binary response",
|
||||||
|
"raw_service.go.txt", "raw_client_expected.go.txt", "raw_client_expected.py", "raw_client_expected.rs"),
|
||||||
)
|
)
|
||||||
|
|
||||||
It("generates compilable client code for comprehensive service", func() {
|
It("generates compilable client code for comprehensive service", func() {
|
||||||
|
|||||||
@@ -256,15 +256,6 @@ func GenerateClientRust(svc Service) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("parsing template: %w", err)
|
return nil, fmt.Errorf("parsing template: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("reading base64_bytes partial: %w", err)
|
|
||||||
}
|
|
||||||
tmpl, err = tmpl.Parse(string(partialContent))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing base64_bytes partial: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data := templateData{
|
data := templateData{
|
||||||
Service: svc,
|
Service: svc,
|
||||||
}
|
}
|
||||||
@@ -631,15 +622,6 @@ func GenerateCapabilityRust(cap Capability) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("parsing template: %w", err)
|
return nil, fmt.Errorf("parsing template: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("reading base64_bytes partial: %w", err)
|
|
||||||
}
|
|
||||||
tmpl, err = tmpl.Parse(string(partialContent))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing base64_bytes partial: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data := capabilityTemplateData{
|
data := capabilityTemplateData{
|
||||||
Package: cap.Name,
|
Package: cap.Name,
|
||||||
Capability: cap,
|
Capability: cap,
|
||||||
|
|||||||
@@ -264,6 +264,96 @@ var _ = Describe("Generator", func() {
|
|||||||
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
|
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("should generate binary framing for raw=true methods", func() {
|
||||||
|
svc := Service{
|
||||||
|
Name: "Stream",
|
||||||
|
Permission: "stream",
|
||||||
|
Interface: "StreamService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "GetStream",
|
||||||
|
HasError: true,
|
||||||
|
Raw: true,
|
||||||
|
Params: []Param{NewParam("uri", "string")},
|
||||||
|
Returns: []Param{
|
||||||
|
NewParam("contentType", "string"),
|
||||||
|
NewParam("data", "[]byte"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateHost(svc, "host")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
_, err = format.Source(code)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
|
||||||
|
// Should include encoding/binary import for raw methods
|
||||||
|
Expect(codeStr).To(ContainSubstring(`"encoding/binary"`))
|
||||||
|
|
||||||
|
// Should NOT generate a response type for raw methods
|
||||||
|
Expect(codeStr).NotTo(ContainSubstring("type StreamGetStreamResponse struct"))
|
||||||
|
|
||||||
|
// Should generate request type (request is still JSON)
|
||||||
|
Expect(codeStr).To(ContainSubstring("type StreamGetStreamRequest struct"))
|
||||||
|
|
||||||
|
// Should build binary frame [0x00][4-byte CT len][CT][data]
|
||||||
|
Expect(codeStr).To(ContainSubstring("frame[0] = 0x00"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("binary.BigEndian.PutUint32"))
|
||||||
|
|
||||||
|
// Should have writeRawError helper
|
||||||
|
Expect(codeStr).To(ContainSubstring("streamWriteRawError"))
|
||||||
|
|
||||||
|
// Should use writeRawError instead of writeError for raw methods
|
||||||
|
Expect(codeStr).To(ContainSubstring("streamWriteRawError(p, stack"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should generate both writeError and writeRawError for mixed services", func() {
|
||||||
|
svc := Service{
|
||||||
|
Name: "API",
|
||||||
|
Permission: "api",
|
||||||
|
Interface: "APIService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "Call",
|
||||||
|
HasError: true,
|
||||||
|
Params: []Param{NewParam("uri", "string")},
|
||||||
|
Returns: []Param{NewParam("response", "string")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "CallRaw",
|
||||||
|
HasError: true,
|
||||||
|
Raw: true,
|
||||||
|
Params: []Param{NewParam("uri", "string")},
|
||||||
|
Returns: []Param{
|
||||||
|
NewParam("contentType", "string"),
|
||||||
|
NewParam("data", "[]byte"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateHost(svc, "host")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
_, err = format.Source(code)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
|
||||||
|
// Should have both helpers
|
||||||
|
Expect(codeStr).To(ContainSubstring("apiWriteResponse"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("apiWriteError"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("apiWriteRawError"))
|
||||||
|
|
||||||
|
// Should generate response type for non-raw method only
|
||||||
|
Expect(codeStr).To(ContainSubstring("type APICallResponse struct"))
|
||||||
|
Expect(codeStr).NotTo(ContainSubstring("type APICallRawResponse struct"))
|
||||||
|
})
|
||||||
|
|
||||||
It("should always include json import for JSON protocol", func() {
|
It("should always include json import for JSON protocol", func() {
|
||||||
// All services use JSON protocol, so json import is always needed
|
// All services use JSON protocol, so json import is always needed
|
||||||
svc := Service{
|
svc := Service{
|
||||||
@@ -627,7 +717,49 @@ var _ = Describe("Generator", func() {
|
|||||||
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
|
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should not import base64 for non-byte services", func() {
|
It("should generate binary frame parsing for raw methods", func() {
|
||||||
|
svc := Service{
|
||||||
|
Name: "Stream",
|
||||||
|
Permission: "stream",
|
||||||
|
Interface: "StreamService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "GetStream",
|
||||||
|
HasError: true,
|
||||||
|
Raw: true,
|
||||||
|
Params: []Param{NewParam("uri", "string")},
|
||||||
|
Returns: []Param{
|
||||||
|
NewParam("contentType", "string"),
|
||||||
|
NewParam("data", "[]byte"),
|
||||||
|
},
|
||||||
|
Doc: "GetStream returns raw binary stream data.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateClientPython(svc)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
|
||||||
|
// Should import Tuple and struct for raw methods
|
||||||
|
Expect(codeStr).To(ContainSubstring("from typing import Any, Tuple"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("import struct"))
|
||||||
|
|
||||||
|
// Should return Tuple[str, bytes]
|
||||||
|
Expect(codeStr).To(ContainSubstring("-> Tuple[str, bytes]:"))
|
||||||
|
|
||||||
|
// Should parse binary frame instead of JSON
|
||||||
|
Expect(codeStr).To(ContainSubstring("response_bytes = response_mem.bytes()"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("struct.unpack"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("return content_type, data"))
|
||||||
|
|
||||||
|
// Should NOT use json.loads for response
|
||||||
|
Expect(codeStr).NotTo(ContainSubstring("json.loads(extism.memory.string(response_mem))"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should not import Tuple or struct for non-raw services", func() {
|
||||||
svc := Service{
|
svc := Service{
|
||||||
Name: "Test",
|
Name: "Test",
|
||||||
Permission: "test",
|
Permission: "test",
|
||||||
@@ -647,37 +779,8 @@ var _ = Describe("Generator", func() {
|
|||||||
|
|
||||||
codeStr := string(code)
|
codeStr := string(code)
|
||||||
|
|
||||||
Expect(codeStr).NotTo(ContainSubstring("import base64"))
|
Expect(codeStr).NotTo(ContainSubstring("Tuple"))
|
||||||
})
|
Expect(codeStr).NotTo(ContainSubstring("import struct"))
|
||||||
|
|
||||||
It("should generate base64 encoding/decoding for byte fields", func() {
|
|
||||||
svc := Service{
|
|
||||||
Name: "Codec",
|
|
||||||
Permission: "codec",
|
|
||||||
Interface: "CodecService",
|
|
||||||
Methods: []Method{
|
|
||||||
{
|
|
||||||
Name: "Encode",
|
|
||||||
HasError: true,
|
|
||||||
Params: []Param{NewParam("data", "[]byte")},
|
|
||||||
Returns: []Param{NewParam("result", "[]byte")},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
code, err := GenerateClientPython(svc)
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
|
|
||||||
codeStr := string(code)
|
|
||||||
|
|
||||||
// Should import base64
|
|
||||||
Expect(codeStr).To(ContainSubstring("import base64"))
|
|
||||||
|
|
||||||
// Should base64-encode byte params in request
|
|
||||||
Expect(codeStr).To(ContainSubstring(`base64.b64encode(data).decode("ascii")`))
|
|
||||||
|
|
||||||
// Should base64-decode byte returns in response
|
|
||||||
Expect(codeStr).To(ContainSubstring(`base64.b64decode(response.get("result", ""))`))
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -836,6 +939,46 @@ var _ = Describe("Generator", func() {
|
|||||||
Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk"))
|
Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("should include encoding/binary import for raw methods", func() {
|
||||||
|
svc := Service{
|
||||||
|
Name: "Stream",
|
||||||
|
Permission: "stream",
|
||||||
|
Interface: "StreamService",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Name: "GetStream",
|
||||||
|
HasError: true,
|
||||||
|
Raw: true,
|
||||||
|
Params: []Param{NewParam("uri", "string")},
|
||||||
|
Returns: []Param{
|
||||||
|
NewParam("contentType", "string"),
|
||||||
|
NewParam("data", "[]byte"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateClientGo(svc, "host")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
codeStr := string(code)
|
||||||
|
|
||||||
|
// Should include encoding/binary for raw binary frame parsing
|
||||||
|
Expect(codeStr).To(ContainSubstring(`"encoding/binary"`))
|
||||||
|
|
||||||
|
// Should NOT generate response type struct for raw methods
|
||||||
|
Expect(codeStr).NotTo(ContainSubstring("streamGetStreamResponse struct"))
|
||||||
|
|
||||||
|
// Should still generate request type
|
||||||
|
Expect(codeStr).To(ContainSubstring("streamGetStreamRequest struct"))
|
||||||
|
|
||||||
|
// Should parse binary frame
|
||||||
|
Expect(codeStr).To(ContainSubstring("responseBytes[0] == 0x01"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("binary.BigEndian.Uint32"))
|
||||||
|
|
||||||
|
// Should return (string, []byte, error)
|
||||||
|
Expect(codeStr).To(ContainSubstring("func StreamGetStream(uri string) (string, []byte, error)"))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("GenerateClientGoStub", func() {
|
Describe("GenerateClientGoStub", func() {
|
||||||
@@ -1605,45 +1748,22 @@ var _ = Describe("Rust Generation", func() {
|
|||||||
Expect(codeStr).NotTo(ContainSubstring("Option<bool>"))
|
Expect(codeStr).NotTo(ContainSubstring("Option<bool>"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should generate base64 serde for Vec<u8> fields", func() {
|
It("should generate raw extern C import and binary frame parsing for raw methods", func() {
|
||||||
svc := Service{
|
svc := Service{
|
||||||
Name: "Codec",
|
Name: "Stream",
|
||||||
Permission: "codec",
|
Permission: "stream",
|
||||||
Interface: "CodecService",
|
Interface: "StreamService",
|
||||||
Methods: []Method{
|
Methods: []Method{
|
||||||
{
|
{
|
||||||
Name: "Encode",
|
Name: "GetStream",
|
||||||
HasError: true,
|
|
||||||
Params: []Param{NewParam("data", "[]byte")},
|
|
||||||
Returns: []Param{NewParam("result", "[]byte")},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
code, err := GenerateClientRust(svc)
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
|
||||||
|
|
||||||
codeStr := string(code)
|
|
||||||
|
|
||||||
// Should generate base64_bytes serde module
|
|
||||||
Expect(codeStr).To(ContainSubstring("mod base64_bytes"))
|
|
||||||
Expect(codeStr).To(ContainSubstring("use base64::Engine as _"))
|
|
||||||
|
|
||||||
// Should add serde(with = "base64_bytes") on Vec<u8> fields
|
|
||||||
Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("should not generate base64 module when no byte fields", func() {
|
|
||||||
svc := Service{
|
|
||||||
Name: "Test",
|
|
||||||
Permission: "test",
|
|
||||||
Interface: "TestService",
|
|
||||||
Methods: []Method{
|
|
||||||
{
|
|
||||||
Name: "Call",
|
|
||||||
HasError: true,
|
HasError: true,
|
||||||
|
Raw: true,
|
||||||
Params: []Param{NewParam("uri", "string")},
|
Params: []Param{NewParam("uri", "string")},
|
||||||
Returns: []Param{NewParam("response", "string")},
|
Returns: []Param{
|
||||||
|
NewParam("contentType", "string"),
|
||||||
|
NewParam("data", "[]byte"),
|
||||||
|
},
|
||||||
|
Doc: "GetStream returns raw binary stream data.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1653,8 +1773,24 @@ var _ = Describe("Rust Generation", func() {
|
|||||||
|
|
||||||
codeStr := string(code)
|
codeStr := string(code)
|
||||||
|
|
||||||
Expect(codeStr).NotTo(ContainSubstring("mod base64_bytes"))
|
// Should use extern "C" with wasm_import_module for raw methods, not #[host_fn] extern "ExtismHost"
|
||||||
Expect(codeStr).NotTo(ContainSubstring("use base64"))
|
Expect(codeStr).To(ContainSubstring(`#[link(wasm_import_module = "extism:host/user")]`))
|
||||||
|
Expect(codeStr).To(ContainSubstring(`extern "C"`))
|
||||||
|
Expect(codeStr).To(ContainSubstring("fn stream_getstream(offset: u64) -> u64"))
|
||||||
|
|
||||||
|
// Should NOT generate response type for raw methods
|
||||||
|
Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse"))
|
||||||
|
|
||||||
|
// Should generate request type (request is still JSON)
|
||||||
|
Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest"))
|
||||||
|
|
||||||
|
// Should return Result<(String, Vec<u8>), Error>
|
||||||
|
Expect(codeStr).To(ContainSubstring("Result<(String, Vec<u8>), Error>"))
|
||||||
|
|
||||||
|
// Should parse binary frame
|
||||||
|
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("u32::from_be_bytes"))
|
||||||
|
Expect(codeStr).To(ContainSubstring("String::from_utf8_lossy"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -761,6 +761,7 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
|
|||||||
m := Method{
|
m := Method{
|
||||||
Name: name,
|
Name: name,
|
||||||
ExportName: annotation["name"],
|
ExportName: annotation["name"],
|
||||||
|
Raw: annotation["raw"] == "true",
|
||||||
Doc: doc,
|
Doc: doc,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,6 +800,13 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate raw=true methods: must return exactly (string, []byte, error)
|
||||||
|
if m.Raw {
|
||||||
|
if !m.HasError || len(m.Returns) != 2 || m.Returns[0].Type != "string" || m.Returns[1].Type != "[]byte" {
|
||||||
|
return m, fmt.Errorf("raw=true method %s must return (string, []byte, error) — content-type, data, error", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,119 @@ type TestService interface {
|
|||||||
Expect(services[0].Methods[0].Name).To(Equal("Exported"))
|
Expect(services[0].Methods[0].Name).To(Equal("Exported"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("should parse raw=true annotation", func() {
|
||||||
|
src := `package host
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=Stream permission=stream
|
||||||
|
type StreamService interface {
|
||||||
|
//nd:hostfunc raw=true
|
||||||
|
GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "stream.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
services, err := ParseDirectory(tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(services).To(HaveLen(1))
|
||||||
|
|
||||||
|
m := services[0].Methods[0]
|
||||||
|
Expect(m.Name).To(Equal("GetStream"))
|
||||||
|
Expect(m.Raw).To(BeTrue())
|
||||||
|
Expect(m.HasError).To(BeTrue())
|
||||||
|
Expect(m.Returns).To(HaveLen(2))
|
||||||
|
Expect(m.Returns[0].Name).To(Equal("contentType"))
|
||||||
|
Expect(m.Returns[0].Type).To(Equal("string"))
|
||||||
|
Expect(m.Returns[1].Name).To(Equal("data"))
|
||||||
|
Expect(m.Returns[1].Type).To(Equal("[]byte"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should set Raw=false when raw annotation is absent", func() {
|
||||||
|
src := `package host
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=Test permission=test
|
||||||
|
type TestService interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
Call(ctx context.Context, uri string) (response string, err error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
services, err := ParseDirectory(tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(services[0].Methods[0].Raw).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should reject raw=true with invalid return signature", func() {
|
||||||
|
src := `package host
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=Test permission=test
|
||||||
|
type TestService interface {
|
||||||
|
//nd:hostfunc raw=true
|
||||||
|
BadRaw(ctx context.Context, uri string) (result string, err error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
_, err = ParseDirectory(tmpDir)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("raw=true"))
|
||||||
|
Expect(err.Error()).To(ContainSubstring("must return (string, []byte, error)"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should reject raw=true without error return", func() {
|
||||||
|
src := `package host
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=Test permission=test
|
||||||
|
type TestService interface {
|
||||||
|
//nd:hostfunc raw=true
|
||||||
|
BadRaw(ctx context.Context, uri string) (contentType string, data []byte)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
_, err = ParseDirectory(tmpDir)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("raw=true"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should parse mixed raw and non-raw methods", func() {
|
||||||
|
src := `package host
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=API permission=api
|
||||||
|
type APIService interface {
|
||||||
|
//nd:hostfunc
|
||||||
|
Call(ctx context.Context, uri string) (responseJSON string, err error)
|
||||||
|
|
||||||
|
//nd:hostfunc raw=true
|
||||||
|
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "api.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
services, err := ParseDirectory(tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(services).To(HaveLen(1))
|
||||||
|
Expect(services[0].Methods).To(HaveLen(2))
|
||||||
|
Expect(services[0].Methods[0].Raw).To(BeFalse())
|
||||||
|
Expect(services[0].Methods[1].Raw).To(BeTrue())
|
||||||
|
Expect(services[0].HasRawMethods()).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
It("should handle custom export name", func() {
|
It("should handle custom export name", func() {
|
||||||
src := `package host
|
src := `package host
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
{{define "base64_bytes_module"}}
|
|
||||||
use base64::Engine as _;
|
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
||||||
|
|
||||||
mod base64_bytes {
|
|
||||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
|
||||||
use base64::Engine as _;
|
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
||||||
|
|
||||||
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
serializer.serialize_str(&BASE64.encode(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let s = String::deserialize(deserializer)?;
|
|
||||||
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
{{- end}}
|
|
||||||
@@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
{{- if hasHashMap .Capability}}
|
{{- if hasHashMap .Capability}}
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if .Capability.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}}
|
|
||||||
|
|
||||||
// Helper functions for skip_serializing_if with numeric types
|
// Helper functions for skip_serializing_if with numeric types
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -71,9 +70,6 @@ pub struct {{.Name}} {
|
|||||||
#[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")]
|
#[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")]
|
||||||
{{- else}}
|
{{- else}}
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
{{- end}}
|
|
||||||
{{- if .IsByteSlice}}
|
|
||||||
#[serde(with = "base64_bytes")]
|
|
||||||
{{- end}}
|
{{- end}}
|
||||||
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
package {{.Package}}
|
package {{.Package}}
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
{{- if .Service.HasRawMethods}}
|
||||||
|
"encoding/binary"
|
||||||
|
{{- end}}
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
{{- if .Service.HasErrors}}
|
{{- if .Service.HasErrors}}
|
||||||
"errors"
|
"errors"
|
||||||
@@ -49,7 +52,7 @@ type {{requestType .}} struct {
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if not .IsErrorOnly}}
|
{{- if and (not .IsErrorOnly) (not .Raw)}}
|
||||||
|
|
||||||
type {{responseType .}} struct {
|
type {{responseType .}} struct {
|
||||||
{{- range .Returns}}
|
{{- range .Returns}}
|
||||||
@@ -95,7 +98,27 @@ func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{
|
|||||||
// Read the response from memory
|
// Read the response from memory
|
||||||
responseMem := pdk.FindMemory(responsePtr)
|
responseMem := pdk.FindMemory(responsePtr)
|
||||||
responseBytes := responseMem.ReadBytes()
|
responseBytes := responseMem.ReadBytes()
|
||||||
{{- if .IsErrorOnly}}
|
{{- if .Raw}}
|
||||||
|
|
||||||
|
// Parse binary-framed response
|
||||||
|
if len(responseBytes) == 0 {
|
||||||
|
return "", nil, errors.New("empty response from host")
|
||||||
|
}
|
||||||
|
if responseBytes[0] == 0x01 { // error
|
||||||
|
return "", nil, errors.New(string(responseBytes[1:]))
|
||||||
|
}
|
||||||
|
if responseBytes[0] != 0x00 {
|
||||||
|
return "", nil, errors.New("unknown response status")
|
||||||
|
}
|
||||||
|
if len(responseBytes) < 5 {
|
||||||
|
return "", nil, errors.New("malformed raw response: incomplete header")
|
||||||
|
}
|
||||||
|
ctLen := binary.BigEndian.Uint32(responseBytes[1:5])
|
||||||
|
if uint32(len(responseBytes)) < 5+ctLen {
|
||||||
|
return "", nil, errors.New("malformed raw response: content-type overflow")
|
||||||
|
}
|
||||||
|
return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil
|
||||||
|
{{- else if .IsErrorOnly}}
|
||||||
|
|
||||||
// Parse error-only response
|
// Parse error-only response
|
||||||
var response struct {
|
var response struct {
|
||||||
|
|||||||
@@ -8,12 +8,12 @@
|
|||||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any{{- if .Service.HasRawMethods}}, Tuple{{end}}
|
||||||
|
|
||||||
import extism
|
import extism
|
||||||
import json
|
import json
|
||||||
{{- if .Service.HasByteFields}}
|
{{- if .Service.HasRawMethods}}
|
||||||
import base64
|
import struct
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ def _{{exportName .}}(offset: int) -> int:
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
{{- /* Generate dataclasses for multi-value returns */ -}}
|
{{- /* Generate dataclasses for multi-value returns */ -}}
|
||||||
{{range .Service.Methods}}
|
{{range .Service.Methods}}
|
||||||
{{- if .NeedsResultClass}}
|
{{- if and .NeedsResultClass (not .Raw)}}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -47,7 +47,7 @@ class {{pythonResultType .}}:
|
|||||||
{{range .Service.Methods}}
|
{{range .Service.Methods}}
|
||||||
|
|
||||||
|
|
||||||
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
|
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .Raw}} -> Tuple[str, bytes]{{else if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
|
||||||
"""{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}}
|
"""{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}}
|
||||||
{{- if .HasParams}}
|
{{- if .HasParams}}
|
||||||
|
|
||||||
@@ -56,7 +56,11 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
|||||||
{{.PythonName}}: {{.PythonType}} parameter.
|
{{.PythonName}}: {{.PythonType}} parameter.
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if .HasReturns}}
|
{{- if .Raw}}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (content_type, data) with the raw binary response.
|
||||||
|
{{- else if .HasReturns}}
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{{- if .NeedsResultClass}}
|
{{- if .NeedsResultClass}}
|
||||||
@@ -72,11 +76,7 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
|||||||
{{- if .HasParams}}
|
{{- if .HasParams}}
|
||||||
request = {
|
request = {
|
||||||
{{- range .Params}}
|
{{- range .Params}}
|
||||||
{{- if .IsByteSlice}}
|
|
||||||
"{{.JSONName}}": base64.b64encode({{.PythonName}}).decode("ascii"),
|
|
||||||
{{- else}}
|
|
||||||
"{{.JSONName}}": {{.PythonName}},
|
"{{.JSONName}}": {{.PythonName}},
|
||||||
{{- end}}
|
|
||||||
{{- end}}
|
{{- end}}
|
||||||
}
|
}
|
||||||
request_bytes = json.dumps(request).encode("utf-8")
|
request_bytes = json.dumps(request).encode("utf-8")
|
||||||
@@ -86,6 +86,24 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
|||||||
request_mem = extism.memory.alloc(request_bytes)
|
request_mem = extism.memory.alloc(request_bytes)
|
||||||
response_offset = _{{exportName .}}(request_mem.offset)
|
response_offset = _{{exportName .}}(request_mem.offset)
|
||||||
response_mem = extism.memory.find(response_offset)
|
response_mem = extism.memory.find(response_offset)
|
||||||
|
{{- if .Raw}}
|
||||||
|
response_bytes = response_mem.bytes()
|
||||||
|
|
||||||
|
if len(response_bytes) == 0:
|
||||||
|
raise HostFunctionError("empty response from host")
|
||||||
|
if response_bytes[0] == 0x01:
|
||||||
|
raise HostFunctionError(response_bytes[1:].decode("utf-8"))
|
||||||
|
if response_bytes[0] != 0x00:
|
||||||
|
raise HostFunctionError("unknown response status")
|
||||||
|
if len(response_bytes) < 5:
|
||||||
|
raise HostFunctionError("malformed raw response: incomplete header")
|
||||||
|
ct_len = struct.unpack(">I", response_bytes[1:5])[0]
|
||||||
|
if len(response_bytes) < 5 + ct_len:
|
||||||
|
raise HostFunctionError("malformed raw response: content-type overflow")
|
||||||
|
content_type = response_bytes[5:5 + ct_len].decode("utf-8")
|
||||||
|
data = response_bytes[5 + ct_len:]
|
||||||
|
return content_type, data
|
||||||
|
{{- else}}
|
||||||
response = json.loads(extism.memory.string(response_mem))
|
response = json.loads(extism.memory.string(response_mem))
|
||||||
{{if .HasError}}
|
{{if .HasError}}
|
||||||
if response.get("error"):
|
if response.get("error"):
|
||||||
@@ -94,17 +112,10 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
|||||||
{{- if .NeedsResultClass}}
|
{{- if .NeedsResultClass}}
|
||||||
return {{pythonResultType .}}(
|
return {{pythonResultType .}}(
|
||||||
{{- range .Returns}}
|
{{- range .Returns}}
|
||||||
{{- if .IsByteSlice}}
|
|
||||||
{{.PythonName}}=base64.b64decode(response.get("{{.JSONName}}", "")),
|
|
||||||
{{- else}}
|
|
||||||
{{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}),
|
{{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}),
|
||||||
{{- end}}
|
|
||||||
{{- end}}
|
{{- end}}
|
||||||
)
|
)
|
||||||
{{- else if .HasReturns}}
|
{{- else if .HasReturns}}
|
||||||
{{- if (index .Returns 0).IsByteSlice}}
|
|
||||||
return base64.b64decode(response.get("{{(index .Returns 0).JSONName}}", ""))
|
|
||||||
{{- else}}
|
|
||||||
return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
|
return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
use extism_pdk::*;
|
use extism_pdk::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
{{- if .Service.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}}
|
|
||||||
{{- /* Generate struct definitions */ -}}
|
{{- /* Generate struct definitions */ -}}
|
||||||
{{- range .Service.Structs}}
|
{{- range .Service.Structs}}
|
||||||
{{if .Doc}}
|
{{if .Doc}}
|
||||||
@@ -17,9 +16,6 @@ pub struct {{.Name}} {
|
|||||||
{{- range .Fields}}
|
{{- range .Fields}}
|
||||||
{{- if .NeedsDefault}}
|
{{- if .NeedsDefault}}
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
{{- end}}
|
|
||||||
{{- if .IsByteSlice}}
|
|
||||||
#[serde(with = "base64_bytes")]
|
|
||||||
{{- end}}
|
{{- end}}
|
||||||
pub {{.RustName}}: {{fieldRustType .}},
|
pub {{.RustName}}: {{fieldRustType .}},
|
||||||
{{- end}}
|
{{- end}}
|
||||||
@@ -33,22 +29,17 @@ pub struct {{.Name}} {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct {{requestType .}} {
|
struct {{requestType .}} {
|
||||||
{{- range .Params}}
|
{{- range .Params}}
|
||||||
{{- if .IsByteSlice}}
|
|
||||||
#[serde(with = "base64_bytes")]
|
|
||||||
{{- end}}
|
|
||||||
{{.RustName}}: {{rustType .}},
|
{{.RustName}}: {{rustType .}},
|
||||||
{{- end}}
|
{{- end}}
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- if not .Raw}}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct {{responseType .}} {
|
struct {{responseType .}} {
|
||||||
{{- range .Returns}}
|
{{- range .Returns}}
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
{{- if .IsByteSlice}}
|
|
||||||
#[serde(with = "base64_bytes")]
|
|
||||||
{{- end}}
|
|
||||||
{{.RustName}}: {{rustType .}},
|
{{.RustName}}: {{rustType .}},
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if .HasError}}
|
{{- if .HasError}}
|
||||||
@@ -57,16 +48,92 @@ struct {{responseType .}} {
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
#[host_fn]
|
#[host_fn]
|
||||||
extern "ExtismHost" {
|
extern "ExtismHost" {
|
||||||
{{- range .Service.Methods}}
|
{{- range .Service.Methods}}
|
||||||
|
{{- if not .Raw}}
|
||||||
fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>;
|
fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>;
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
}
|
}
|
||||||
|
{{- /* Declare raw extern "C" imports for raw methods */ -}}
|
||||||
|
{{- range .Service.Methods}}
|
||||||
|
{{- if .Raw}}
|
||||||
|
|
||||||
|
#[link(wasm_import_module = "extism:host/user")]
|
||||||
|
extern "C" {
|
||||||
|
fn {{exportName .}}(offset: u64) -> u64;
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
{{- /* Generate wrapper functions */ -}}
|
{{- /* Generate wrapper functions */ -}}
|
||||||
{{range .Service.Methods}}
|
{{range .Service.Methods}}
|
||||||
|
{{- if .Raw}}
|
||||||
|
|
||||||
|
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
|
||||||
|
{{- if .HasParams}}
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
{{- range .Params}}
|
||||||
|
/// * `{{.RustName}}` - {{rustType .}} parameter.
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// A tuple of (content_type, data) with the raw binary response.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the host function call fails.
|
||||||
|
pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<(String, Vec<u8>), Error> {
|
||||||
|
{{- if .HasParams}}
|
||||||
|
let req = {{requestType .}} {
|
||||||
|
{{- range .Params}}
|
||||||
|
{{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}},
|
||||||
|
{{- end}}
|
||||||
|
};
|
||||||
|
let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?;
|
||||||
|
{{- else}}
|
||||||
|
let input_bytes = b"{}".to_vec();
|
||||||
|
{{- end}}
|
||||||
|
let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?;
|
||||||
|
|
||||||
|
let response_offset = unsafe { {{exportName .}}(input_mem.offset()) };
|
||||||
|
|
||||||
|
let response_mem = Memory::find(response_offset)
|
||||||
|
.ok_or_else(|| Error::msg("empty response from host"))?;
|
||||||
|
let response_bytes = response_mem.to_vec();
|
||||||
|
|
||||||
|
if response_bytes.is_empty() {
|
||||||
|
return Err(Error::msg("empty response from host"));
|
||||||
|
}
|
||||||
|
if response_bytes[0] == 0x01 {
|
||||||
|
let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string();
|
||||||
|
return Err(Error::msg(msg));
|
||||||
|
}
|
||||||
|
if response_bytes[0] != 0x00 {
|
||||||
|
return Err(Error::msg("unknown response status"));
|
||||||
|
}
|
||||||
|
if response_bytes.len() < 5 {
|
||||||
|
return Err(Error::msg("malformed raw response: incomplete header"));
|
||||||
|
}
|
||||||
|
let ct_len = u32::from_be_bytes([
|
||||||
|
response_bytes[1],
|
||||||
|
response_bytes[2],
|
||||||
|
response_bytes[3],
|
||||||
|
response_bytes[4],
|
||||||
|
]) as usize;
|
||||||
|
if ct_len > response_bytes.len() - 5 {
|
||||||
|
return Err(Error::msg("malformed raw response: content-type overflow"));
|
||||||
|
}
|
||||||
|
let ct_end = 5 + ct_len;
|
||||||
|
let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string();
|
||||||
|
let data = response_bytes[ct_end..].to_vec();
|
||||||
|
Ok((content_type, data))
|
||||||
|
}
|
||||||
|
{{- else}}
|
||||||
|
|
||||||
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
|
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
|
||||||
{{- if .HasParams}}
|
{{- if .HasParams}}
|
||||||
@@ -142,3 +209,4 @@ pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName
|
|||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ package {{.Package}}
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
{{- if .Service.HasRawMethods}}
|
||||||
|
"encoding/binary"
|
||||||
|
{{- end}}
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
extism "github.com/extism/go-sdk"
|
extism "github.com/extism/go-sdk"
|
||||||
@@ -20,6 +23,7 @@ type {{requestType .}} struct {
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- if not .Raw}}
|
||||||
|
|
||||||
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
|
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
|
||||||
type {{responseType .}} struct {
|
type {{responseType .}} struct {
|
||||||
@@ -30,6 +34,7 @@ type {{responseType .}} struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
{{- end}}
|
{{- end}}
|
||||||
}
|
}
|
||||||
|
{{- end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
|
// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
|
||||||
@@ -51,18 +56,48 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
|
|||||||
// Read JSON request from plugin memory
|
// Read JSON request from plugin memory
|
||||||
reqBytes, err := p.ReadBytes(stack[0])
|
reqBytes, err := p.ReadBytes(stack[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
{{- if .Raw}}
|
||||||
|
{{$.Service.Name | lower}}WriteRawError(p, stack, err)
|
||||||
|
{{- else}}
|
||||||
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||||
|
{{- end}}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req {{requestType .}}
|
var req {{requestType .}}
|
||||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||||
|
{{- if .Raw}}
|
||||||
|
{{$.Service.Name | lower}}WriteRawError(p, stack, err)
|
||||||
|
{{- else}}
|
||||||
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||||
|
{{- end}}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|
||||||
// Call the service method
|
// Call the service method
|
||||||
{{- if .HasReturns}}
|
{{- if .Raw}}
|
||||||
|
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||||
|
if svcErr != nil {
|
||||||
|
{{$.Service.Name | lower}}WriteRawError(p, stack, svcErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write binary-framed response to plugin memory:
|
||||||
|
// [0x00][4-byte content-type length (big-endian)][content-type string][raw data]
|
||||||
|
ctBytes := []byte({{lower (index .Returns 0).Name}})
|
||||||
|
frame := make([]byte, 1+4+len(ctBytes)+len({{lower (index .Returns 1).Name}}))
|
||||||
|
frame[0] = 0x00 // success
|
||||||
|
binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes)))
|
||||||
|
copy(frame[5:5+len(ctBytes)], ctBytes)
|
||||||
|
copy(frame[5+len(ctBytes):], {{lower (index .Returns 1).Name}})
|
||||||
|
|
||||||
|
respPtr, err := p.WriteBytes(frame)
|
||||||
|
if err != nil {
|
||||||
|
stack[0] = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stack[0] = respPtr
|
||||||
|
{{- else if .HasReturns}}
|
||||||
{{- if .HasError}}
|
{{- if .HasError}}
|
||||||
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||||
if svcErr != nil {
|
if svcErr != nil {
|
||||||
@@ -127,3 +162,16 @@ func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64
|
|||||||
respPtr, _ := p.WriteBytes(respBytes)
|
respPtr, _ := p.WriteBytes(respBytes)
|
||||||
stack[0] = respPtr
|
stack[0] = respPtr
|
||||||
}
|
}
|
||||||
|
{{- if .Service.HasRawMethods}}
|
||||||
|
|
||||||
|
// {{.Service.Name | lower}}WriteRawError writes a binary-framed error response to plugin memory.
|
||||||
|
// Format: [0x01][UTF-8 error message]
|
||||||
|
func {{.Service.Name | lower}}WriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||||
|
errMsg := []byte(err.Error())
|
||||||
|
frame := make([]byte, 1+len(errMsg))
|
||||||
|
frame[0] = 0x01 // error
|
||||||
|
copy(frame[1:], errMsg)
|
||||||
|
respPtr, _ := p.WriteBytes(frame)
|
||||||
|
stack[0] = respPtr
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
|||||||
@@ -173,6 +173,16 @@ func (s Service) HasErrors() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasRawMethods returns true if any method in the service uses raw binary framing.
|
||||||
|
func (s Service) HasRawMethods() bool {
|
||||||
|
for _, m := range s.Methods {
|
||||||
|
if m.Raw {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Method represents a host function method within a service.
|
// Method represents a host function method within a service.
|
||||||
type Method struct {
|
type Method struct {
|
||||||
Name string // Go method name (e.g., "Call")
|
Name string // Go method name (e.g., "Call")
|
||||||
@@ -181,6 +191,7 @@ type Method struct {
|
|||||||
Returns []Param // Return values (excluding error)
|
Returns []Param // Return values (excluding error)
|
||||||
HasError bool // Whether the method returns an error
|
HasError bool // Whether the method returns an error
|
||||||
Doc string // Documentation comment for the method
|
Doc string // Documentation comment for the method
|
||||||
|
Raw bool // If true, response uses binary framing instead of JSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// FunctionName returns the Extism host function export name.
|
// FunctionName returns the Extism host function export name.
|
||||||
@@ -332,52 +343,6 @@ type Param struct {
|
|||||||
JSONName string // JSON field name (camelCase)
|
JSONName string // JSON field name (camelCase)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsByteSlice returns true if the parameter type is []byte.
|
|
||||||
func (p Param) IsByteSlice() bool {
|
|
||||||
return p.Type == "[]byte"
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsByteSlice returns true if the field type is []byte.
|
|
||||||
func (f FieldDef) IsByteSlice() bool {
|
|
||||||
return f.Type == "[]byte"
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasByteFields returns true if any method params, returns, or struct fields use []byte.
|
|
||||||
func (s Service) HasByteFields() bool {
|
|
||||||
for _, m := range s.Methods {
|
|
||||||
for _, p := range m.Params {
|
|
||||||
if p.IsByteSlice() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range m.Returns {
|
|
||||||
if r.IsByteSlice() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, st := range s.Structs {
|
|
||||||
for _, f := range st.Fields {
|
|
||||||
if f.IsByteSlice() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasByteFields returns true if any capability struct fields use []byte.
|
|
||||||
func (c Capability) HasByteFields() bool {
|
|
||||||
for _, st := range c.Structs {
|
|
||||||
for _, f := range st.Fields {
|
|
||||||
if f.IsByteSlice() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewParam creates a Param with auto-generated JSON name.
|
// NewParam creates a Param with auto-generated JSON name.
|
||||||
func NewParam(name, typ string) Param {
|
func NewParam(name, typ string) Param {
|
||||||
return Param{
|
return Param{
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from typing import Any
|
|||||||
|
|
||||||
import extism
|
import extism
|
||||||
import json
|
import json
|
||||||
import base64
|
|
||||||
|
|
||||||
|
|
||||||
class HostFunctionError(Exception):
|
class HostFunctionError(Exception):
|
||||||
@@ -39,7 +38,7 @@ def codec_encode(data: bytes) -> bytes:
|
|||||||
HostFunctionError: If the host function returns an error.
|
HostFunctionError: If the host function returns an error.
|
||||||
"""
|
"""
|
||||||
request = {
|
request = {
|
||||||
"data": base64.b64encode(data).decode("ascii"),
|
"data": data,
|
||||||
}
|
}
|
||||||
request_bytes = json.dumps(request).encode("utf-8")
|
request_bytes = json.dumps(request).encode("utf-8")
|
||||||
request_mem = extism.memory.alloc(request_bytes)
|
request_mem = extism.memory.alloc(request_bytes)
|
||||||
@@ -50,4 +49,4 @@ def codec_encode(data: bytes) -> bytes:
|
|||||||
if response.get("error"):
|
if response.get("error"):
|
||||||
raise HostFunctionError(response["error"])
|
raise HostFunctionError(response["error"])
|
||||||
|
|
||||||
return base64.b64decode(response.get("result", ""))
|
return response.get("result", b"")
|
||||||
|
|||||||
@@ -5,34 +5,10 @@
|
|||||||
|
|
||||||
use extism_pdk::*;
|
use extism_pdk::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use base64::Engine as _;
|
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
||||||
|
|
||||||
mod base64_bytes {
|
|
||||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
|
||||||
use base64::Engine as _;
|
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
||||||
|
|
||||||
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
serializer.serialize_str(&BASE64.encode(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let s = String::deserialize(deserializer)?;
|
|
||||||
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct CodecEncodeRequest {
|
struct CodecEncodeRequest {
|
||||||
#[serde(with = "base64_bytes")]
|
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +16,6 @@ struct CodecEncodeRequest {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct CodecEncodeResponse {
|
struct CodecEncodeResponse {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[serde(with = "base64_bytes")]
|
|
||||||
result: Vec<u8>,
|
result: Vec<u8>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from typing import Any
|
|||||||
|
|
||||||
import extism
|
import extism
|
||||||
import json
|
import json
|
||||||
import base64
|
|
||||||
|
|
||||||
|
|
||||||
class HostFunctionError(Exception):
|
class HostFunctionError(Exception):
|
||||||
@@ -328,7 +327,7 @@ def comprehensive_byte_slice(data: bytes) -> bytes:
|
|||||||
HostFunctionError: If the host function returns an error.
|
HostFunctionError: If the host function returns an error.
|
||||||
"""
|
"""
|
||||||
request = {
|
request = {
|
||||||
"data": base64.b64encode(data).decode("ascii"),
|
"data": data,
|
||||||
}
|
}
|
||||||
request_bytes = json.dumps(request).encode("utf-8")
|
request_bytes = json.dumps(request).encode("utf-8")
|
||||||
request_mem = extism.memory.alloc(request_bytes)
|
request_mem = extism.memory.alloc(request_bytes)
|
||||||
@@ -339,4 +338,4 @@ def comprehensive_byte_slice(data: bytes) -> bytes:
|
|||||||
if response.get("error"):
|
if response.get("error"):
|
||||||
raise HostFunctionError(response["error"])
|
raise HostFunctionError(response["error"])
|
||||||
|
|
||||||
return base64.b64decode(response.get("result", ""))
|
return response.get("result", b"")
|
||||||
|
|||||||
@@ -5,29 +5,6 @@
|
|||||||
|
|
||||||
use extism_pdk::*;
|
use extism_pdk::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use base64::Engine as _;
|
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
||||||
|
|
||||||
mod base64_bytes {
|
|
||||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
|
||||||
use base64::Engine as _;
|
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
||||||
|
|
||||||
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
serializer.serialize_str(&BASE64.encode(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let s = String::deserialize(deserializer)?;
|
|
||||||
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -167,7 +144,6 @@ struct ComprehensiveMultipleReturnsResponse {
|
|||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ComprehensiveByteSliceRequest {
|
struct ComprehensiveByteSliceRequest {
|
||||||
#[serde(with = "base64_bytes")]
|
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +151,6 @@ struct ComprehensiveByteSliceRequest {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ComprehensiveByteSliceResponse {
|
struct ComprehensiveByteSliceResponse {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[serde(with = "base64_bytes")]
|
|
||||||
result: Vec<u8>,
|
result: Vec<u8>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
|||||||
66
plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt
vendored
Normal file
66
plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt
vendored
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Code generated by ndpgen. DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// This file contains client wrappers for the Stream host service.
|
||||||
|
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||||
|
//
|
||||||
|
//go:build wasip1
|
||||||
|
|
||||||
|
package ndpdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stream_getstream is the host function provided by Navidrome.
|
||||||
|
//
|
||||||
|
//go:wasmimport extism:host/user stream_getstream
|
||||||
|
func stream_getstream(uint64) uint64
|
||||||
|
|
||||||
|
type streamGetStreamRequest struct {
|
||||||
|
Uri string `json:"uri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamGetStream calls the stream_getstream host function.
|
||||||
|
// GetStream returns raw binary stream data with content type.
|
||||||
|
func StreamGetStream(uri string) (string, []byte, error) {
|
||||||
|
// Marshal request to JSON
|
||||||
|
req := streamGetStreamRequest{
|
||||||
|
Uri: uri,
|
||||||
|
}
|
||||||
|
reqBytes, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
reqMem := pdk.AllocateBytes(reqBytes)
|
||||||
|
defer reqMem.Free()
|
||||||
|
|
||||||
|
// Call the host function
|
||||||
|
responsePtr := stream_getstream(reqMem.Offset())
|
||||||
|
|
||||||
|
// Read the response from memory
|
||||||
|
responseMem := pdk.FindMemory(responsePtr)
|
||||||
|
responseBytes := responseMem.ReadBytes()
|
||||||
|
|
||||||
|
// Parse binary-framed response
|
||||||
|
if len(responseBytes) == 0 {
|
||||||
|
return "", nil, errors.New("empty response from host")
|
||||||
|
}
|
||||||
|
if responseBytes[0] == 0x01 { // error
|
||||||
|
return "", nil, errors.New(string(responseBytes[1:]))
|
||||||
|
}
|
||||||
|
if responseBytes[0] != 0x00 {
|
||||||
|
return "", nil, errors.New("unknown response status")
|
||||||
|
}
|
||||||
|
if len(responseBytes) < 5 {
|
||||||
|
return "", nil, errors.New("malformed raw response: incomplete header")
|
||||||
|
}
|
||||||
|
ctLen := binary.BigEndian.Uint32(responseBytes[1:5])
|
||||||
|
if uint32(len(responseBytes)) < 5+ctLen {
|
||||||
|
return "", nil, errors.New("malformed raw response: content-type overflow")
|
||||||
|
}
|
||||||
|
return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil
|
||||||
|
}
|
||||||
63
plugins/cmd/ndpgen/testdata/raw_client_expected.py
vendored
Normal file
63
plugins/cmd/ndpgen/testdata/raw_client_expected.py
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Code generated by ndpgen. DO NOT EDIT.
|
||||||
|
#
|
||||||
|
# This file contains client wrappers for the Stream host service.
|
||||||
|
# It is intended for use in Navidrome plugins built with extism-py.
|
||||||
|
#
|
||||||
|
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
|
||||||
|
# The @extism.import_fn decorators are only detected when defined in the plugin's
|
||||||
|
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Tuple
|
||||||
|
|
||||||
|
import extism
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
|
||||||
|
|
||||||
|
class HostFunctionError(Exception):
|
||||||
|
"""Raised when a host function returns an error."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@extism.import_fn("extism:host/user", "stream_getstream")
|
||||||
|
def _stream_getstream(offset: int) -> int:
|
||||||
|
"""Raw host function - do not call directly."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def stream_get_stream(uri: str) -> Tuple[str, bytes]:
|
||||||
|
"""GetStream returns raw binary stream data with content type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
uri: str parameter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (content_type, data) with the raw binary response.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HostFunctionError: If the host function returns an error.
|
||||||
|
"""
|
||||||
|
request = {
|
||||||
|
"uri": uri,
|
||||||
|
}
|
||||||
|
request_bytes = json.dumps(request).encode("utf-8")
|
||||||
|
request_mem = extism.memory.alloc(request_bytes)
|
||||||
|
response_offset = _stream_getstream(request_mem.offset)
|
||||||
|
response_mem = extism.memory.find(response_offset)
|
||||||
|
response_bytes = response_mem.bytes()
|
||||||
|
|
||||||
|
if len(response_bytes) == 0:
|
||||||
|
raise HostFunctionError("empty response from host")
|
||||||
|
if response_bytes[0] == 0x01:
|
||||||
|
raise HostFunctionError(response_bytes[1:].decode("utf-8"))
|
||||||
|
if response_bytes[0] != 0x00:
|
||||||
|
raise HostFunctionError("unknown response status")
|
||||||
|
if len(response_bytes) < 5:
|
||||||
|
raise HostFunctionError("malformed raw response: incomplete header")
|
||||||
|
ct_len = struct.unpack(">I", response_bytes[1:5])[0]
|
||||||
|
if len(response_bytes) < 5 + ct_len:
|
||||||
|
raise HostFunctionError("malformed raw response: content-type overflow")
|
||||||
|
content_type = response_bytes[5:5 + ct_len].decode("utf-8")
|
||||||
|
data = response_bytes[5 + ct_len:]
|
||||||
|
return content_type, data
|
||||||
73
plugins/cmd/ndpgen/testdata/raw_client_expected.rs
vendored
Normal file
73
plugins/cmd/ndpgen/testdata/raw_client_expected.rs
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Code generated by ndpgen. DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// This file contains client wrappers for the Stream host service.
|
||||||
|
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||||
|
|
||||||
|
use extism_pdk::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct StreamGetStreamRequest {
|
||||||
|
uri: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[host_fn]
|
||||||
|
extern "ExtismHost" {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[link(wasm_import_module = "extism:host/user")]
|
||||||
|
extern "C" {
|
||||||
|
fn stream_getstream(offset: u64) -> u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GetStream returns raw binary stream data with content type.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `uri` - String parameter.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// A tuple of (content_type, data) with the raw binary response.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the host function call fails.
|
||||||
|
pub fn get_stream(uri: &str) -> Result<(String, Vec<u8>), Error> {
|
||||||
|
let req = StreamGetStreamRequest {
|
||||||
|
uri: uri.to_owned(),
|
||||||
|
};
|
||||||
|
let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?;
|
||||||
|
let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?;
|
||||||
|
|
||||||
|
let response_offset = unsafe { stream_getstream(input_mem.offset()) };
|
||||||
|
|
||||||
|
let response_mem = Memory::find(response_offset)
|
||||||
|
.ok_or_else(|| Error::msg("empty response from host"))?;
|
||||||
|
let response_bytes = response_mem.to_vec();
|
||||||
|
|
||||||
|
if response_bytes.is_empty() {
|
||||||
|
return Err(Error::msg("empty response from host"));
|
||||||
|
}
|
||||||
|
if response_bytes[0] == 0x01 {
|
||||||
|
let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string();
|
||||||
|
return Err(Error::msg(msg));
|
||||||
|
}
|
||||||
|
if response_bytes[0] != 0x00 {
|
||||||
|
return Err(Error::msg("unknown response status"));
|
||||||
|
}
|
||||||
|
if response_bytes.len() < 5 {
|
||||||
|
return Err(Error::msg("malformed raw response: incomplete header"));
|
||||||
|
}
|
||||||
|
let ct_len = u32::from_be_bytes([
|
||||||
|
response_bytes[1],
|
||||||
|
response_bytes[2],
|
||||||
|
response_bytes[3],
|
||||||
|
response_bytes[4],
|
||||||
|
]) as usize;
|
||||||
|
if ct_len > response_bytes.len() - 5 {
|
||||||
|
return Err(Error::msg("malformed raw response: content-type overflow"));
|
||||||
|
}
|
||||||
|
let ct_end = 5 + ct_len;
|
||||||
|
let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string();
|
||||||
|
let data = response_bytes[ct_end..].to_vec();
|
||||||
|
Ok((content_type, data))
|
||||||
|
}
|
||||||
10
plugins/cmd/ndpgen/testdata/raw_service.go.txt
vendored
Normal file
10
plugins/cmd/ndpgen/testdata/raw_service.go.txt
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package testpkg
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//nd:hostservice name=Stream permission=stream
|
||||||
|
type StreamService interface {
|
||||||
|
// GetStream returns raw binary stream data with content type.
|
||||||
|
//nd:hostfunc raw=true
|
||||||
|
GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
|
||||||
"github.com/navidrome/navidrome/plugins/pdk/go/metadata"
|
"github.com/navidrome/navidrome/plugins/pdk/go/metadata"
|
||||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||||
)
|
)
|
||||||
@@ -78,28 +77,21 @@ func sparqlQuery(endpoint, query string) (*SPARQLResult, error) {
|
|||||||
form := url.Values{}
|
form := url.Values{}
|
||||||
form.Set("query", query)
|
form.Set("query", query)
|
||||||
|
|
||||||
|
req := pdk.NewHTTPRequest(pdk.MethodPost, endpoint)
|
||||||
|
req.SetHeader("Accept", "application/sparql-results+json")
|
||||||
|
req.SetHeader("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.SetHeader("User-Agent", "NavidromeWikimediaPlugin/1.0")
|
||||||
|
req.SetBody([]byte(form.Encode()))
|
||||||
|
|
||||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("SPARQL query to %s: %s", endpoint, query))
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("SPARQL query to %s: %s", endpoint, query))
|
||||||
|
|
||||||
resp, err := host.HTTPSend(host.HTTPRequest{
|
resp := req.Send()
|
||||||
Method: "POST",
|
if resp.Status() != 200 {
|
||||||
URL: endpoint,
|
return nil, fmt.Errorf("SPARQL HTTP error: status %d", resp.Status())
|
||||||
Headers: map[string]string{
|
|
||||||
"Accept": "application/sparql-results+json",
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
"User-Agent": "NavidromeWikimediaPlugin/1.0",
|
|
||||||
},
|
|
||||||
Body: []byte(form.Encode()),
|
|
||||||
TimeoutMs: 10000,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SPARQL HTTP error: %w", err)
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("SPARQL HTTP error: status %d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result SPARQLResult
|
var result SPARQLResult
|
||||||
if err := json.Unmarshal(resp.Body, &result); err != nil {
|
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse SPARQL response: %w", err)
|
return nil, fmt.Errorf("failed to parse SPARQL response: %w", err)
|
||||||
}
|
}
|
||||||
if len(result.Results.Bindings) == 0 {
|
if len(result.Results.Bindings) == 0 {
|
||||||
@@ -112,22 +104,15 @@ func sparqlQuery(endpoint, query string) (*SPARQLResult, error) {
|
|||||||
func mediawikiQuery(params url.Values) ([]byte, error) {
|
func mediawikiQuery(params url.Values) ([]byte, error) {
|
||||||
apiURL := fmt.Sprintf("%s?%s", mediawikiAPIEndpoint, params.Encode())
|
apiURL := fmt.Sprintf("%s?%s", mediawikiAPIEndpoint, params.Encode())
|
||||||
|
|
||||||
resp, err := host.HTTPSend(host.HTTPRequest{
|
req := pdk.NewHTTPRequest(pdk.MethodGet, apiURL)
|
||||||
Method: "GET",
|
req.SetHeader("Accept", "application/json")
|
||||||
URL: apiURL,
|
req.SetHeader("User-Agent", "NavidromeWikimediaPlugin/1.0")
|
||||||
Headers: map[string]string{
|
|
||||||
"Accept": "application/json",
|
resp := req.Send()
|
||||||
"User-Agent": "NavidromeWikimediaPlugin/1.0",
|
if resp.Status() != 200 {
|
||||||
},
|
return nil, fmt.Errorf("MediaWiki HTTP error: status %d", resp.Status())
|
||||||
TimeoutMs: 10000,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("MediaWiki HTTP error: %w", err)
|
|
||||||
}
|
}
|
||||||
if resp.StatusCode != 200 {
|
return resp.Body(), nil
|
||||||
return nil, fmt.Errorf("MediaWiki HTTP error: status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return resp.Body, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getWikidataWikipediaURL fetches the Wikipedia URL from Wikidata using MBID or name
|
// getWikidataWikipediaURL fetches the Wikipedia URL from Wikidata using MBID or name
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
package host
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// HTTPRequest represents an outbound HTTP request from a plugin.
|
|
||||||
type HTTPRequest struct {
|
|
||||||
Method string `json:"method"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Headers map[string]string `json:"headers,omitempty"`
|
|
||||||
Body []byte `json:"body,omitempty"`
|
|
||||||
TimeoutMs int32 `json:"timeoutMs,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTPResponse represents the response from an outbound HTTP request.
|
|
||||||
type HTTPResponse struct {
|
|
||||||
StatusCode int32 `json:"statusCode"`
|
|
||||||
Headers map[string]string `json:"headers,omitempty"`
|
|
||||||
Body []byte `json:"body,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTPService provides outbound HTTP request capabilities for plugins.
|
|
||||||
//
|
|
||||||
// This service allows plugins to make HTTP requests to external services.
|
|
||||||
// Requests are validated against the plugin's declared requiredHosts patterns
|
|
||||||
// from the http permission in the manifest. Redirects are followed but each
|
|
||||||
// redirect destination is also validated against the allowed hosts.
|
|
||||||
//
|
|
||||||
//nd:hostservice name=HTTP permission=http
|
|
||||||
type HTTPService interface {
|
|
||||||
// Send executes an HTTP request and returns the response.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - request: The HTTP request to execute, including method, URL, headers, body, and timeout
|
|
||||||
//
|
|
||||||
// Returns the HTTP response with status code, headers, and body.
|
|
||||||
// Network errors, timeouts, and permission failures are returned as Go errors.
|
|
||||||
// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error.
|
|
||||||
//nd:hostfunc
|
|
||||||
Send(ctx context.Context, request HTTPRequest) (*HTTPResponse, error)
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// Code generated by ndpgen. DO NOT EDIT.
|
|
||||||
|
|
||||||
package host
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
extism "github.com/extism/go-sdk"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HTTPSendRequest is the request type for HTTP.Send.
|
|
||||||
type HTTPSendRequest struct {
|
|
||||||
Request HTTPRequest `json:"request"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTPSendResponse is the response type for HTTP.Send.
|
|
||||||
type HTTPSendResponse struct {
|
|
||||||
Result *HTTPResponse `json:"result,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterHTTPHostFunctions registers HTTP service host functions.
|
|
||||||
// The returned host functions should be added to the plugin's configuration.
|
|
||||||
func RegisterHTTPHostFunctions(service HTTPService) []extism.HostFunction {
|
|
||||||
return []extism.HostFunction{
|
|
||||||
newHTTPSendHostFunction(service),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHTTPSendHostFunction(service HTTPService) extism.HostFunction {
|
|
||||||
return extism.NewHostFunctionWithStack(
|
|
||||||
"http_send",
|
|
||||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
|
||||||
// Read JSON request from plugin memory
|
|
||||||
reqBytes, err := p.ReadBytes(stack[0])
|
|
||||||
if err != nil {
|
|
||||||
httpWriteError(p, stack, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req HTTPSendRequest
|
|
||||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
|
||||||
httpWriteError(p, stack, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call the service method
|
|
||||||
result, svcErr := service.Send(ctx, req.Request)
|
|
||||||
if svcErr != nil {
|
|
||||||
httpWriteError(p, stack, svcErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write JSON response to plugin memory
|
|
||||||
resp := HTTPSendResponse{
|
|
||||||
Result: result,
|
|
||||||
}
|
|
||||||
httpWriteResponse(p, stack, resp)
|
|
||||||
},
|
|
||||||
[]extism.ValueType{extism.ValueTypePTR},
|
|
||||||
[]extism.ValueType{extism.ValueTypePTR},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// httpWriteResponse writes a JSON response to plugin memory.
|
|
||||||
func httpWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
|
||||||
respBytes, err := json.Marshal(resp)
|
|
||||||
if err != nil {
|
|
||||||
httpWriteError(p, stack, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respPtr, err := p.WriteBytes(respBytes)
|
|
||||||
if err != nil {
|
|
||||||
stack[0] = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stack[0] = respPtr
|
|
||||||
}
|
|
||||||
|
|
||||||
// httpWriteError writes an error response to plugin memory.
|
|
||||||
func httpWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
|
||||||
errResp := struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}{Error: err.Error()}
|
|
||||||
respBytes, _ := json.Marshal(errResp)
|
|
||||||
respPtr, _ := p.WriteBytes(respBytes)
|
|
||||||
stack[0] = respPtr
|
|
||||||
}
|
|
||||||
@@ -17,8 +17,8 @@ type SubsonicAPIService interface {
|
|||||||
Call(ctx context.Context, uri string) (responseJSON string, err error)
|
Call(ctx context.Context, uri string) (responseJSON string, err error)
|
||||||
|
|
||||||
// CallRaw executes a Subsonic API request and returns the raw binary response.
|
// CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||||
// Designed for binary endpoints like getCoverArt and stream that return
|
// Optimized for binary endpoints like getCoverArt and stream that return
|
||||||
// non-JSON data. The data is base64-encoded over JSON on the wire.
|
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
||||||
//nd:hostfunc
|
//nd:hostfunc raw=true
|
||||||
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package host
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
extism "github.com/extism/go-sdk"
|
extism "github.com/extism/go-sdk"
|
||||||
@@ -25,13 +26,6 @@ type SubsonicAPICallRawRequest struct {
|
|||||||
Uri string `json:"uri"`
|
Uri string `json:"uri"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubsonicAPICallRawResponse is the response type for SubsonicAPI.CallRaw.
|
|
||||||
type SubsonicAPICallRawResponse struct {
|
|
||||||
ContentType string `json:"contentType,omitempty"`
|
|
||||||
Data []byte `json:"data,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions.
|
// RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions.
|
||||||
// The returned host functions should be added to the plugin's configuration.
|
// The returned host functions should be added to the plugin's configuration.
|
||||||
func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction {
|
func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction {
|
||||||
@@ -82,28 +76,37 @@ func newSubsonicAPICallRawHostFunction(service SubsonicAPIService) extism.HostFu
|
|||||||
// Read JSON request from plugin memory
|
// Read JSON request from plugin memory
|
||||||
reqBytes, err := p.ReadBytes(stack[0])
|
reqBytes, err := p.ReadBytes(stack[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
subsonicapiWriteError(p, stack, err)
|
subsonicapiWriteRawError(p, stack, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req SubsonicAPICallRawRequest
|
var req SubsonicAPICallRawRequest
|
||||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||||
subsonicapiWriteError(p, stack, err)
|
subsonicapiWriteRawError(p, stack, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the service method
|
// Call the service method
|
||||||
contenttype, data, svcErr := service.CallRaw(ctx, req.Uri)
|
contenttype, data, svcErr := service.CallRaw(ctx, req.Uri)
|
||||||
if svcErr != nil {
|
if svcErr != nil {
|
||||||
subsonicapiWriteError(p, stack, svcErr)
|
subsonicapiWriteRawError(p, stack, svcErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write JSON response to plugin memory
|
// Write binary-framed response to plugin memory:
|
||||||
resp := SubsonicAPICallRawResponse{
|
// [0x00][4-byte content-type length (big-endian)][content-type string][raw data]
|
||||||
ContentType: contenttype,
|
ctBytes := []byte(contenttype)
|
||||||
Data: data,
|
frame := make([]byte, 1+4+len(ctBytes)+len(data))
|
||||||
|
frame[0] = 0x00 // success
|
||||||
|
binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes)))
|
||||||
|
copy(frame[5:5+len(ctBytes)], ctBytes)
|
||||||
|
copy(frame[5+len(ctBytes):], data)
|
||||||
|
|
||||||
|
respPtr, err := p.WriteBytes(frame)
|
||||||
|
if err != nil {
|
||||||
|
stack[0] = 0
|
||||||
|
return
|
||||||
}
|
}
|
||||||
subsonicapiWriteResponse(p, stack, resp)
|
stack[0] = respPtr
|
||||||
},
|
},
|
||||||
[]extism.ValueType{extism.ValueTypePTR},
|
[]extism.ValueType{extism.ValueTypePTR},
|
||||||
[]extism.ValueType{extism.ValueTypePTR},
|
[]extism.ValueType{extism.ValueTypePTR},
|
||||||
@@ -134,3 +137,14 @@ func subsonicapiWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
|||||||
respPtr, _ := p.WriteBytes(respBytes)
|
respPtr, _ := p.WriteBytes(respBytes)
|
||||||
stack[0] = respPtr
|
stack[0] = respPtr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// subsonicapiWriteRawError writes a binary-framed error response to plugin memory.
|
||||||
|
// Format: [0x01][UTF-8 error message]
|
||||||
|
func subsonicapiWriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||||
|
errMsg := []byte(err.Error())
|
||||||
|
frame := make([]byte, 1+len(errMsg))
|
||||||
|
frame[0] = 0x01 // error
|
||||||
|
copy(frame[1:], errMsg)
|
||||||
|
respPtr, _ := p.WriteBytes(frame)
|
||||||
|
stack[0] = respPtr
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user